mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 22:55:25 +00:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d846548c8 | |||
| 8e4c94a4e5 | |||
| 854c448bad | |||
| 64a0b0f169 | |||
| 13ff22b6a8 | |||
| 8927e8fbbb | |||
| 25e2b40d47 | |||
| a60af8ff9c | |||
| 27b328cfd7 | |||
| fa6ef0c01c | |||
| 6217a2ed3b | |||
| ffa866cb99 | |||
| 011aa931cf | |||
| 68308154b5 | |||
| 36af03f4d5 | |||
| cbc71a56b6 | |||
| 1420180220 | |||
| e1b2cf6d81 | |||
| c0e9c49679 | |||
| afc4bd4415 | |||
| bcfa8d8b19 | |||
| 43766d6519 | |||
| 42c17f5c74 | |||
| d655d3eece | |||
| 3500de95e8 | |||
| 7f03b94949 | |||
| f0cf060191 | |||
| 5a137e7b14 | |||
| 28f7fbaa8a | |||
| ff96bb5787 | |||
| 87841124e0 | |||
| a650b58276 | |||
| 388b20d44f | |||
| ea7e94fc51 | |||
| d30bcbabdb | |||
| fb858a1941 | |||
| cc2e615b9a | |||
| a862cde6b4 | |||
| e2b7bec162 | |||
| c1b577ecc9 | |||
| ba7ec14f76 | |||
| 9f0117723c | |||
| f72b6a20db | |||
| 2f798209bb | |||
| eec8f16424 | |||
| 885264f132 | |||
| f1a6dec951 | |||
| 44f41510fa | |||
| f220229e5e | |||
| 61b4cb8d7c | |||
| 185cdb4883 | |||
| 2cdba53451 | |||
| 950f6c36cf | |||
| ea37977b1e | |||
| 81da82aed7 | |||
| 39a3518651 | |||
| d903273759 | |||
| d130532f69 | |||
| 94fcd21f34 | |||
| 1d2db4ae2b | |||
| d270b4af9b | |||
| ccfbd3c1a8 | |||
| 5c4923557f | |||
| b1f608b865 | |||
| c7993756dd | |||
| d02465823f | |||
| 6b9f6b5b79 | |||
| df7de8fb0c | |||
| 72f5ce3985 | |||
| 04c80262e2 | |||
| 56d1b8fe2d | |||
| 054a1634ab | |||
| 6ff63f2db6 | |||
| b5ad0ce1e5 | |||
| 2ba7e54c81 | |||
| 0a858e74ad | |||
| b0c534fc2c | |||
| 250c194a02 | |||
| 5b10cde936 | |||
| 4aeb81bfa5 | |||
| 955cebf944 | |||
| 7c951ef437 | |||
| 68f0dbcc35 | |||
| 39c4f76aea | |||
| 2fe58e8cbb | |||
| 765141a4d8 | |||
| eb1bd3aa01 | |||
| b7284b0223 | |||
| 5bbd254742 | |||
| 611e28626a | |||
| 2a3bb0eb22 | |||
| 6816f0fa5a | |||
| 17386776ec | |||
| 9ee9082614 | |||
| 334cfdd522 | |||
| 8c961a5dbd | |||
| 4708956e77 | |||
| 813dd03dbb | |||
| 0522dbc61e | |||
| acb9f53156 | |||
| 6016846156 | |||
| cf1fae5837 | |||
| 7212345466 | |||
| e38caf78ff | |||
| 0a8dd67541 | |||
| ed684c19a9 | |||
| 52ab3681e3 | |||
| 9cc5a787e9 | |||
| 0003030bf5 | |||
| 9ad8f45d38 | |||
| a3855257c8 | |||
| 9169c2f053 | |||
| abe028bd38 | |||
| 12c2451f4f | |||
| 8daede5427 | |||
| 4c89aa60eb | |||
| d0a3f64ae4 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Initial version of the embed SDK.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve `Button` and `ButtonGroup` styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix scroll of page outline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support "objectivec" as alias for Objective C syntax
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support custom AI providers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/browser-types": minor
|
||||
---
|
||||
|
||||
First version of the public package for typing script integrations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix AI Search follow-up question closing search
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Small fixes to search modal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fixes to `PageAside`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Tweaks to AIChatButton and AIChatInput
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow integrations to provide tools to the Docs Assistant
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve vertical alignment of site items and fix floating page aside
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix search bar layout shift caused by ToC
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Emit a <link rel="alternate"> for the markdown version of a page
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Support new coverDefinitionDark for cards & image type
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/icons": minor
|
||||
---
|
||||
|
||||
Update icons set to the latest version
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Clear AI chat properly
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix bug in search highlight in GBO
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Start routes for embeddable version of the assistant and docs pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Page outline: scroll to active item
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve OpenAPI parsing errors
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/browser-types": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Custom assistants followup
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Custom logo not rendering on the published site
|
||||
@@ -1,37 +0,0 @@
|
||||
name: 'Setup Playwright'
|
||||
description: 'Install Playwright and dependencies'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Run npm ci and get Playwright version
|
||||
- name: 🏗 Prepare Playwright env
|
||||
shell: bash
|
||||
run: |
|
||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
||||
|
||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
||||
- name: 🧰 Cache Playwright browser binaries
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: '~/.cache/ms-playwright'
|
||||
key: '${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}'
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
# Install browser binaries & OS dependencies if cache missed
|
||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
bun x playwright install --with-deps chromium
|
||||
|
||||
# Install only the OS dependencies if cache hit
|
||||
- name: 🏗 Install Playwright OS dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
bun x playwright install-deps
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2
|
||||
needs: deploy-v2-vercel
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -110,8 +110,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -131,8 +131,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2
|
||||
needs: deploy-v2-vercel
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -152,8 +152,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e-customers
|
||||
env:
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -173,8 +173,8 @@ jobs:
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e-customers
|
||||
env:
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts"
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
+2
-1
@@ -34,7 +34,8 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.128.0"
|
||||
"@gitbook/api": "^0.136.0",
|
||||
"bidc": "^0.0.2"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,3 @@
|
||||
# `@gitbook/browser-types`
|
||||
|
||||
Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@gitbook/browser-types",
|
||||
"description": "Typescript types for the global variables available in a GitBook website. These types can be used by integrations embedding scripts.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||
|
||||
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||
|
||||
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
* The input is provided by the AI assistant following the input schema of the tool.
|
||||
*/
|
||||
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||
};
|
||||
|
||||
export type GitBookAssistant = {
|
||||
/**
|
||||
* Name of the assistant displayed in the UI.
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Icon of the assistant displayed in the UI.
|
||||
* Any FontAwesome icon name is supported.
|
||||
* @example 'sparkle'
|
||||
*/
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* Callback when the assistant is opened.
|
||||
*/
|
||||
open: (query?: string) => void;
|
||||
|
||||
/**
|
||||
* Whether to display the triggers for this assistant in the UI.
|
||||
* @default true
|
||||
*/
|
||||
ui?: boolean;
|
||||
};
|
||||
|
||||
export type GitBookGlobal = {
|
||||
/**
|
||||
* Register an event listener.
|
||||
*/
|
||||
addEventListener: (
|
||||
type: GitBookIntegrationEvent,
|
||||
func: GitBookIntegrationEventCallback
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Remove an event listener.
|
||||
*/
|
||||
removeEventListener: (
|
||||
type: GitBookIntegrationEvent,
|
||||
func: GitBookIntegrationEventCallback
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Register a custom tool to be exposed to the AI assistant.
|
||||
*/
|
||||
registerTool: (tool: GitBookIntegrationTool) => void;
|
||||
|
||||
/**
|
||||
* Register a custom assistant to be available on the site.
|
||||
*/
|
||||
registerAssistant: (assistant: GitBookAssistant) => () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Global `window.GitBook` object accessible by integrations.
|
||||
*/
|
||||
GitBook?: GitBookGlobal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export function getCacheTag(
|
||||
| {
|
||||
tag: 'translation';
|
||||
organization: string;
|
||||
translationSettings: string;
|
||||
translation: string;
|
||||
}
|
||||
): string {
|
||||
switch (spec.tag) {
|
||||
@@ -115,7 +115,7 @@ export function getCacheTag(
|
||||
case 'openapi':
|
||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||
case 'translation':
|
||||
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
||||
return `organization:${spec.organization}:translation:${spec.translation}`;
|
||||
default:
|
||||
assertNever(spec);
|
||||
}
|
||||
@@ -144,6 +144,10 @@ export function getComputedContentSourceCacheTags(
|
||||
) {
|
||||
const tags: string[] = [];
|
||||
|
||||
if (!('dependencies' in source)) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
// We add the dependencies as tags, to ensure that the computed content is invalidated
|
||||
// when the dependencies are updated.
|
||||
const dependencies = Object.values(source.dependencies ?? {});
|
||||
@@ -167,12 +171,12 @@ export function getComputedContentSourceCacheTags(
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'translation-language':
|
||||
case 'translation':
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'translation',
|
||||
organization: inContext.organizationId,
|
||||
translationSettings: dependency.ref.translationSettings,
|
||||
translation: dependency.ref.translation,
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
standalone/
|
||||
@@ -0,0 +1,49 @@
|
||||
# `@gitbook/embed`
|
||||
|
||||
Embed the GitBook Docs Assistant in your product or website.
|
||||
|
||||
# Usage
|
||||
|
||||
## As a script from your docs site
|
||||
|
||||
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
||||
|
||||
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||
|
||||
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
||||
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||
<script>
|
||||
window.GitBook('show');
|
||||
</script>
|
||||
```
|
||||
|
||||
## As a package from NPM
|
||||
|
||||
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||
|
||||
```tsx
|
||||
import { createGitBook } from '@gitbook/embed';
|
||||
|
||||
const gitbook = createGitBook({
|
||||
siteURL: 'https://docs.company.com'
|
||||
});
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = gitbook.getFrameURL();
|
||||
|
||||
const frame = gitbook.createFrame(iframe);
|
||||
```
|
||||
|
||||
## As React components
|
||||
|
||||
After installing the NPM package, you can import prebuilt React components:
|
||||
|
||||
```tsx
|
||||
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||
|
||||
<GitBookProvider siteURL="https://docs.company.com">
|
||||
<GitBookAssistantFrame />
|
||||
</GitBookProvider>
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@gitbook/embed",
|
||||
"description": "Embeddable components for GitBook",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js",
|
||||
"standalone": "./dist/standalone/index.js",
|
||||
"react": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type GitBookFrameClient, createGitBookFrame } from './createGitBookFrame';
|
||||
|
||||
export type CreateGitBookOptions = {
|
||||
/**
|
||||
* URL of the GitBook site to embed.
|
||||
*/
|
||||
siteURL: string;
|
||||
};
|
||||
|
||||
export type GetFrameURLOptions = {
|
||||
/**
|
||||
* Authentication to use for the frame.
|
||||
*/
|
||||
visitor?: {
|
||||
/**
|
||||
* Signed JWT token for Adaptive Content or Visitor Authentication to use.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* Unsigned claims to pass to the frame.
|
||||
* You can use these claims in dynamic expressions using `visitor.claims.unsigned.<claim-name>`.
|
||||
*/
|
||||
unsignedClaims?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GitBookClient = {
|
||||
/**
|
||||
* Get the URL for a GitBook frame.
|
||||
*/
|
||||
getFrameURL: (options: GetFrameURLOptions) => string;
|
||||
/**
|
||||
* Create a new GitBook frame.
|
||||
*/
|
||||
createFrame: (iframe: HTMLIFrameElement) => GitBookFrameClient;
|
||||
};
|
||||
|
||||
export function createGitBook(options: CreateGitBookOptions) {
|
||||
const client: GitBookClient = {
|
||||
getFrameURL: (frameOptions) => {
|
||||
const url = new URL(options.siteURL);
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||
|
||||
if (frameOptions.visitor?.token) {
|
||||
url.searchParams.set('token', frameOptions.visitor.token);
|
||||
}
|
||||
|
||||
if (frameOptions.visitor?.unsignedClaims) {
|
||||
Object.entries(frameOptions.visitor.unsignedClaims).forEach(([key, value]) => {
|
||||
url.searchParams.set(`visitor.${key}`, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
},
|
||||
createFrame: (iframe) => createGitBookFrame(iframe),
|
||||
};
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createChannel } from 'bidc';
|
||||
import type {
|
||||
FrameToParentMessage,
|
||||
GitBookPlaceholderSettings,
|
||||
GitBookToolDefinition,
|
||||
ParentToFrameMessage,
|
||||
} from './protocol';
|
||||
|
||||
export type GitBookFrameClient = {
|
||||
/**
|
||||
* Navigate to a page by its path.
|
||||
*/
|
||||
navigateToPage: (path: string) => void;
|
||||
|
||||
/**
|
||||
* Navigate to the assistant.
|
||||
*/
|
||||
navigateToAssistant: () => void;
|
||||
|
||||
/**
|
||||
* Post a message to the chat.
|
||||
*/
|
||||
postUserMessage: (message: string) => void;
|
||||
|
||||
/**
|
||||
* Register a custom tool.
|
||||
*/
|
||||
registerTool: (tool: GitBookToolDefinition) => void;
|
||||
|
||||
/**
|
||||
* Clear the chat.
|
||||
*/
|
||||
clearChat: () => void;
|
||||
|
||||
/**
|
||||
* Set the placeholder settings.
|
||||
*/
|
||||
setPlaceholder: (placeholder: GitBookPlaceholderSettings) => void;
|
||||
|
||||
/**
|
||||
* Register an event listener.
|
||||
*/
|
||||
on: (event: string, listener: (...args: any[]) => void) => () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a client to communicate with the GitBook Assistant frame.
|
||||
*/
|
||||
export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClient {
|
||||
if (!iframe.contentWindow) {
|
||||
throw new Error('Iframe must have a content window');
|
||||
}
|
||||
const channel = createChannel(iframe.contentWindow);
|
||||
|
||||
channel.receive((message: FrameToParentMessage) => {
|
||||
if (message.type === 'close') {
|
||||
const listeners = events.get('close') || [];
|
||||
if (listeners) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sendToFrame = (message: ParentToFrameMessage) => {
|
||||
channel.send(message);
|
||||
};
|
||||
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
return {
|
||||
navigateToPage: (pagePath) => {
|
||||
sendToFrame({ type: 'navigateToPage', pagePath });
|
||||
},
|
||||
navigateToAssistant: () => {
|
||||
sendToFrame({ type: 'navigateToAssistant' });
|
||||
},
|
||||
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
|
||||
registerTool: (tool) => sendToFrame({ type: 'registerTool', tool }),
|
||||
clearChat: () => sendToFrame({ type: 'clearChat' }),
|
||||
setPlaceholder: (settings) => sendToFrame({ type: 'setPlaceholder', settings }),
|
||||
on: (event, listener) => {
|
||||
const listeners = events.get(event) || [];
|
||||
listeners.push(listener);
|
||||
events.set(event, listeners);
|
||||
return () => {
|
||||
events.set(
|
||||
event,
|
||||
listeners.filter((l) => l !== listener)
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './createGitBook';
|
||||
export * from './createGitBookFrame';
|
||||
export * from './protocol';
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Custom tool definition to be passed to the AI assistant.
|
||||
*/
|
||||
export type GitBookToolDefinition = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
*/
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
* The input is provided by the AI assistant following the input schema of the tool.
|
||||
*/
|
||||
execute: (input: object) => Promise<Pick<AIToolCallResult, 'output' | 'summary'>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder settings.
|
||||
*/
|
||||
export type GitBookPlaceholderSettings = {
|
||||
/**
|
||||
* Welcome message to be displayed in the placeholder.
|
||||
*/
|
||||
welcomeMessage: string;
|
||||
|
||||
/**
|
||||
* Suggestions to be displayed in the placeholder.
|
||||
*/
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Messages sent from the parent to the frame.
|
||||
*/
|
||||
export type ParentToFrameMessage =
|
||||
| {
|
||||
type: 'postUserMessage';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'registerTool';
|
||||
tool: GitBookToolDefinition;
|
||||
}
|
||||
| {
|
||||
type: 'clearChat';
|
||||
}
|
||||
| {
|
||||
type: 'setPlaceholder';
|
||||
settings: GitBookPlaceholderSettings;
|
||||
}
|
||||
| {
|
||||
type: 'navigateToPage';
|
||||
pagePath: string;
|
||||
}
|
||||
| {
|
||||
type: 'navigateToAssistant';
|
||||
};
|
||||
|
||||
/**
|
||||
* Messages sent from the frame to the parent.
|
||||
*/
|
||||
export type FrameToParentMessage = {
|
||||
type: 'close';
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './client';
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import type { GetFrameURLOptions, GitBookFrameClient } from '../client';
|
||||
import { useGitBook } from './GitBookProvider';
|
||||
|
||||
export type GitBookAssistantFrameProps = {
|
||||
title?: string;
|
||||
className?: string;
|
||||
} & GetFrameURLOptions;
|
||||
|
||||
/**
|
||||
* Render a frame with the GitBook Assistant in it.
|
||||
*/
|
||||
export function GitBookAssistantFrame(props: GitBookAssistantFrameProps) {
|
||||
const { title, className, ...frameOptions } = props;
|
||||
|
||||
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const gitbookFrameRef = React.useRef<GitBookFrameClient | null>(null);
|
||||
const gitbook = useGitBook();
|
||||
const frameURL = gitbook.getFrameURL(frameOptions);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (frameRef.current) {
|
||||
gitbookFrameRef.current = gitbook.createFrame(frameRef.current);
|
||||
}
|
||||
}, [gitbook]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<iframe
|
||||
title={title ?? 'GitBook Assistant'}
|
||||
ref={frameRef}
|
||||
src={frameURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type CreateGitBookOptions, createGitBook } from '../client';
|
||||
import { GitBookContext } from './context';
|
||||
|
||||
/**
|
||||
* Provider for the GitBook client.
|
||||
*/
|
||||
export function GitBookProvider(props: React.PropsWithChildren<CreateGitBookOptions>) {
|
||||
const { siteURL, children } = props;
|
||||
|
||||
const options = React.useMemo(
|
||||
() => ({
|
||||
siteURL,
|
||||
}),
|
||||
[siteURL]
|
||||
);
|
||||
|
||||
const client = React.useMemo(() => createGitBook(options), [options]);
|
||||
|
||||
return <GitBookContext.Provider value={client}>{children}</GitBookContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the GitBook client.
|
||||
*/
|
||||
export function useGitBook() {
|
||||
const context = React.useContext(GitBookContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('This component must be used within a <GitBookProvider />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { GitBookClient } from '../client';
|
||||
|
||||
export const GitBookContext = React.createContext<GitBookClient | null>(null);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './GitBookProvider';
|
||||
@@ -0,0 +1,158 @@
|
||||
import './style.css';
|
||||
|
||||
import {
|
||||
type CreateGitBookOptions,
|
||||
type GetFrameURLOptions,
|
||||
type GitBookClient,
|
||||
type GitBookFrameClient,
|
||||
type GitBookPlaceholderSettings,
|
||||
type GitBookToolDefinition,
|
||||
createGitBook,
|
||||
} from '../client';
|
||||
|
||||
export type GitBook = () => void;
|
||||
|
||||
type StandaloneCalls =
|
||||
// Initialize the widget
|
||||
| ['init', CreateGitBookOptions, GetFrameURLOptions]
|
||||
// Unload the widget
|
||||
| ['unload']
|
||||
// Show the widget
|
||||
| ['show']
|
||||
// Hide the widget
|
||||
| ['hide']
|
||||
// Open the window
|
||||
| ['open']
|
||||
// Close the window
|
||||
| ['close']
|
||||
// Toggle the window
|
||||
| ['toggle']
|
||||
// Post a user message
|
||||
| ['postUserMessage', string]
|
||||
// Register a tool
|
||||
| ['registerTool', GitBookToolDefinition]
|
||||
// Clear the chat
|
||||
| ['clearChat']
|
||||
// Configure the placeholder
|
||||
| ['setPlaceholder', GitBookPlaceholderSettings]
|
||||
// Navigate to a page
|
||||
| ['navigateToPage', string]
|
||||
// Navigate to the assistant
|
||||
| ['navigateToAssistant'];
|
||||
|
||||
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||
q?: StandaloneCalls[];
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
widgetButton.id = 'gitbook-widget-button';
|
||||
widgetButton.addEventListener('click', () => {
|
||||
GitBook('toggle');
|
||||
});
|
||||
widgetButton.innerHTML = `
|
||||
<span id="gitbook-widget-button-icon"></span>
|
||||
<span id="gitbook-widget-button-label">Ask</span>
|
||||
`;
|
||||
|
||||
const widgetWindow = document.createElement('div');
|
||||
widgetWindow.id = 'gitbook-widget-window';
|
||||
widgetWindow.classList.add('hidden');
|
||||
|
||||
document.body.appendChild(widgetButton);
|
||||
document.body.appendChild(widgetWindow);
|
||||
|
||||
let widgetIframe: HTMLIFrameElement | undefined;
|
||||
let _client: GitBookClient | undefined;
|
||||
let _frame: GitBookFrameClient | undefined;
|
||||
let frameOptions: GetFrameURLOptions | undefined;
|
||||
|
||||
function getClient() {
|
||||
if (!_client) {
|
||||
throw new Error(
|
||||
'GitBook client not initialized. Call GitBook("init", { siteURL: "..." }) first.'
|
||||
);
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getIframe() {
|
||||
if (!widgetIframe || !_frame) {
|
||||
const client = getClient();
|
||||
|
||||
widgetIframe?.remove();
|
||||
widgetIframe = document.createElement('iframe');
|
||||
widgetIframe.id = 'gitbook-widget-iframe';
|
||||
widgetIframe.src = client.getFrameURL({
|
||||
...frameOptions,
|
||||
});
|
||||
widgetWindow.appendChild(widgetIframe);
|
||||
|
||||
_frame = client.createFrame(widgetIframe);
|
||||
}
|
||||
return { iframe: widgetIframe, frame: _frame };
|
||||
}
|
||||
|
||||
const GitBook = (...args: StandaloneCalls) => {
|
||||
switch (args[0]) {
|
||||
case 'init':
|
||||
if (_client) {
|
||||
throw new Error(
|
||||
'GitBook client already initialized. Call GitBook("unload") first.'
|
||||
);
|
||||
}
|
||||
_client = createGitBook(args[1]);
|
||||
frameOptions = args[2];
|
||||
break;
|
||||
case 'unload':
|
||||
_client = undefined;
|
||||
_frame = undefined;
|
||||
widgetIframe?.remove();
|
||||
widgetWindow.classList.add('hidden');
|
||||
break;
|
||||
case 'show':
|
||||
widgetButton.classList.remove('hidden');
|
||||
break;
|
||||
case 'hide':
|
||||
widgetButton.classList.add('hidden');
|
||||
break;
|
||||
case 'open':
|
||||
widgetWindow.classList.remove('hidden');
|
||||
widgetButton.classList.add('open');
|
||||
getIframe();
|
||||
break;
|
||||
case 'toggle':
|
||||
widgetWindow.classList.toggle('hidden');
|
||||
widgetButton.classList.toggle('open');
|
||||
getIframe();
|
||||
break;
|
||||
case 'close':
|
||||
widgetWindow.classList.add('hidden');
|
||||
widgetButton.classList.remove('open');
|
||||
break;
|
||||
case 'postUserMessage':
|
||||
getIframe().frame.postUserMessage(args[1]);
|
||||
break;
|
||||
case 'registerTool':
|
||||
getIframe().frame.registerTool(args[1]);
|
||||
break;
|
||||
case 'clearChat':
|
||||
getIframe().frame.clearChat();
|
||||
break;
|
||||
case 'setPlaceholder':
|
||||
getIframe().frame.setPlaceholder(args[1]);
|
||||
break;
|
||||
case 'navigateToPage':
|
||||
getIframe().frame.navigateToPage(args[1]);
|
||||
break;
|
||||
case 'navigateToAssistant':
|
||||
getIframe().frame.navigateToAssistant();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-expect-error - GitBook is not defined in the global scope
|
||||
const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||
|
||||
// @ts-expect-error - GitBook is not defined in the global scope
|
||||
window.GitBook = GitBook;
|
||||
precalls.forEach((call) => GitBook(...call));
|
||||
@@ -0,0 +1,170 @@
|
||||
:root {
|
||||
--gitbook-widget-top: 1rem;
|
||||
--gitbook-widget-bottom: 1rem;
|
||||
--gitbook-widget-right: 1rem;
|
||||
|
||||
--gitbook-widget-button-height: 46px;
|
||||
|
||||
--gitbook-widget-radius: .5rem;
|
||||
--gitbook-widget-text-size: 1rem;
|
||||
--gitbook-widget-text-color: #656973;
|
||||
--gitbook-widget-border-color: #e5e5e5;
|
||||
|
||||
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
|
||||
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
|
||||
--gitbook-widget-background-solid: #FFFFFF;
|
||||
--gitbook-widget-background-solid-hover: #FBFBFB;
|
||||
|
||||
--gitbook-widget-icon-size: 1.25rem;
|
||||
|
||||
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||
--gitbook-widget-window-height: 40rem; /* 640px */
|
||||
--gitbook-widget-window-spacing: .5rem; /* Spacing between the button and the window */
|
||||
--gitbook-widget-window-bottom: calc(var(--gitbook-widget-bottom) + var(--gitbook-widget-button-height) + var(--gitbook-widget-window-spacing));
|
||||
|
||||
--gitbook-widget-transition-duration-fast: 0.2s;
|
||||
--gitbook-widget-transition-duration-slow: 0.5s;
|
||||
--gitbook-widget-easing: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
#gitbook-widget-button {
|
||||
position: fixed;
|
||||
bottom: var(--gitbook-widget-bottom);
|
||||
right: var(--gitbook-widget-right);
|
||||
height: var(--gitbook-widget-button-height);
|
||||
z-index: 9999;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: .5rem;
|
||||
padding: .75rem 1rem;
|
||||
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--gitbook-widget-border-color);
|
||||
background-color: var(--gitbook-widget-background-translucent);
|
||||
backdrop-filter: blur(16px);
|
||||
|
||||
font-size: var(--gitbook-widget-text-size);
|
||||
color: var(--gitbook-widget-text-color);
|
||||
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||
transition: all var(--gitbook-widget-transition-duration-fast) var(--gitbook-widget-easing);
|
||||
|
||||
cursor:pointer;
|
||||
animation: gitbook-widget-present var(--gitbook-widget-transition-duration-slow) var(--gitbook-widget-easing-bounce);
|
||||
}
|
||||
|
||||
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||
background-color: var(--gitbook-widget-background-translucent-hover);
|
||||
}
|
||||
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||||
}
|
||||
#gitbook-widget-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05), 0 1px 2px -1px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
#gitbook-widget-button {
|
||||
background-color: var(--gitbook-widget-background-solid);
|
||||
}
|
||||
#gitbook-widget-button:hover, #gitbook-widget-button:focus-visible {
|
||||
background-color: var(--gitbook-widget-background-solid-hover);
|
||||
}
|
||||
}
|
||||
|
||||
#gitbook-widget-button.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#gitbook-widget-button.open {
|
||||
padding: .75rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Button: Icon */
|
||||
#gitbook-widget-button-icon {
|
||||
width: var(--gitbook-widget-icon-size);
|
||||
height: var(--gitbook-widget-icon-size);
|
||||
mask-image: url("https://static-2v.gitbook.com/~gitbook/static/icons/svgs/custom-icons/gitbook-assistant.svg?v=2");
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||
}
|
||||
|
||||
/* Button: Label */
|
||||
#gitbook-widget-button.open #gitbook-widget-button-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Window */
|
||||
#gitbook-widget-window {
|
||||
position: fixed;
|
||||
bottom: var(--gitbook-widget-window-bottom);
|
||||
right: var(--gitbook-widget-right);
|
||||
z-index: 9998;
|
||||
width: var(--gitbook-widget-window-width);
|
||||
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||
background-color: var(--gitbook-widget-background-solid);
|
||||
border: 1px solid var(--gitbook-widget-border-color);
|
||||
border-radius: var(--gitbook-widget-radius);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
overflow: hidden;
|
||||
transition-property: transform, opacity, display;
|
||||
transition-duration: var(--gitbook-widget-transition-duration-slow);
|
||||
transition-timing-function: var(--gitbook-widget-easing-bounce);
|
||||
transform-origin: bottom right;
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#gitbook-widget-window {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
body:has(#gitbook-widget-button.hidden) #gitbook-widget-window {
|
||||
bottom: var(--gitbook-widget-bottom);
|
||||
}
|
||||
|
||||
#gitbook-widget-window.hidden {
|
||||
transition-property: transform, opacity, display;
|
||||
transition-duration: var(--gitbook-widget-transition-duration-fast);
|
||||
transition-timing-function: var(--gitbook-widget-easing);
|
||||
transition-behavior: allow-discrete;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
#gitbook-widget-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@keyframes gitbook-widget-present {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +1,72 @@
|
||||
# gitbook
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d130532: Suggest questions in the current space context
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 44f4151: Fix close buttons tooltips
|
||||
- d903273: Strip visitor params from URL
|
||||
- df7de8f: Fix an issue where links were not rendering properly in Ask AI answers
|
||||
- d270b4a: All tracking is now disabled for dynamic routes such as the site preview.
|
||||
- b1f608b: Implement basic URL scheme for assistant with ask
|
||||
- d024658: Prevent page breaks inside blocks when printing
|
||||
- 185cdb4: Fix scrolling to anchor positionning
|
||||
- 5c49235: Remove highlighting in Safari for PowerShell and C++ to avoid page crash until next version with bug fix is released
|
||||
- f1a6dec: Update OpenAPI parser
|
||||
- 2cdba53: Upgrade to Tailwind v4
|
||||
- Updated dependencies [2cdba53]
|
||||
- Updated dependencies [f1a6dec]
|
||||
- @gitbook/react-math@0.6.1
|
||||
- @gitbook/icons@0.2.2
|
||||
- @gitbook/openapi-parser@3.0.0
|
||||
- @gitbook/react-contentkit@0.7.3
|
||||
- @gitbook/react-openapi@1.3.5
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7c951ef: 'Support new colors in text formatting
|
||||
- 39c4f76: Add fullwidth page option
|
||||
- eb1bd3a: AI response feedback buttons
|
||||
- 2ba7e54: Fix headings styles in hint block
|
||||
- 250c194: Add nosnippet to announcement banner.
|
||||
- 4aeb81b: Fix search input styling & text cutoff
|
||||
- 68f0dbc: Add confirmation modal to contentkit button
|
||||
- 611e286: Remove padding on first Heading in Columns
|
||||
- b0c534f: Tweaks to AI Chat and AI Actions Dropdown
|
||||
- Updated dependencies [b5ad0ce]
|
||||
- @gitbook/openapi-parser@2.2.2
|
||||
- @gitbook/react-openapi@1.3.4
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0003030: Implement AI actions dropdown
|
||||
- acb9f53: New search layout
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 334cfdd: Fix responsive class for SearchInput button
|
||||
- 6816f0f: add browserlists and fix old browser css-masks
|
||||
- 9ee9082: Refactor icon loading state in AIAction components
|
||||
- 52ab368: Reposition AI Actions dropdown
|
||||
- 7212345: Fix AI Actions dropdown and LLM integration
|
||||
- 8daede5: Ensure all content links are resolved relatively to preview.
|
||||
- ed684c1: Move AI Actions markdown fetching to client-side
|
||||
- 9cc5a78: Generalise keyboard shortcuts, add Cmd+J to AI Chat
|
||||
- Updated dependencies [6816f0f]
|
||||
- Updated dependencies [1738677]
|
||||
- @gitbook/icons@0.2.1
|
||||
- @gitbook/openapi-parser@2.2.1
|
||||
- @gitbook/react-contentkit@0.7.2
|
||||
- @gitbook/react-openapi@1.3.3
|
||||
|
||||
## 0.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CustomizationAIMode,
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type Test,
|
||||
type TestsCase,
|
||||
allDeprecatedThemePresets,
|
||||
allLocales,
|
||||
@@ -34,6 +36,179 @@ import {
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const searchTestCases: Test[] = [
|
||||
{
|
||||
name: 'Search - AI Mode: None - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
|
||||
// Fill search input, expecting search results
|
||||
await searchInput.fill('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
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.
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: None - Keyboard shortcut',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.keyboard.press('ControlOrMeta+K');
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: None - URL query (Initial)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
})}&q=`,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search - AI Mode: None - URL query (Results)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||
// - Search - AI Mode: Search - Complete flow
|
||||
// - Search - AI Mode: Search - URL query (Initial)
|
||||
{
|
||||
name: 'Search - AI Mode: Search - URL query (Results)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
||||
// - Ask - AI Mode: Search - URL query (Ask initial)
|
||||
// - Ask - AI Mode: Search - URL query (Ask results)
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
|
||||
// await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
// const recommendedQuestions = await page
|
||||
// .getByTestId('search-recommended-question')
|
||||
// .all();
|
||||
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill('What is gitbook?');
|
||||
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||
await expect(aiSearchResult).toBeVisible();
|
||||
await aiSearchResult.click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Button',
|
||||
url: getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Results)',
|
||||
url: `${getCustomizationURL({
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=What+is+GitBook%3F`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
|
||||
'What is GitBook?'
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'GitBook Site (Single Variant)',
|
||||
@@ -53,34 +228,7 @@ const testCases: TestsCase[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
url: '?q=',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const allItems = await page.getByTestId('search-result-item').all();
|
||||
// Expect at least 3 questions
|
||||
await expect(allItems.length).toBeGreaterThan(2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search Results',
|
||||
url: '?q=gitbook',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -279,34 +427,7 @@ const testCases: TestsCase[] = [
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
url: '?q=',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
const allItems = await page.getByTestId('search-result-item').all();
|
||||
// Expect at least 3 questions
|
||||
await expect(allItems.length).toBeGreaterThan(2);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search Results',
|
||||
url: '?q=gitbook',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -415,6 +536,28 @@ const testCases: TestsCase[] = [
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With sections',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const sectionTabs = page.getByLabel('Sections');
|
||||
await expect(sectionTabs).toBeVisible();
|
||||
|
||||
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\/?/);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -910,6 +1053,22 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'Page actions',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Without page actions',
|
||||
url: getCustomizationURL({
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ads',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
|
||||
@@ -318,10 +318,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
feedback: {
|
||||
enabled: false,
|
||||
},
|
||||
// TODO: remove aiSearch once the cache has been fully updated (after 11/07/2025)
|
||||
aiSearch: {
|
||||
enabled: true,
|
||||
},
|
||||
ai: {
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
@@ -337,6 +333,10 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
pagination: {
|
||||
enabled: true,
|
||||
},
|
||||
pageActions: {
|
||||
externalAI: true,
|
||||
markdown: true,
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@ const nextConfig = {
|
||||
dynamic: 3600, // 1 hour
|
||||
static: 3600, // 1 hour
|
||||
},
|
||||
|
||||
// Since content is fully static, we don't want to fetch on hover again
|
||||
optimisticClientCache: false,
|
||||
},
|
||||
|
||||
env: {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/ini
|
||||
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
|
||||
//Only needed to run locally, in prod we'll use the one from do.js
|
||||
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||
|
||||
// Only needed to run locally, in prod we'll use the one from do.js
|
||||
export class R2WriteBuffer extends DurableObject {
|
||||
writePromise;
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"env": {
|
||||
"dev": {
|
||||
"vars": {
|
||||
"STAGE": "dev"
|
||||
"STAGE": "dev",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -35,13 +36,17 @@
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache"
|
||||
}
|
||||
]
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["R2WriteBuffer"]
|
||||
"new_sqlite_classes": ["R2WriteBuffer", "DOShardedTagCache"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -84,6 +89,9 @@
|
||||
}
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
@@ -125,7 +133,8 @@
|
||||
"vars": {
|
||||
// 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"
|
||||
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -7,12 +7,37 @@ export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js
|
||||
|
||||
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||
|
||||
//Format used by the tail logger
|
||||
const formatLog = (requestDuration, responseCode, responseCache, responseRoute) =>
|
||||
`%${JSON.stringify({ requestDuration, responseCode, responseCache, responseRoute })}%[main] Response Done`;
|
||||
|
||||
const getResolvedRoute = (reqOrResp, defaultValue) => {
|
||||
try {
|
||||
const resolvedRoutes = JSON.parse(
|
||||
reqOrResp.headers.get('x-opennext-resolved-routes') ?? ''
|
||||
)[0].route;
|
||||
return resolvedRoutes ?? defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export default class extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||
const startTime = Date.now();
|
||||
// - `Request`s are handled by the Next server
|
||||
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||
if (reqOrResp instanceof Response) {
|
||||
const duration = Date.now() - startTime;
|
||||
const logMessage = formatLog(
|
||||
duration,
|
||||
reqOrResp.status,
|
||||
reqOrResp.headers.get('x-opennext-cache') ?? 'MISS',
|
||||
getResolvedRoute(reqOrResp, 'middleware')
|
||||
);
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
console.log(logMessage);
|
||||
return reqOrResp;
|
||||
}
|
||||
|
||||
@@ -22,17 +47,31 @@ export default class extends WorkerEntrypoint {
|
||||
'Cloudflare-Workers-Version-Overrides',
|
||||
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
|
||||
);
|
||||
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||
const response = await this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||
redirect: 'manual',
|
||||
cf: {
|
||||
cacheEverything: false,
|
||||
},
|
||||
});
|
||||
const formatedLog = formatLog(
|
||||
Date.now() - startTime,
|
||||
response.status,
|
||||
`SERVER-${response.headers.get('x-nextjs-cache') ?? 'MISS'}`,
|
||||
getResolvedRoute(reqOrResp, 'unresolved')
|
||||
);
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
console.log(formatedLog);
|
||||
|
||||
return response;
|
||||
}
|
||||
// If we are in preview mode, we need to send the request to the preview URL
|
||||
const modifiedUrl = new URL(reqOrResp.url);
|
||||
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
||||
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
||||
return fetch(nextRequest, {
|
||||
// We never want to follow the redirects here.
|
||||
// Redirects are supposed to happen from the client.
|
||||
redirect: 'manual',
|
||||
cf: {
|
||||
cacheEverything: false,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
"dev": {
|
||||
"vars": {
|
||||
"STAGE": "dev",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
||||
// When deployed locally, we don't have access to the tag cache here,
|
||||
// we should just bypass the cache to go to the server directly
|
||||
"SHOULD_BYPASS_CACHE": "true",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -85,7 +90,8 @@
|
||||
"staging": {
|
||||
"vars": {
|
||||
"STAGE": "staging",
|
||||
"WORKER_VERSION_ID": "TO_REPLACE"
|
||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
@@ -153,7 +159,8 @@
|
||||
// TODO: remove this once the issue is fixed
|
||||
"DEBUG_CLOUDFLARE": "true",
|
||||
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||
"STAGE": "production"
|
||||
"STAGE": "production",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type {
|
||||
CacheEntryType,
|
||||
CacheValue,
|
||||
@@ -34,41 +32,42 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
cacheType?: CacheType
|
||||
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
return trace(
|
||||
{
|
||||
operation: 'openNextIncrementalCacheGet',
|
||||
name: cacheKey,
|
||||
},
|
||||
async (span) => {
|
||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
try {
|
||||
// Check local cache first if available
|
||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||
if (localCacheEntry) {
|
||||
span.setAttribute('cacheHit', 'local');
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404(result);
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
if (!r2Object) return null;
|
||||
|
||||
span.setAttribute('cacheHit', 'r2');
|
||||
return this.returnNullOn404({
|
||||
value: await r2Object.json(),
|
||||
lastModified: r2Object.uploaded.getTime(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to get from cache', e);
|
||||
return null;
|
||||
}
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
if (process.env.SHOULD_BYPASS_CACHE === 'true') {
|
||||
// We are in a local middleware environment, we should bypass the cache
|
||||
// and go directly to the server.
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// Check local cache first if available
|
||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||
if (localCacheEntry) {
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404({
|
||||
...result,
|
||||
// Because we use tag cache and also invalidate them every time,
|
||||
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
|
||||
// this should improve performance even further, and reduce costs
|
||||
shouldBypassTagCache: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
if (!r2Object) return null;
|
||||
|
||||
return this.returnNullOn404({
|
||||
value: await r2Object.json(),
|
||||
lastModified: r2Object.uploaded.getTime(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to get from cache', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: This is a workaround to handle 404 responses in the cache.
|
||||
@@ -89,91 +88,84 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
cacheType?: CacheType
|
||||
): Promise<void> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
return trace(
|
||||
{
|
||||
operation: 'openNextIncrementalCacheSet',
|
||||
name: cacheKey,
|
||||
},
|
||||
async (span) => {
|
||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||
const localCache = await this.getCacheInstance();
|
||||
|
||||
try {
|
||||
await this.writeToR2(cacheKey, JSON.stringify(value));
|
||||
const localCache = await this.getCacheInstance();
|
||||
|
||||
//TODO: Check if there is any places where we don't have tags
|
||||
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||
];
|
||||
try {
|
||||
await this.writeToR2(cacheKey, JSON.stringify(value));
|
||||
|
||||
// We consider R2 as the source of truth, so we update the local cache
|
||||
// only after a successful R2 write
|
||||
await localCache.put(
|
||||
this.getCacheUrlKey(cacheKey),
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value,
|
||||
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||
// In theory we should always get the `revalidate` value
|
||||
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||
'cache-tag': tags.join(','),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to set to cache', e);
|
||||
}
|
||||
}
|
||||
);
|
||||
//TODO: Check if there is any places where we don't have tags
|
||||
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||
];
|
||||
|
||||
// We consider R2 as the source of truth, so we update the local cache
|
||||
// only after a successful R2 write
|
||||
await localCache.put(
|
||||
this.getCacheUrlKey(cacheKey),
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value,
|
||||
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||
// In theory we should always get the `revalidate` value
|
||||
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||
'cache-tag': tags.join(','),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to set to cache', e);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const cacheKey = this.getR2Key(key);
|
||||
return trace(
|
||||
{
|
||||
operation: 'openNextIncrementalCacheDelete',
|
||||
name: cacheKey,
|
||||
},
|
||||
async () => {
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
|
||||
try {
|
||||
await r2.delete(cacheKey);
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
|
||||
// Here again R2 is the source of truth, so we delete from local cache first
|
||||
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||
} catch (e) {
|
||||
console.error('Failed to delete from cache', e);
|
||||
}
|
||||
}
|
||||
);
|
||||
try {
|
||||
await r2.delete(cacheKey);
|
||||
|
||||
// Here again R2 is the source of truth, so we delete from local cache first
|
||||
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||
} catch (e) {
|
||||
console.error('Failed to delete from cache', e);
|
||||
}
|
||||
}
|
||||
|
||||
async writeToR2(key: string, value: string): Promise<void> {
|
||||
const env = getCloudflareContext().env as {
|
||||
WRITE_BUFFER: DurableObjectNamespace<
|
||||
Rpc.DurableObjectBranded & {
|
||||
write: (key: string, value: string) => Promise<void>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const id = env.WRITE_BUFFER.idFromName(key);
|
||||
try {
|
||||
const env = getCloudflareContext().env as {
|
||||
WRITE_BUFFER: DurableObjectNamespace<
|
||||
Rpc.DurableObjectBranded & {
|
||||
write: (key: string, value: string) => Promise<void>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
const id = env.WRITE_BUFFER.idFromName(key);
|
||||
|
||||
// A stub is a client used to invoke methods on the Durable Object
|
||||
const stub = env.WRITE_BUFFER.get(id);
|
||||
// A stub is a client used to invoke methods on the Durable Object
|
||||
const stub = env.WRITE_BUFFER.get(id);
|
||||
|
||||
await stub.write(key, value);
|
||||
await stub.write(key, value);
|
||||
} catch {
|
||||
// We fallback to writing directly to R2
|
||||
// it can fail locally because the limit is 1Mb per args
|
||||
// It is 32Mb in production, so we should be fine
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
r2?.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheInstance(): Promise<Cache> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||
@@ -6,9 +5,7 @@ import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||
export default {
|
||||
name: 'GitbookISRQueue',
|
||||
send: async (msg) => {
|
||||
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
|
||||
const { ctx } = getCloudflareContext();
|
||||
ctx.waitUntil(doQueue.send(msg));
|
||||
});
|
||||
const { ctx } = getCloudflareContext();
|
||||
ctx.waitUntil(doQueue.send(msg));
|
||||
},
|
||||
} satisfies Queue;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getLogger } from '@/lib/logger';
|
||||
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||
|
||||
export default {
|
||||
name: 'GitbookISRQueue',
|
||||
send: async (msg) => {
|
||||
const logger = getLogger().subLogger('GitbookISRQueue');
|
||||
// We should never reach this point in the server. If that's the case, we should log it.
|
||||
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
|
||||
logger.warn('send called on server side, this should not happen.', msg);
|
||||
},
|
||||
} satisfies Queue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { trace } from '@/lib/tracing';
|
||||
import { createLogger, getLogger } from '@/lib/logger';
|
||||
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||
@@ -6,8 +6,9 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
|
||||
const originalTagCache = doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
// We can probably increase this value even further
|
||||
regionalCacheTtlSec: 60,
|
||||
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
|
||||
// Because we invalidate the Cache API on update, we can safely set this to true
|
||||
regionalCacheDangerouslyPersistMissingTags: true,
|
||||
shardReplication: {
|
||||
numberOfSoftReplicas: 2,
|
||||
numberOfHardReplicas: 1,
|
||||
@@ -20,62 +21,39 @@ const originalTagCache = doShardedTagCache({
|
||||
export default {
|
||||
name: 'GitbookTagCache',
|
||||
mode: 'nextMode',
|
||||
// We don't really use this one, as of now it is only used for soft tags
|
||||
getLastRevalidated: async (tags: string[]) => {
|
||||
const tagsToCheck = tags.filter(softTagFilter);
|
||||
if (tagsToCheck.length === 0) {
|
||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
||||
console.warn(
|
||||
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
|
||||
tags
|
||||
);
|
||||
return 0; // If no tags to check, return 0
|
||||
}
|
||||
return trace(
|
||||
{
|
||||
operation: 'gitbookTagCacheGetLastRevalidated',
|
||||
name: tagsToCheck.join(', '),
|
||||
},
|
||||
async () => {
|
||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||
}
|
||||
);
|
||||
|
||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||
},
|
||||
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||
const tagsToCheck = tags.filter(softTagFilter);
|
||||
if (tagsToCheck.length === 0) {
|
||||
// If we reach here, it probably means that there is an issue that we'll need to address.
|
||||
console.warn(
|
||||
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
|
||||
tags
|
||||
);
|
||||
return false; // If no tags to check, return false
|
||||
}
|
||||
return trace(
|
||||
{
|
||||
operation: 'gitbookTagCacheHasBeenRevalidated',
|
||||
name: tagsToCheck.join(', '),
|
||||
},
|
||||
async () => {
|
||||
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
||||
return result;
|
||||
try {
|
||||
const tagsToCheck = tags.filter(softTagFilter);
|
||||
if (tagsToCheck.length === 0) {
|
||||
return false; // If no tags to check, return false
|
||||
}
|
||||
);
|
||||
|
||||
return await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
||||
} catch (e) {
|
||||
createLogger('gitbookTagCache', {}).error(
|
||||
`hasBeenRevalidated - Error checking tags ${tags.join(', ')}`,
|
||||
e
|
||||
);
|
||||
return false; // In case of error, return false
|
||||
}
|
||||
},
|
||||
writeTags: async (tags: string[]) => {
|
||||
return trace(
|
||||
{
|
||||
operation: 'gitbookTagCacheWriteTags',
|
||||
name: tags.join(', '),
|
||||
},
|
||||
async () => {
|
||||
const tagsToWrite = tags.filter(softTagFilter);
|
||||
if (tagsToWrite.length === 0) {
|
||||
console.warn('writeTags - No valid tags to write');
|
||||
return; // If no tags to write, exit early
|
||||
}
|
||||
// Write only the filtered tags
|
||||
await originalTagCache.writeTags(tagsToWrite);
|
||||
}
|
||||
);
|
||||
const tagsToWrite = tags.filter(softTagFilter);
|
||||
if (tagsToWrite.length === 0) {
|
||||
const logger = getLogger().subLogger('gitbookTagCache');
|
||||
logger.warn('writeTags - No valid tags to write');
|
||||
return; // If no tags to write, exit early
|
||||
}
|
||||
|
||||
await originalTagCache.writeTags(tagsToWrite);
|
||||
},
|
||||
} satisfies NextModeTagCache;
|
||||
|
||||
@@ -1,79 +1,82 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.13.1",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@opennextjs/cloudflare": "1.2.1",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1",
|
||||
"object-identity": "^0.1.2",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.6.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "^15.5.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"parse-cache-control": "^1.0.1",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^3.2.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"warn-once": "^0.1.1",
|
||||
"zustand": "^5.0.3",
|
||||
"image-size": "^2.0.2",
|
||||
"direction": "^2.0.1"
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.5",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
@@ -85,32 +88,33 @@
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/rison": "^0.0.9",
|
||||
"autoprefixer": "^10",
|
||||
"deepmerge": "^4.3.1",
|
||||
"env-cmd": "^10.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math && wrangler types",
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
||||
"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 --turbopack",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"build:cloudflare": "opennextjs-cloudflare build",
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@ export default defineConfig({
|
||||
['@argos-ci/playwright/reporter', { uploadToArgos: !!process.env.CI }],
|
||||
],
|
||||
projects: [
|
||||
// To speed up CI, we only test on Chrome.
|
||||
// https://github.com/microsoft/playwright/issues/14434
|
||||
// https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
},
|
||||
},
|
||||
],
|
||||
use: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
rm -rf ./.next
|
||||
rm -rf ./public/~gitbook/static/icons
|
||||
rm -rf ./public/~gitbook/static/math
|
||||
rm -rf ./public/~gitbook/static/embed
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Copy the assets
|
||||
gitbook-icons ./public/~gitbook/static/icons custom-icons
|
||||
gitbook-math ./public/~gitbook/static/math
|
||||
cp -r ../embed/standalone/ ./public/~gitbook/static/embed
|
||||
|
||||
# Generate the types
|
||||
wrangler types
|
||||
+4
-2
@@ -5,8 +5,9 @@ import {
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env';
|
||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
interface SiteDynamicLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -18,13 +19,14 @@ export default async function SiteDynamicLayout({
|
||||
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
|
||||
return <EmbeddableAssistantPage context={context} />;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import {
|
||||
EmbeddableRootLayout,
|
||||
generateEmbeddableMetadata,
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
|
||||
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>;
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableViewport({ context });
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableMetadata({ context });
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
|
||||
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
|
||||
}
|
||||
+3
-2
@@ -5,7 +5,7 @@ import {
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -16,12 +16,13 @@ export default async function SiteStaticLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
/**
|
||||
* This route serves a quick demo to test the script.js script.
|
||||
* It is not used in production.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
|
||||
return new Response(
|
||||
`
|
||||
<html>
|
||||
<head></head>
|
||||
<body></body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
|
||||
return <EmbeddableAssistantPage context={context} />;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import {
|
||||
EmbeddableRootLayout,
|
||||
generateEmbeddableMetadata,
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
|
||||
return <EmbeddableRootLayout context={context}>{children}</EmbeddableRootLayout>;
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableViewport({ context });
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
return generateEmbeddableMetadata({ context });
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type RouteParams, getPagePathFromParams } from '@/app/utils';
|
||||
import { EmbeddableDocsPage, generateEmbeddableDocsPageMetadata } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
return generateEmbeddableDocsPageMetadata({ context, pageParams: { pathname } });
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import type { CreateGitBookOptions } from '@gitbook/embed';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
/**
|
||||
* This route is used to serve the assistant.js script.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getEmbeddableStaticContext(await params);
|
||||
const maxAge = 7 * 24 * 60 * 60;
|
||||
const initOptions: CreateGitBookOptions = {
|
||||
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
|
||||
};
|
||||
|
||||
return new Response(
|
||||
`
|
||||
(function () {
|
||||
const w = window;
|
||||
const gb = w.GitBook;
|
||||
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
|
||||
|
||||
if (typeof gb === "function") {
|
||||
gb('init', initOptions);
|
||||
} else {
|
||||
var d = document;
|
||||
|
||||
var g = function () {
|
||||
g.c(arguments);
|
||||
};
|
||||
g.q = [];
|
||||
g.c = function (args) {
|
||||
g.q.push(args);
|
||||
};
|
||||
w.GitBook = g;
|
||||
|
||||
g('init', initOptions);
|
||||
|
||||
const load = function () {
|
||||
const style = document.createElement('link');
|
||||
style.rel = 'stylesheet';
|
||||
style.href = ${JSON.stringify(getAssetURL('embed/index.css'))};
|
||||
document.head.appendChild(style);
|
||||
|
||||
const script = d.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.async = true;
|
||||
script.src = ${JSON.stringify(getAssetURL('embed/index.js'))};
|
||||
|
||||
var latestScript = d.getElementsByTagName('script')[0];
|
||||
latestScript.parentNode.insertBefore(script, latestScript);
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
load();
|
||||
} else if (w.attachEvent) {
|
||||
w.attachEvent('onload', load);
|
||||
} else {
|
||||
w.addEventListener('load', load, false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': `public, max-age=${maxAge}, immutable`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getLogger } from '@/lib/logger';
|
||||
import { withVerifySignature } from '@/lib/routes';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
@@ -12,6 +13,7 @@ interface JsonBody {
|
||||
* The body should be a JSON with { tags: string[] }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const logger = getLogger().subLogger('revalidate');
|
||||
return withVerifySignature<JsonBody>(req, async (body) => {
|
||||
if (!body.tags || !Array.isArray(body.tags)) {
|
||||
return NextResponse.json(
|
||||
@@ -23,8 +25,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
body.tags.forEach((tag) => {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
||||
console.log(`Revalidating tag: ${tag}`);
|
||||
logger.log(`Revalidating tag: ${tag}`);
|
||||
revalidateTag(tag);
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './useAI';
|
||||
export * from './useAIChat';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIMessage } from '@gitbook/api';
|
||||
import { DocumentView } from '../../DocumentView';
|
||||
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
||||
@@ -13,24 +14,31 @@ export function AIMessageView(
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
) {
|
||||
const { message, context, renderToolCalls = true } = props;
|
||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{message.steps.map((step, index) => {
|
||||
return (
|
||||
<div key={index} className="flex animate-[fadeIn_500ms_both] flex-col gap-2">
|
||||
<div
|
||||
key={index}
|
||||
className={tcls(
|
||||
'flex animate-fade-in-slow flex-col gap-2',
|
||||
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||
)}
|
||||
>
|
||||
<DocumentView
|
||||
document={step.content}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
wrapBlocksInSuspense: false,
|
||||
shouldRenderLinkPreviews: true,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style={['space-y-4']}
|
||||
style="mt-2 space-y-4 empty:hidden"
|
||||
/>
|
||||
{renderToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
|
||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type {
|
||||
AIToolCall,
|
||||
AIToolCallGetPageContent,
|
||||
AIToolCallGetPages,
|
||||
AIToolCallMCP,
|
||||
AIToolCallOther,
|
||||
AIToolCallSearch,
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
@@ -37,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
|
||||
const { toolCall, context } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-sm text-tint-subtle">
|
||||
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
|
||||
<Icon
|
||||
icon={getIconForToolCall(toolCall)}
|
||||
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
||||
@@ -57,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||
case 'mcp':
|
||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||
case 'other':
|
||||
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
|
||||
default:
|
||||
return <>{toolCall.tool}</>;
|
||||
}
|
||||
@@ -111,6 +115,15 @@ function DescriptionForMCPToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForOtherToolCall(props: {
|
||||
toolCall: AIToolCallOther;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall } = props;
|
||||
|
||||
return <p>{toolCall.summary.text}</p>;
|
||||
}
|
||||
|
||||
async function DescriptionForSearchToolCall(props: {
|
||||
toolCall: AIToolCallSearch;
|
||||
context: GitBookSiteContext;
|
||||
@@ -144,69 +157,80 @@ async function DescriptionForSearchToolCall(props: {
|
||||
})
|
||||
);
|
||||
|
||||
const hasResults = toolCall.results.length > 0;
|
||||
|
||||
return (
|
||||
<details className="-ml-5 group flex w-full flex-col gap-2">
|
||||
<summary className="-mx-2 -mt-2 flex cursor-pointer list-none items-center gap-2 rounded-corners:rounded-md py-2 pr-4 pl-7 transition-colors marker:hidden hover:bg-primary-hover">
|
||||
<div className="flex flex-col">
|
||||
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
|
||||
<summary
|
||||
className={tcls(
|
||||
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
|
||||
hasResults ? '-my-2 cursor-pointer py-2 hover:bg-primary-hover' : ''
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col leading-snug">
|
||||
<p>{t(language, 'searched_for', <strong>{toolCall.query}</strong>)}</p>
|
||||
<p className="text-tint-subtle text-xs">
|
||||
{toolCall.results.length
|
||||
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||
{hasResults
|
||||
? t(language, 'search_results_count', toolCall.results.length)
|
||||
: t(language, 'search_no_results')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
||||
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
</div>
|
||||
{hasResults ? (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="block group-open:hidden">{t(language, 'view')}</span>
|
||||
<span className="hidden group-open:block">{t(language, 'close')}</span>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 shrink-0 transition-transform group-open:rotate-90"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</summary>
|
||||
<div className="max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all duration-500 [transition-behavior:allow-discrete] group-open:max-h-96 group-open:opacity-11">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
key={`${result.pageId}-${index}`}
|
||||
className="animate-fadeIn"
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={result.href}
|
||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||
{hasResults ? (
|
||||
<div className="mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
key={`${result.pageId}-${index}`}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon="memo"
|
||||
className="mt-1 size-3 shrink-0 text-tint-subtle"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-tint">
|
||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.title}
|
||||
/>
|
||||
</h3>
|
||||
{result.description && (
|
||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||
<Link
|
||||
href={result.href}
|
||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||
>
|
||||
<Icon
|
||||
icon="memo"
|
||||
className="mt-1 size-3 shrink-0 text-tint-subtle"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-tint">
|
||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.description}
|
||||
text={result.title}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="ml-auto size-3 shrink-0 self-center"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</h3>
|
||||
{result.description && (
|
||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.description}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="ml-auto size-3 shrink-0 self-center"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -235,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
|
||||
return 'magnifying-glass';
|
||||
case 'getPages':
|
||||
return 'files';
|
||||
case 'other':
|
||||
return (toolCall.summary.icon as IconName) ?? 'hammer';
|
||||
default:
|
||||
return 'hammer';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
import type { GitBookBaseContext } from '@/lib/context';
|
||||
import { fetchServerActionSiteContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessage,
|
||||
AIMessageRole,
|
||||
@@ -19,97 +20,100 @@ export async function streamRenderAIMessage(
|
||||
rawStream: AsyncIterable<AIStreamResponse>,
|
||||
options?: RenderAIMessageOptions
|
||||
) {
|
||||
const message: AIMessage = {
|
||||
id: '',
|
||||
role: AIMessageRole.Assistant,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const updateProcessingMessageStep = (
|
||||
stepIndex: number,
|
||||
callback: (step: AIMessageStep) => void
|
||||
) => {
|
||||
if (stepIndex > message.steps.length) {
|
||||
throw new Error(
|
||||
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.steps[stepIndex]) {
|
||||
message.steps = [...message.steps];
|
||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||
callback(message.steps[stepIndex]);
|
||||
} else {
|
||||
message.steps = [
|
||||
...message.steps,
|
||||
{
|
||||
content: {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
callback(message.steps[stepIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the full-context in the background to avoid blocking the stream.
|
||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||
|
||||
return parseResponse<{
|
||||
content: React.ReactNode;
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (event) => {
|
||||
switch (event.type) {
|
||||
/**
|
||||
* The agent is processing a tool call in a new message.
|
||||
*/
|
||||
case 'response_tool_call': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
step.toolCalls ??= [];
|
||||
step.toolCalls.push(event.toolCall);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent is writing the content of a new message.
|
||||
*/
|
||||
case 'response_reasoning':
|
||||
case 'response_document': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
const container = event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||
|
||||
step[container] ??= {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
};
|
||||
step[container] = {
|
||||
...step[container],
|
||||
nodes: [...step[container].nodes],
|
||||
};
|
||||
if (event.operation === 'insert') {
|
||||
step[container].nodes.push(...event.blocks);
|
||||
} else {
|
||||
step[container].nodes.splice(
|
||||
-event.blocks.length,
|
||||
event.blocks.length,
|
||||
...event.blocks
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
content: (
|
||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||
),
|
||||
return traceErrorOnly('AI.streamRenderAIMessage', async () => {
|
||||
const message: AIMessage = {
|
||||
id: '',
|
||||
role: AIMessageRole.Assistant,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const updateProcessingMessageStep = (
|
||||
stepIndex: number,
|
||||
callback: (step: AIMessageStep) => void
|
||||
) => {
|
||||
if (stepIndex > message.steps.length) {
|
||||
throw new Error(
|
||||
`Step index out of bounds ${stepIndex} (${message.steps.length} steps)`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.steps[stepIndex]) {
|
||||
message.steps = [...message.steps];
|
||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||
callback(message.steps[stepIndex]);
|
||||
} else {
|
||||
message.steps = [
|
||||
...message.steps,
|
||||
{
|
||||
content: {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
callback(message.steps[stepIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the full-context in the background to avoid blocking the stream.
|
||||
const promiseContext = fetchServerActionSiteContext(baseContext);
|
||||
|
||||
return parseResponse<{
|
||||
content: React.ReactNode;
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (event) => {
|
||||
switch (event.type) {
|
||||
/**
|
||||
* The agent is processing a tool call in a new message.
|
||||
*/
|
||||
case 'response_tool_call': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
step.toolCalls ??= [];
|
||||
step.toolCalls.push(event.toolCall);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent is writing the content of a new message.
|
||||
*/
|
||||
case 'response_reasoning':
|
||||
case 'response_document': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
const container =
|
||||
event.type === 'response_reasoning' ? 'reasoning' : 'content';
|
||||
|
||||
step[container] ??= {
|
||||
object: 'document',
|
||||
data: {},
|
||||
nodes: [],
|
||||
};
|
||||
step[container] = {
|
||||
...step[container],
|
||||
nodes: [...step[container].nodes],
|
||||
};
|
||||
if (event.operation === 'insert') {
|
||||
step[container].nodes.push(...event.blocks);
|
||||
} else {
|
||||
step[container].nodes.splice(
|
||||
-event.blocks.length,
|
||||
event.blocks.length,
|
||||
...event.blocks
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
content: (
|
||||
<AIMessageView message={message} context={await promiseContext} {...options} />
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
'use server';
|
||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import {
|
||||
type AIMessageContext,
|
||||
AIMessageRole,
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
|
||||
@@ -12,32 +20,48 @@ export async function* streamAIChatResponse({
|
||||
message,
|
||||
messageContext,
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
options,
|
||||
}: {
|
||||
message: string;
|
||||
message?: string;
|
||||
messageContext: AIMessageContext;
|
||||
previousResponseId?: string;
|
||||
toolCall?: AIToolCallResult;
|
||||
tools?: AIToolDefinition[];
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
let context = await getServerActionBaseContext();
|
||||
if (options?.asEmbeddable) {
|
||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||
}
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamAiResponseInSite(siteURLData.organization, siteURLData.site, {
|
||||
mode: 'assistant',
|
||||
input: [
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: message,
|
||||
context: messageContext,
|
||||
},
|
||||
],
|
||||
output: { type: 'document' },
|
||||
model: AIModel.ReasoningLow,
|
||||
previousResponseId,
|
||||
});
|
||||
input: message
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: message,
|
||||
context: messageContext,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
model: AIModel.ReasoningLow,
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
}
|
||||
);
|
||||
|
||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
||||
return await streamRenderAIMessage(context, rawStream, options);
|
||||
});
|
||||
|
||||
for await (const output of stream) {
|
||||
yield output;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './responses';
|
||||
export * from './chat';
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
'use server';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
|
||||
/**
|
||||
* Stream an existing AI responses.
|
||||
*/
|
||||
export async function* streamAIResponseById({
|
||||
responseId,
|
||||
options,
|
||||
}: {
|
||||
responseId: string;
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const api = await context.dataFetcher.api();
|
||||
const rawStream = api.orgs.streamExistingAiResponseInSite(
|
||||
siteURLData.organization,
|
||||
siteURLData.site,
|
||||
responseId
|
||||
);
|
||||
const { stream } = await streamRenderAIMessage(context, rawStream, options);
|
||||
|
||||
for await (const output of stream) {
|
||||
yield output;
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,17 @@ export type RenderAIMessageOptions = {
|
||||
* Render the tool calls.
|
||||
* @default true
|
||||
*/
|
||||
renderToolCalls?: boolean;
|
||||
withToolCalls?: boolean;
|
||||
|
||||
/**
|
||||
* Render the link previews.
|
||||
* @default true
|
||||
*/
|
||||
withLinkPreviews?: boolean;
|
||||
|
||||
/**
|
||||
* Generate links in the context of an embeddable view.
|
||||
* @default false
|
||||
*/
|
||||
asEmbeddable?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSearch } from '../Search/useSearch';
|
||||
|
||||
// Unify assistants configuration context with the assistants hook in one place
|
||||
export type AIConfig = {
|
||||
aiMode: CustomizationAIMode;
|
||||
trademark: boolean;
|
||||
};
|
||||
|
||||
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||
/**
|
||||
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
|
||||
* @example '123e4567-e89b-12d3-a456-426614174000'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display mode for the assistant. Currently, only `overlay` is supported for custom assistants.
|
||||
* - `overlay`: Display the assistant in an overlay on top of the page.
|
||||
* - `sidebar`: Display the assistant in a sidebar next to the page. Only supported for the GitBook Assistant.
|
||||
* - `search`: Display the assistant inside the search container. Only supported for GitBook AI Search.
|
||||
* @default 'overlay'
|
||||
*/
|
||||
mode?: 'overlay' | 'sidebar' | 'search';
|
||||
|
||||
/**
|
||||
* Whether the assistant is displayed in the page action menu.
|
||||
* @default false
|
||||
*/
|
||||
pageAction: boolean;
|
||||
|
||||
/**
|
||||
* Icon of the assistant displayed in the UI.
|
||||
*/
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const AIContext = React.createContext<AIConfig | null>(null);
|
||||
|
||||
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
||||
const { aiMode, trademark, children } = props;
|
||||
const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
|
||||
function useAIConfig(): AIConfig {
|
||||
const ctx = React.useContext(AIContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAI must be used within AIContextProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
type AIContext = {
|
||||
config: AIConfig;
|
||||
assistants: Assistant[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified assistants list combining the built-in GitBook Assistant (when enabled)
|
||||
* with any custom assistants registered at runtime.
|
||||
*/
|
||||
export function useAI(): AIContext {
|
||||
const config = useAIConfig();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
const assistants: Assistant[] = [];
|
||||
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
chatController.open();
|
||||
if (query) {
|
||||
chatController.postMessage({ message: query });
|
||||
}
|
||||
},
|
||||
pageAction: true,
|
||||
ui: true,
|
||||
mode: 'sidebar',
|
||||
});
|
||||
} else if (config.aiMode === CustomizationAIMode.Search) {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<Icon icon="search" className="size-4" />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="absolute top-[2.5px] left-[2.6px] size-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, query: null, ask: query, open: true } : null
|
||||
);
|
||||
}
|
||||
},
|
||||
pageAction: false,
|
||||
ui: false,
|
||||
mode: 'search',
|
||||
});
|
||||
}
|
||||
|
||||
const integrationAssistants = useIntegrationAssistants();
|
||||
if (integrationAssistants.length > 0) {
|
||||
assistants.push(
|
||||
...integrationAssistants.map((assistant) => ({
|
||||
...assistant,
|
||||
icon: <Icon icon={assistant.icon as IconName} className="size-4" />,
|
||||
open: (query?: string) => {
|
||||
setSearchState((prev) => ({
|
||||
ask: null, // Reset ask as we assume the assistant will handle it
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
assistant.open(query);
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
assistants,
|
||||
};
|
||||
}
|
||||
@@ -2,15 +2,42 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { streamAIChatResponse } from './server-actions';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { useSearch } from '../Search';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
|
||||
export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
content: React.ReactNode;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type AIChatSize = 'default' | 'large';
|
||||
|
||||
export type AIChatPendingTool = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Confirm the tool call by calling this function.
|
||||
*/
|
||||
confirm: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Tool call result to cancel it.
|
||||
*/
|
||||
cancelToolCall: AIToolCallResult;
|
||||
};
|
||||
|
||||
export type AIChatState = {
|
||||
@@ -24,6 +51,16 @@ export type AIChatState = {
|
||||
*/
|
||||
responseId: string | null;
|
||||
|
||||
/**
|
||||
* The latest query sent to the AI.
|
||||
*/
|
||||
query: string | null;
|
||||
|
||||
/**
|
||||
* The first query sent to the AI. This is appended to the URL when the AI chat is opened.
|
||||
*/
|
||||
initialQuery: string | null;
|
||||
|
||||
/**
|
||||
* Messages in the session.
|
||||
*/
|
||||
@@ -34,6 +71,11 @@ export type AIChatState = {
|
||||
*/
|
||||
followUpSuggestions: string[];
|
||||
|
||||
/**
|
||||
* Tools that are pending confirmation to be executed.
|
||||
*/
|
||||
pendingTools: AIChatPendingTool[];
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
*/
|
||||
@@ -45,39 +87,41 @@ export type AIChatState = {
|
||||
* display an error alert. Clearing the conversation will reset this flag.
|
||||
*/
|
||||
error: boolean;
|
||||
|
||||
/**
|
||||
* The size of the chat window.
|
||||
*/
|
||||
size: AIChatSize;
|
||||
};
|
||||
|
||||
export type AIChatController = {
|
||||
/** Open the dialog */
|
||||
open: () => void;
|
||||
|
||||
/** Close the dialog */
|
||||
close: () => void;
|
||||
|
||||
/** Post a message to the session */
|
||||
postMessage: (input: {
|
||||
/** The message to post to the session. it can be markdown formatted. */
|
||||
message: string;
|
||||
}) => void;
|
||||
|
||||
postMessage: (input: { message: string }) => void;
|
||||
/** Clear the conversation */
|
||||
clear: () => void;
|
||||
/** Toggle the size of the chat window */
|
||||
setSize: (size: AIChatSize) => void;
|
||||
};
|
||||
|
||||
const globalState = zustand.create<{
|
||||
state: AIChatState;
|
||||
setState: (fn: (state: AIChatState) => Partial<AIChatState>) => void;
|
||||
}>((set) => {
|
||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||
|
||||
// Global state store for AI chat
|
||||
export const globalState = zustand.create<AIChatState>(() => {
|
||||
return {
|
||||
state: {
|
||||
opened: false,
|
||||
responseId: null,
|
||||
messages: [],
|
||||
followUpSuggestions: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
},
|
||||
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
|
||||
opened: false,
|
||||
responseId: null,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
size: 'default',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -85,116 +129,354 @@ const globalState = zustand.create<{
|
||||
* Get the current state of the AI chat.
|
||||
*/
|
||||
export function useAIChatState(): AIChatState {
|
||||
const state = zustand.useStore(globalState, (state) => state.state);
|
||||
const state = zustand.useStore(globalState);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the controller to interact with the AI chat.
|
||||
* Provide the controller to interact with the AI chat.
|
||||
*/
|
||||
export function useAIChatController(): AIChatController {
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
||||
const trackEvent = useTrackEvent();
|
||||
export function AIChatProvider(props: {
|
||||
renderMessageOptions?: RenderAIMessageOptions;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { renderMessageOptions, children } = props;
|
||||
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
open: () => setState((state) => ({ ...state, opened: true })),
|
||||
close: () => setState((state) => ({ ...state, opened: false })),
|
||||
clear: () =>
|
||||
setState((state) => ({
|
||||
opened: state.opened,
|
||||
loading: false,
|
||||
messages: [],
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
const [, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(() => {
|
||||
const { initialQuery } = globalState.getState();
|
||||
globalState.setState((state) => ({ ...state, opened: true }));
|
||||
|
||||
// Update search state to show ask mode with first message or current ask value
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
const onClose = React.useCallback(() => {
|
||||
globalState.setState((state) => ({ ...state, opened: false }));
|
||||
|
||||
// Clear ask parameter but keep other search state
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
const streamResponse = React.useCallback(
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
responseId: null,
|
||||
pendingTools: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
})),
|
||||
postMessage: async (input: { message: string }) => {
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
// TODO: how to handle markdown here?
|
||||
// to avoid rendering as plain text
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
});
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().state.responseId ?? undefined,
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
summary: {
|
||||
icon: 'bomb',
|
||||
text: 'An error occurred while executing the tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
tools: integrationTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
|
||||
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
|
||||
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const event = data.event;
|
||||
// Process streaming response
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
responseId: event.responseId,
|
||||
// Mark as not loading when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
...event.suggestions,
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setState((state) => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
},
|
||||
],
|
||||
}));
|
||||
if (input.message && globalState.getState().query !== input.message) {
|
||||
// Chat was cleared, stop processing the stream
|
||||
break;
|
||||
}
|
||||
|
||||
setState((state) => ({
|
||||
const event = data.event;
|
||||
|
||||
switch (event.type) {
|
||||
case 'response_finish': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.responseId,
|
||||
// Mark as not loading when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_followup_suggestion': {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
followUpSuggestions: [
|
||||
...state.followUpSuggestions,
|
||||
...event.suggestions,
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = integrationTools.find(
|
||||
(tool) => tool.name === event.toolCall.tool
|
||||
);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
const confirmation = toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
pendingTools: [
|
||||
...state.pendingTools,
|
||||
{
|
||||
icon: confirmation.icon,
|
||||
label: confirmation.label,
|
||||
cancelToolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
cancelled: 'User did not confirm the tool call',
|
||||
},
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
confirm: async () => {
|
||||
await executeToolCall(event);
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
toolToExecute = event;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the assistant message with streamed content
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
} catch {
|
||||
setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
messages: [
|
||||
...state.messages.slice(0, -1),
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
}
|
||||
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
} catch {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[
|
||||
messageContextRef.current,
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, pendingTools } = globalState.getState();
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (query === input.message) {
|
||||
// Return early if the message is the same as the previous message
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
globalState.setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
};
|
||||
});
|
||||
|
||||
const pendingTool = pendingTools[0];
|
||||
streamResponse({
|
||||
message: input.message,
|
||||
// If we had a pending tool call, we need to send it as being cancelled
|
||||
// otherwise the AI will fail to process the message
|
||||
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
|
||||
});
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onSetSize = React.useCallback((size: AIChatSize) => {
|
||||
globalState.setState((state) => ({ ...state, size }));
|
||||
}, []);
|
||||
|
||||
const controller = React.useMemo(() => {
|
||||
return {
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
setSize: onSetSize,
|
||||
};
|
||||
}, [messageContextRef, setState, trackEvent]);
|
||||
}, [onOpen, onClose, onClear, onPostMessage, onSetSize]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
</AIChatControllerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the controller to interact with the AI chat.
|
||||
* Integrates with search state to synchronize ?ask= parameter.
|
||||
*/
|
||||
export function useAIChatController(): AIChatController {
|
||||
const controller = React.useContext(AIChatControllerContext);
|
||||
if (!controller) {
|
||||
throw new Error('useAIChatController must be used within an AIChatProvider');
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useAIChatState } from '@/components/AI';
|
||||
import type { Assistant } from '@/components/AI';
|
||||
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
|
||||
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
|
||||
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import type React from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
type AIActionType = 'button' | 'dropdown-menu-item';
|
||||
|
||||
/**
|
||||
* Opens our AI Docs Assistant.
|
||||
*/
|
||||
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
|
||||
const { assistant, type } = props;
|
||||
const chat = useAIChatState();
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={assistant.icon}
|
||||
label={assistant.label}
|
||||
shortLabel={tString(language, 'ask')}
|
||||
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
|
||||
disabled={chat.loading}
|
||||
onClick={() => {
|
||||
assistant.open(tString(language, 'ai_chat_suggested_questions_about_this_page'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type CopiedStore = {
|
||||
copied: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
// We need to store everything in a store to share the state between every instance of the component.
|
||||
const useCopiedStore = create<
|
||||
CopiedStore & {
|
||||
setLoading: (loading: boolean) => void;
|
||||
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||
}
|
||||
>((set) => {
|
||||
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return {
|
||||
copied: false,
|
||||
loading: false,
|
||||
setLoading: (loading: boolean) => set({ loading }),
|
||||
copy: async (data, opts) => {
|
||||
const { onSuccess } = opts || {};
|
||||
|
||||
if (timeoutRef) {
|
||||
clearTimeout(timeoutRef);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(data);
|
||||
|
||||
set({ copied: true });
|
||||
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
onSuccess?.();
|
||||
|
||||
// Reset the timeout ref to avoid multiple timeouts
|
||||
timeoutRef = null;
|
||||
}, 1500);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache for the markdown versbion of the page.
|
||||
*/
|
||||
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
|
||||
|
||||
/**
|
||||
* Copies the markdown version of the page to the clipboard.
|
||||
*/
|
||||
export function CopyMarkdown(props: {
|
||||
markdownPageUrl: string;
|
||||
type: AIActionType;
|
||||
isDefaultAction?: boolean;
|
||||
}) {
|
||||
const { markdownPageUrl, type, isDefaultAction } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const closeDropdown = useDropdownMenuClose();
|
||||
|
||||
const { copied, loading, setLoading, copy } = useCopiedStore();
|
||||
|
||||
// Fetch the markdown from the page
|
||||
const fetchMarkdown = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const result = await fetch(markdownPageUrl).then((res) => res.text());
|
||||
markdownCache.set(markdownPageUrl, result);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const onClick = async (e: React.MouseEvent) => {
|
||||
// Prevent default behavior for non-default actions to avoid closing the dropdown.
|
||||
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
|
||||
if (!isDefaultAction) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
copy(markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), {
|
||||
onSuccess: () => {
|
||||
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
|
||||
if (type === 'dropdown-menu-item' && !isDefaultAction) {
|
||||
closeDropdown();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
|
||||
shortLabel={copied ? tString(language, 'code_copied') : tString(language, 'code_copy')}
|
||||
description={tString(language, 'copy_page_markdown')}
|
||||
onClick={onClick}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to the markdown version of the page.
|
||||
*/
|
||||
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
|
||||
const { markdownPageUrl, type } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={<MarkdownIcon className="size-4 fill-current" />}
|
||||
label={tString(language, 'view_page_markdown')}
|
||||
description={tString(language, 'view_page_plaintext')}
|
||||
href={markdownPageUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude.
|
||||
*/
|
||||
export function OpenInLLM(props: {
|
||||
provider: 'chatgpt' | 'claude';
|
||||
url: string;
|
||||
type: AIActionType;
|
||||
}) {
|
||||
const { provider, url, type } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
|
||||
|
||||
return (
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={
|
||||
provider === 'chatgpt' ? (
|
||||
<ChatGPTIcon className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<ClaudeIcon className="size-3.5 fill-current" />
|
||||
)
|
||||
}
|
||||
label={tString(language, 'open_in', providerLabel)}
|
||||
shortLabel={providerLabel}
|
||||
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
|
||||
href={getLLMURL(provider, url, language)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an action in a button (for the default action) or dropdown menu item.
|
||||
*/
|
||||
function AIActionWrapper(props: {
|
||||
type: AIActionType;
|
||||
icon: IconName | React.ReactNode;
|
||||
label: string;
|
||||
/**
|
||||
* The label to display in the button. If not provided, the `label` will be used.
|
||||
*/
|
||||
shortLabel?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
description?: string;
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { type, icon, label, shortLabel, onClick, href, description, disabled, loading } = props;
|
||||
|
||||
if (type === 'button') {
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
loading ? <Icon icon="spinner-third" className="size-4 animate-spin" /> : icon
|
||||
}
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
label={label ?? shortLabel}
|
||||
className="bg-tint-base text-sm"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
target={href ? '_blank' : undefined}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{shortLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="flex items-stretch gap-2.5 p-2"
|
||||
href={href}
|
||||
target="_blank"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center text-tint">
|
||||
{loading ? (
|
||||
<Icon icon="spinner-third" className="size-4 shrink-0 animate-spin" />
|
||||
) : icon ? (
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Regular}
|
||||
className="size-4 shrink-0 fill-transparent stroke-current"
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-tint-strong">
|
||||
<span className="truncate font-medium text-sm">{label}</span>
|
||||
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
|
||||
</span>
|
||||
{description && <span className="text-tint text-xs">{description}</span>}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL to open the page in a LLM with a pre-filled prompt.
|
||||
*/
|
||||
function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) {
|
||||
const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url));
|
||||
|
||||
switch (provider) {
|
||||
case 'chatgpt':
|
||||
return `https://chat.openai.com/?q=${prompt}`;
|
||||
case 'claude':
|
||||
return `https://claude.ai/new?q=${prompt}`;
|
||||
default:
|
||||
assertNever(provider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CopyMarkdown,
|
||||
OpenAIAssistant,
|
||||
OpenInLLM,
|
||||
ViewAsMarkdown,
|
||||
} from '@/components/AIActions/AIActions';
|
||||
import { Button, ButtonGroup } from '@/components/primitives/Button';
|
||||
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { SiteCustomizationSettings } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useRef } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
|
||||
interface AIActionsDropdownProps {
|
||||
markdownPageUrl: string;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
|
||||
*/
|
||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
const language = useLanguage();
|
||||
|
||||
return assistants.length > 0 || props.actions.markdown || props.actions.externalAI ? (
|
||||
<ButtonGroup ref={ref} className={props.className}>
|
||||
<DefaultAction {...props} />
|
||||
{props.actions.markdown || props.actions.externalAI ? (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-60 max-w-max"
|
||||
button={
|
||||
<Button
|
||||
icon={
|
||||
<Icon
|
||||
icon="chevron-down"
|
||||
className="size-3 transition-transform group-data-[state=open]/button:rotate-180"
|
||||
/>
|
||||
}
|
||||
label={tString(language, 'more')}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
className="bg-tint-base text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIActionsDropdownMenuContent {...props} />
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of the dropdown menu.
|
||||
*/
|
||||
function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{assistants.map((assistant) => (
|
||||
<OpenAIAssistant
|
||||
key={assistant.label}
|
||||
assistant={assistant}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
))}
|
||||
|
||||
{actions.markdown ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!assistants.length}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{actions.externalAI ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function DefaultAction(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
if (assistants.length) {
|
||||
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
||||
}
|
||||
|
||||
if (actions.markdown) {
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!assistants.length}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.externalAI) {
|
||||
return (
|
||||
<>
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="button" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
|
||||
<title>ChatGPT</title>
|
||||
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function ClaudeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 256 257"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
{...props}
|
||||
>
|
||||
<title>Claude</title>
|
||||
<g>
|
||||
<path
|
||||
d="M50.2278481,170.321013 L100.585316,142.063797 L101.427848,139.601013 L100.585316,138.24 L98.1225316,138.24 L89.6972152,137.721519 L60.921519,136.943797 L35.9696203,135.906835 L11.795443,134.610633 L5.70329114,133.31443 L0,125.796456 L0.583291139,122.037468 L5.70329114,118.602532 L13.0268354,119.250633 L29.2293671,120.352405 L53.5331646,122.037468 L71.161519,123.07443 L97.28,125.796456 L101.427848,125.796456 L102.011139,124.111392 L100.585316,123.07443 L99.4835443,122.037468 L74.3372152,104.992405 L47.116962,86.9751899 L32.8587342,76.6055696 L25.1463291,71.3559494 L21.2577215,66.4303797 L19.5726582,55.6718987 L26.5721519,47.9594937 L35.9696203,48.6075949 L38.3675949,49.2556962 L47.8946835,56.5792405 L68.2450633,72.3281013 L94.8172152,91.9007595 L98.7058228,95.1412658 L100.261266,94.0394937 L100.455696,93.2617722 L98.7058228,90.3453165 L84.2531646,64.2268354 L68.8283544,37.6546835 L61.958481,26.636962 L60.1437975,20.0263291 C59.4956962,17.3043038 59.0420253,15.0359494 59.0420253,12.2491139 L67.0136709,1.42582278 L71.4207595,-1.42108547e-14 L82.0496203,1.42582278 L86.521519,5.31443038 L93.1321519,20.4151899 L103.825823,44.2005063 L120.417215,76.5407595 L125.277975,86.1326582 L127.87038,95.0116456 L128.842532,97.7336709 L130.527595,97.7336709 L130.527595,96.1782278 L131.888608,77.9665823 L134.416203,55.6070886 L136.878987,26.8313924 L137.721519,18.7301266 L141.739747,9.00860759 L149.711392,3.75898734 L155.933165,6.74025316 L161.053165,14.0637975 L160.340253,18.7949367 L157.294177,38.5620253 L151.331646,69.5412658 L147.443038,90.2805063 L149.711392,90.2805063 L152.303797,87.6881013 L162.803038,73.7539241 L180.431392,51.718481 L188.208608,42.9691139 L197.282025,33.3124051 L203.114937,28.7108861 L214.132658,28.7108861 L222.233924,40.7655696 L218.604557,53.2091139 L207.262785,67.596962 L197.865316,79.7812658 L184.38481,97.9281013 L175.959494,112.44557 L176.737215,113.612152 L178.746329,113.417722 L209.207089,106.936709 L225.668861,103.955443 L245.306329,100.585316 L254.185316,104.733165 L255.157468,108.945823 L251.657722,117.56557 L230.659241,122.75038 L206.031392,127.675949 L169.348861,136.360506 L168.89519,136.684557 L169.413671,137.332658 L185.940253,138.888101 L193.004557,139.276962 L210.308861,139.276962 L242.519494,141.674937 L250.94481,147.248608 L256,154.053671 L255.157468,159.238481 L242.195443,165.849114 L224.696709,161.701266 L183.866329,151.979747 L169.867342,148.48 L167.923038,148.48 L167.923038,149.646582 L179.588861,161.053165 L200.976203,180.366582 L227.742785,205.253671 L229.103797,211.410633 L225.668861,216.271392 L222.039494,215.752911 L198.513418,198.059747 L189.44,190.088101 L168.89519,172.783797 L167.534177,172.783797 L167.534177,174.598481 L172.265316,181.533165 L197.282025,219.123038 L198.578228,230.659241 L196.763544,234.418228 L190.282532,236.686582 L183.153418,235.39038 L168.506329,214.84557 L153.40557,191.708354 L141.221266,170.969114 L139.730633,171.811646 L132.536709,249.259747 L129.166582,253.213165 L121.389367,256.19443 L114.908354,251.268861 L111.473418,243.297215 L114.908354,227.548354 L119.056203,207.003544 L122.426329,190.671392 L125.472405,170.385823 L127.287089,163.64557 L127.157468,163.191899 L125.666835,163.386329 L110.371646,184.38481 L87.1048101,215.817722 L68.6987342,235.52 L64.2916456,237.269873 L56.6440506,233.316456 L57.356962,226.252152 L61.6344304,219.96557 L87.1048101,187.560506 L102.46481,167.469367 L112.380759,155.868354 L112.315949,154.183291 L111.732658,154.183291 L44.0708861,198.124557 L32.0162025,199.68 L26.8313924,194.819241 L27.4794937,186.847595 L29.9422785,184.25519 L50.2926582,170.256203 L50.2278481,170.321013 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function MarkdownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 471 289.85" {...props}>
|
||||
<title>Markdown</title>
|
||||
<path d="M437,289.85H34a34,34,0,0,1-34-34V34A34,34,0,0,1,34,0H437a34,34,0,0,1,34,34V255.88A34,34,0,0,1,437,289.85ZM34,22.64A11.34,11.34,0,0,0,22.64,34V255.88A11.34,11.34,0,0,0,34,267.2H437a11.34,11.34,0,0,0,11.33-11.32V34A11.34,11.34,0,0,0,437,22.64Z" />
|
||||
<path d="M67.93,221.91v-154h45.29l45.29,56.61L203.8,67.93h45.29v154H203.8V133.6l-45.29,56.61L113.22,133.6v88.31Zm283.06,0-67.94-74.72h45.29V67.93h45.29v79.26h45.29Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatState,
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
} from '../AI/useAIChat';
|
||||
} from '../AI';
|
||||
import { EmbeddableFrame } from '../Embeddable/EmbeddableFrame';
|
||||
import { useNow } from '../hooks';
|
||||
import { Button } from '../primitives';
|
||||
import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu';
|
||||
import AIChatIcon from './AIChatIcon';
|
||||
import { Button, DropdownMenuSeparator } from '../primitives';
|
||||
import { DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
|
||||
export function AIChat() {
|
||||
export function AIChat(props: { trademark: boolean }) {
|
||||
const { trademark } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// When the chat is opened, scroll to it (applicable on mobile, where it's displayed above the content)
|
||||
React.useEffect(() => {
|
||||
if (chat.opened) {
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, [chat.opened]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chatController.open();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
chatController.close();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!chat.opened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AIChatWindow chat={chat} />;
|
||||
return (
|
||||
<div
|
||||
data-testid="ai-chat"
|
||||
className={tcls(
|
||||
'ai-chat z-40 mx-auto flex max-h-full w-full max-w-3xl animate-present scroll-mt-36 p-4 transition-all duration-300 max-md:inset-x-0 max-md:bottom-0 lg:fixed lg:inset-y-0 lg:right-0 lg:animate-enter-from-right',
|
||||
chat.size === 'large' ? 'lg:w-104 xl:w-156' : 'lg:w-80 xl:w-96'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame
|
||||
className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle"
|
||||
icon={<AIChatDynamicIcon trademark={trademark} />}
|
||||
title={getAIChatName(language, trademark)}
|
||||
subtitle={
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1].content
|
||||
? tString(language, 'ai_chat_working')
|
||||
: tString(language, 'ai_chat_thinking')
|
||||
: ''
|
||||
}
|
||||
buttons={
|
||||
<>
|
||||
{chat.messages.length > 0 ? (
|
||||
<DropdownMenu
|
||||
button={
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
iconOnly
|
||||
icon="ellipsis"
|
||||
label={tString(language, 'actions')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
chatController.setSize(
|
||||
chat.size === 'default' ? 'large' : 'default'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
chat.size === 'default'
|
||||
? 'arrow-up-right-and-arrow-down-left-from-center'
|
||||
: 'arrow-down-left-and-arrow-up-right-to-center'
|
||||
}
|
||||
className="size-3 shrink-0 text-tint-subtle"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-medium">
|
||||
{chat.size === 'default' ? 'Maximize' : 'Minimize'}
|
||||
</p>
|
||||
<p className="text-tint text-xs">
|
||||
{chat.size === 'default'
|
||||
? 'Longer, more detailed answers'
|
||||
: 'Shorter, more concise answers'}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
chatController.clear();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon="broom-wide"
|
||||
className="size-3 shrink-0 text-tint-subtle"
|
||||
/>
|
||||
{t(language, 'ai_chat_clear_conversation')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
icon="close"
|
||||
label={tString(language, 'close')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
|
||||
</EmbeddableFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIChatWindow(props: { chat: AIChatState }) {
|
||||
const { chat } = props;
|
||||
/**
|
||||
* Dynamic icon to indicate the AI chat state.
|
||||
*/
|
||||
export function AIChatDynamicIcon(props: {
|
||||
trademark: boolean;
|
||||
}) {
|
||||
const { trademark } = props;
|
||||
const chat = useAIChatState();
|
||||
|
||||
return (
|
||||
<AIChatIcon
|
||||
className="size-5 text-tint"
|
||||
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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the AI chat window.
|
||||
*/
|
||||
export function AIChatBody(props: {
|
||||
chatController: AIChatController;
|
||||
chat: AIChatState;
|
||||
trademark: boolean;
|
||||
}) {
|
||||
const { chatController, chat, trademark } = props;
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
// Ref for the last user message element
|
||||
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -64,11 +225,14 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
||||
}, [chat.messages.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// When the chat is opened, scroll to it (applicable on mobile, where it's displayed above the content)
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
if (lastUserMessageRef.current) {
|
||||
lastUserMessageRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
|
||||
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
|
||||
@@ -80,121 +244,74 @@ export function AIChatWindow(props: { chat: AIChatState }) {
|
||||
if (inputRef.current) {
|
||||
observer.observe(inputRef.current);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [chat.opened]);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enterFromRight lg:pr-4 lg:pl-0 xl:w-96"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative flex h-full grow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-md bg-tint-base text-sm text-tint depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||
<div className="flex items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
|
||||
<AIChatIcon
|
||||
className={`size-5 text-tint ${chat.loading ? 'animate-pulse' : ''}`}
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-bold">Docs Assistant</div>
|
||||
<div
|
||||
className={`text-tint text-xs leading-none transition-all duration-500 ${
|
||||
chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{chat.loading
|
||||
? chat.messages[chat.messages.length - 1].content
|
||||
? t(language, 'ai_chat_working')
|
||||
: t(language, 'ai_chat_thinking')
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<DropdownMenu
|
||||
button={
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
iconOnly
|
||||
icon="ellipsis"
|
||||
label={tString(language, 'more')}
|
||||
className="!px-2"
|
||||
variant="blank"
|
||||
size="default"
|
||||
<>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={tcls(
|
||||
'flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4 transition-all',
|
||||
chat.size === 'large' ? 'text-base' : 'text-sm'
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: `${inputHeight}px`,
|
||||
}}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
||||
<div className="flex items-center gap-4 lg:flex-col">
|
||||
<div className="flex animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle p-4 lg:p-8">
|
||||
<AIChatIcon
|
||||
state="intro"
|
||||
trademark={trademark}
|
||||
className="size-8 animate-[present_500ms_200ms_both] lg:size-16"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
chatController.clear();
|
||||
}}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<Icon icon="broom-wide" className="size-3 text-tint-subtle" />
|
||||
{t(language, 'ai_chat_clear_conversation')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
icon="close"
|
||||
label={tString(language, 'close')}
|
||||
className="!px-2"
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
|
||||
style={{
|
||||
paddingBottom: `${inputHeight}px`,
|
||||
}}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
||||
<div className="flex size-32 animate-[fadeIn_500ms_both] items-center justify-center rounded-full bg-tint-subtle">
|
||||
<AIChatIcon className="size-16 animate-[present_500ms_200ms_both]" />
|
||||
</div>
|
||||
<div className="animate-[fadeIn_500ms_400ms_both]">
|
||||
<h5 className=" text-center font-bold text-lg text-tint-strong">
|
||||
<h5 className="font-bold text-lg text-tint-strong lg:text-center">
|
||||
{timeGreeting}
|
||||
</h5>
|
||||
<p className="text-center text-tint">
|
||||
<p className="text-tint lg:text-center">
|
||||
{t(language, 'ai_chat_assistant_description')}
|
||||
</p>
|
||||
</div>
|
||||
{!chat.error ? (
|
||||
<AIChatSuggestedQuestions chatController={chatController} />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<AIChatMessages
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
lastUserMessageRef={lastUserMessageRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={inputRef}
|
||||
className="absolute inset-x-0 bottom-0 mr-2 flex flex-col gap-4 bg-gradient-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
||||
>
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
<AIChatInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={() => {
|
||||
chatController.postMessage({ message: input });
|
||||
setInput('');
|
||||
}}
|
||||
{!chat.error ? (
|
||||
<AIChatSuggestedQuestions chatController={chatController} />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<AIChatMessages
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
lastUserMessageRef={lastUserMessageRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={inputRef}
|
||||
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
||||
>
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
<AIChatInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={() => {
|
||||
chatController.postMessage({ message: input });
|
||||
setInput('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +320,7 @@ function AIChatError(props: { chatController: AIChatController }) {
|
||||
const { chatController } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-between gap-2 rounded-md bg-danger p-3 text-danger text-sm ring-1 ring-danger">
|
||||
<div className="flex animate-scale-in flex-wrap justify-between gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md bg-danger px-3 py-2 text-danger text-sm ring-1 ring-danger">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="exclamation-triangle" className="size-3.5" />
|
||||
<span className="flex items-center gap-1">{t(language, 'ai_chat_error')}</span>
|
||||
@@ -217,9 +334,15 @@ function AIChatError(props: { chatController: AIChatController }) {
|
||||
onClick={() => {
|
||||
chatController.clear();
|
||||
}}
|
||||
className="!text-danger hover:bg-danger-5"
|
||||
className="text-danger! hover:bg-danger-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
|
||||
return trademark
|
||||
? tString(language, 'ai_chat_assistant_name')
|
||||
: tString(language, 'ai_chat_assistant_name_unbranded');
|
||||
}
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
'use client';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { Assistant } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import AIChatIcon from './AIChatIcon';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
*/
|
||||
export function AIChatButton() {
|
||||
const chatController = useAIChatController();
|
||||
const chat = useAIChatState();
|
||||
|
||||
export function AIChatButton(props: {
|
||||
assistant: Assistant;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const { assistant, showLabel = true } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<AIChatIcon />}
|
||||
iconOnly
|
||||
size="default"
|
||||
variant="secondary"
|
||||
className="!px-3 bg-tint-base py-2.5"
|
||||
label={tString(language, 'ai_chat_assistant_name')}
|
||||
onClick={() => {
|
||||
if (chat.opened) {
|
||||
chatController.close();
|
||||
} else {
|
||||
chatController.open();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
icon={assistant.icon}
|
||||
data-testid="ai-chat-button"
|
||||
iconOnly={!showLabel}
|
||||
size="medium"
|
||||
variant="header"
|
||||
className={tcls('h-9 px-2.5')}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
|
||||
</div>
|
||||
}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,166 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
size?: number;
|
||||
state?: 'thinking' | 'default';
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
const AIChatIcon = ({
|
||||
export const AIChatIcon = ({
|
||||
className = 'size-4',
|
||||
size,
|
||||
trademark = true,
|
||||
state = 'default',
|
||||
...props
|
||||
}: AIChatIconProps) => {
|
||||
if (!trademark) {
|
||||
return (
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
className={tcls(
|
||||
className,
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'intro' && 'animate-[spin_2s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
preserveAspectRatio="xMaxYMid meet"
|
||||
className={className}
|
||||
aria-busy={state === 'thinking'}
|
||||
overflow="visible"
|
||||
{...props}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
{...(size && { width: size, height: size })}
|
||||
{...props}
|
||||
>
|
||||
<title>Docs Assistant</title>
|
||||
<title>GitBook Assistant</title>
|
||||
|
||||
{/* Sparkle */}
|
||||
<path
|
||||
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
className={state === 'thinking' ? 'animate-spin' : ''}
|
||||
className={tcls(
|
||||
state === 'intro' &&
|
||||
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
|
||||
(state === 'working' || state === 'thinking') &&
|
||||
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
||||
state === 'confirm' && 'animate-[fadeOut_.5s_both]',
|
||||
state === 'default' && 'animate-[fadeIn_0s_both]',
|
||||
state === 'error' && 'hidden'
|
||||
)}
|
||||
style={{ transformOrigin: '13px 3.5px' }}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
<g
|
||||
clipPath="url(#clip0_153_2034)"
|
||||
className={tcls(
|
||||
'text-danger-subtle',
|
||||
state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M13.0312 1.42059L13.0312 3.95184"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.0312 6.00253V6.00263"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Check */}
|
||||
<path
|
||||
d="M9.479 10.2734C9.479 11.2369 8.6115 11.2578 7.54138 11.2578C6.47126 11.2578 5.60376 11.2369 5.60376 10.2734"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.47913 6.77344L9.47913 7.77344"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.60388 6.77344L5.60388 7.77344"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.2813 8.875L13.2813 10.125C13.2813 12.3341 11.4904 14.125 9.28125 14.125L1.80165 14.125L1.80165 6.78125C1.80165 4.57211 3.59251 2.78125 5.80165 2.78125L7.67889 2.78125"
|
||||
d="M10.8051 3.71161L12.2401 5.27411L14.823 2.14911"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={tcls(
|
||||
state === 'done'
|
||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||
: 'animate-[fadeOut_.5s_both]',
|
||||
state === 'intro' && 'hidden',
|
||||
state === 'confirm' && 'hidden'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Confirm */}
|
||||
<path
|
||||
className={tcls(
|
||||
'fill-primary-9',
|
||||
state === 'confirm'
|
||||
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
|
||||
: state === 'thinking'
|
||||
? 'animate-[fadeOut_.5s_both]'
|
||||
: 'hidden'
|
||||
)}
|
||||
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
||||
/>
|
||||
|
||||
{/* Background */}
|
||||
<path
|
||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={tcls(state === 'intro' && 'animate-[fadeIn_2s_forwards]')}
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
<mask
|
||||
id="mask0_220_25"
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="1"
|
||||
width="16"
|
||||
height="14"
|
||||
>
|
||||
<path
|
||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||
stroke="currentColor"
|
||||
pathLength="100"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_220_25)">
|
||||
<path
|
||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L14.1991 4.90434L9.37937 2.12165C8.76057 1.76438 7.99907 1.76386 7.38027 2.12113C5.89314 2.97972 3.20298 4.53289 2.5596 4.90434C1.77376 5.35804 1 6.11597 1.00148 7.9207"
|
||||
stroke="currentColor"
|
||||
pathLength="100"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={tcls(
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
'animate-[pathLoading_2s_infinite_both]',
|
||||
state === 'intro' && 'animate-[pathEnter_2s_both]',
|
||||
state === 'done' && 'animate-[pathEnter_1s_forwards_ease]'
|
||||
)}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChatIcon;
|
||||
|
||||
@@ -2,8 +2,9 @@ import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button } from '../primitives';
|
||||
import { Tooltip } from '../primitives/Tooltip';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
export function AIChatInput(props: {
|
||||
value: string;
|
||||
@@ -31,29 +32,46 @@ export function AIChatInput(props: {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
setTimeout(() => {
|
||||
if (!disabled && !loading) {
|
||||
// Add a small delay to ensure the input is rendered before focusing
|
||||
// This fixes inconsistent focus behaviour across browsers
|
||||
const timeout = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 300);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [disabled]);
|
||||
}, [disabled, loading]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
|
||||
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-testid="ai-chat-input"
|
||||
className={tcls(
|
||||
'resize-none',
|
||||
'focus:outline-none',
|
||||
'focus:outline-hidden',
|
||||
'focus:ring-0',
|
||||
'w-full',
|
||||
'px-4',
|
||||
'px-3',
|
||||
'py-3',
|
||||
'pb-12',
|
||||
'h-auto',
|
||||
'bg-transparent',
|
||||
'peer',
|
||||
'max-h-64',
|
||||
'placeholder:text-tint/8',
|
||||
'transition-colors',
|
||||
@@ -69,6 +87,12 @@ export function AIChatInput(props: {
|
||||
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
||||
onChange={handleInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.style.height = 'auto';
|
||||
@@ -76,11 +100,21 @@ export function AIChatInput(props: {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
||||
<Tooltip
|
||||
label={
|
||||
{!disabled ? (
|
||||
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
||||
<HoverCardRoot>
|
||||
<HoverCard
|
||||
className="max-w-xs bg-tint p-2 text-sm text-tint"
|
||||
arrow={{ className: 'fill-tint-3' }}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-2">
|
||||
<p>{t(language, 'ai_chat_context_description')}</p>
|
||||
<p className="font-semibold">
|
||||
{t(language, 'ai_chat_context_description')}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Icon icon="memo" className="size-3.5 opacity-7" />
|
||||
@@ -97,16 +131,19 @@ export function AIChatInput(props: {
|
||||
</ul>
|
||||
<p>{t(language, 'ai_chat_context_disclaimer')}</p>
|
||||
</div>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2 py-1 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
||||
AI
|
||||
</span>{' '}
|
||||
<span>{t(language, 'ai_chat_context_title')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
||||
{t(language, 'ai_chat_context_badge')}
|
||||
</span>{' '}
|
||||
<span className="leading-none">
|
||||
{t(language, 'ai_chat_context_title')}
|
||||
</span>
|
||||
<Icon icon="question-circle" className="size-3 shrink-0" />
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
</HoverCardRoot>
|
||||
<Button
|
||||
label={tString(language, 'send')}
|
||||
size="medium"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import type React from 'react';
|
||||
import type { AIChatController, AIChatState } from '../AI/useAIChat';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
export function AIChatMessages(props: {
|
||||
@@ -22,28 +24,32 @@ export function AIChatMessages(props: {
|
||||
return (
|
||||
<div
|
||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||
data-testid="ai-chat-message"
|
||||
className={tcls(
|
||||
message.content ? 'animate-[fadeIn_500ms_both]' : '',
|
||||
message.content ? 'animate-fade-in-slow' : '',
|
||||
'shrink-0',
|
||||
'last:min-h-[calc(100%-5rem)]',
|
||||
'scroll-mt-36',
|
||||
'lg:scroll-mt-0',
|
||||
'flex flex-col gap-6',
|
||||
'group/message',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: ''
|
||||
: 'text-tint-strong'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{message.content ? (
|
||||
message.content
|
||||
) : chat.loading ? (
|
||||
<div className="flex w-full animate-[fadeIn_500ms_both] flex-wrap gap-2">
|
||||
{message.content ? message.content : null}
|
||||
|
||||
{isLastMessage && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-wrap gap-2">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_0.5s_ease_both,pulse_1.5s_infinite] rounded-md bg-tint-4"
|
||||
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
||||
style={{
|
||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
@@ -52,11 +58,26 @@ export function AIChatMessages(props: {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { AIChatController } from '../AI/useAIChat';
|
||||
import type { AIChatController } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
|
||||
@@ -13,14 +13,13 @@ export default function AIChatSuggestedQuestions(props: { chatController: AIChat
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2 lg:flex-col">
|
||||
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
|
||||
<Button
|
||||
key={question}
|
||||
label={question}
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
||||
className="shrink grow animate-[present_500ms_both] whitespace-normal"
|
||||
style={{
|
||||
animationDelay: `${800 + index * 100}ms`,
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user