mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 14:45:17 +00:00
Compare commits
90 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 |
@@ -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,48 @@
|
||||
# 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
|
||||
|
||||
@@ -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',
|
||||
@@ -932,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;
|
||||
|
||||
@@ -36,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"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
"vars": {
|
||||
"STAGE": "dev",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "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": [
|
||||
{
|
||||
|
||||
@@ -36,6 +36,11 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
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));
|
||||
@@ -43,7 +48,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404(result);
|
||||
return this.returnNullOn404({
|
||||
...result,
|
||||
// Because we use tag cache and also invalidate them every time,
|
||||
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
|
||||
// this should improve performance even further, and reduce costs
|
||||
shouldBypassTagCache: true,
|
||||
});
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
@@ -134,19 +145,27 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
}
|
||||
|
||||
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,4 @@
|
||||
import { getLogger } from '@/lib/logger';
|
||||
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,6 +21,7 @@ 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) {
|
||||
@@ -29,12 +31,20 @@ export default {
|
||||
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||
},
|
||||
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||
const tagsToCheck = tags.filter(softTagFilter);
|
||||
if (tagsToCheck.length === 0) {
|
||||
return false; // If no tags to check, return false
|
||||
}
|
||||
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);
|
||||
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[]) => {
|
||||
const tagsToWrite = tags.filter(softTagFilter);
|
||||
@@ -43,7 +53,7 @@ export default {
|
||||
logger.warn('writeTags - No valid tags to write');
|
||||
return; // If no tags to write, exit early
|
||||
}
|
||||
// Write only the filtered tags
|
||||
|
||||
await originalTagCache.writeTags(tagsToWrite);
|
||||
},
|
||||
} satisfies NextModeTagCache;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -13,15 +14,17 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.4.0",
|
||||
"@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",
|
||||
@@ -42,14 +45,14 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "^15.3.2",
|
||||
"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",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -68,11 +71,12 @@
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"warn-once": "^0.1.1",
|
||||
"zustand": "^5.0.3",
|
||||
"quick-lru": "^7.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",
|
||||
@@ -84,31 +88,29 @@
|
||||
"@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"
|
||||
},
|
||||
|
||||
@@ -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 +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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useAIChatController } from '@/components/AI/useAIChat';
|
||||
import { useAIChatState } from '@/components/AI/useAIChat';
|
||||
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 { getAIChatName } from '@/components/AIChat';
|
||||
import { AIChatIcon } from '@/components/AIChat';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
@@ -22,32 +20,21 @@ type AIActionType = 'button' | 'dropdown-menu-item';
|
||||
/**
|
||||
* Opens our AI Docs Assistant.
|
||||
*/
|
||||
export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolean }) {
|
||||
const { type, trademark } = props;
|
||||
const chatController = useAIChatController();
|
||||
export function OpenAIAssistant(props: { assistant: Assistant; type: AIActionType }) {
|
||||
const { assistant, type } = props;
|
||||
const chat = useAIChatState();
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<AIActionWrapper
|
||||
type={type}
|
||||
icon={
|
||||
<AIChatIcon state={chat.loading ? 'thinking' : 'default'} trademark={trademark} />
|
||||
}
|
||||
label={tString(language, 'ai_chat_ask', getAIChatName(trademark))}
|
||||
icon={assistant.icon}
|
||||
label={assistant.label}
|
||||
shortLabel={tString(language, 'ask')}
|
||||
description={tString(language, 'ai_chat_ask_about_page', getAIChatName(trademark))}
|
||||
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
|
||||
disabled={chat.loading}
|
||||
onClick={() => {
|
||||
// Open the chat if it's not already open
|
||||
if (!chat.opened) {
|
||||
chatController.open();
|
||||
}
|
||||
|
||||
// Send the "What is this page about?" message
|
||||
chatController.postMessage({
|
||||
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
});
|
||||
assistant.open(tString(language, 'ai_chat_suggested_questions_about_this_page'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -231,13 +218,15 @@ function AIActionWrapper(props: {
|
||||
}
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
label={shortLabel || label}
|
||||
className="hover:!scale-100 !shadow-none !rounded-r-none hover:!translate-y-0 border-r-0 bg-tint-base text-sm"
|
||||
label={label ?? shortLabel}
|
||||
className="bg-tint-base text-sm"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
target={href ? '_blank' : undefined}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
>
|
||||
{shortLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,13 +240,13 @@ function AIActionWrapper(props: {
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center text-tint">
|
||||
{loading ? (
|
||||
<Icon icon="spinner-third" className="size-4 animate-spin" />
|
||||
<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 fill-transparent stroke-current"
|
||||
className="size-4 shrink-0 fill-transparent stroke-current"
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
@@ -268,9 +257,9 @@ function AIActionWrapper(props: {
|
||||
<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" /> : null}
|
||||
{href ? <Icon icon="arrow-up-right" className="size-3 shrink-0" /> : null}
|
||||
</span>
|
||||
{description && <span className="truncate text-tint text-xs">{description}</span>}
|
||||
{description && <span className="text-tint text-xs">{description}</span>}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -2,24 +2,22 @@
|
||||
|
||||
import {
|
||||
CopyMarkdown,
|
||||
OpenDocsAssistant,
|
||||
OpenAIAssistant,
|
||||
OpenInLLM,
|
||||
ViewAsMarkdown,
|
||||
} from '@/components/AIActions/AIActions';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { DropdownMenu } from '@/components/primitives/DropdownMenu';
|
||||
|
||||
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;
|
||||
withAIChat?: boolean;
|
||||
trademark: boolean;
|
||||
/**
|
||||
* Whether to include the "Open in LLM" entries in the dropdown menu.
|
||||
*/
|
||||
withLLMActions?: boolean;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,55 +25,75 @@ interface AIActionsDropdownProps {
|
||||
*/
|
||||
export function AIActionsDropdown(props: AIActionsDropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex h-fit items-stretch justify-start">
|
||||
<DefaultAction {...props} />
|
||||
<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"
|
||||
/>
|
||||
}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIActionsDropdownMenuContent {...props} />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
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, withAIChat, trademark, withLLMActions } = props;
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{withAIChat ? (
|
||||
<OpenDocsAssistant trademark={trademark} type="dropdown-menu-item" />
|
||||
{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}
|
||||
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
|
||||
|
||||
{withLLMActions ? (
|
||||
{actions.externalAI ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<OpenInLLM provider="chatgpt" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
<OpenInLLM provider="claude" url={markdownPageUrl} type="dropdown-menu-item" />
|
||||
</>
|
||||
@@ -88,17 +106,30 @@ function AIActionsDropdownMenuContent(props: AIActionsDropdownProps) {
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function DefaultAction(props: AIActionsDropdownProps) {
|
||||
const { markdownPageUrl, withAIChat, trademark } = props;
|
||||
const { markdownPageUrl, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
|
||||
if (withAIChat) {
|
||||
return <OpenDocsAssistant trademark={trademark} type="button" />;
|
||||
if (assistants.length) {
|
||||
return <OpenAIAssistant assistant={assistants[0]} type="button" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyMarkdown
|
||||
isDefaultAction={!withAIChat}
|
||||
markdownPageUrl={markdownPageUrl}
|
||||
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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'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';
|
||||
@@ -9,10 +11,11 @@ import {
|
||||
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 { Button, DropdownMenuSeparator } from '../primitives';
|
||||
import { DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
@@ -20,16 +23,31 @@ import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
|
||||
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+j',
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chatController.open();
|
||||
},
|
||||
[]
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
}
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
@@ -44,10 +62,131 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AIChatWindow trademark={trademark} chatController={chatController} 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: {
|
||||
/**
|
||||
* 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;
|
||||
@@ -56,7 +195,6 @@ export function AIChatWindow(props: {
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
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);
|
||||
@@ -87,11 +225,14 @@ export function AIChatWindow(props: {
|
||||
}, [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.
|
||||
@@ -103,136 +244,74 @@ export function AIChatWindow(props: {
|
||||
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 scroll-mt-36 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"
|
||||
trademark={trademark}
|
||||
state={
|
||||
chat.error
|
||||
? 'error'
|
||||
: chat.loading
|
||||
? chat.messages[chat.messages.length - 1].content
|
||||
? 'working'
|
||||
: 'thinking'
|
||||
: chat.messages.length > 0
|
||||
? 'done'
|
||||
: 'default'
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-bold">{getAIChatName(trademark)}</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"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<>
|
||||
<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-16 animate-[present_500ms_200ms_both]"
|
||||
className="size-8 animate-[present_500ms_200ms_both] lg:size-16"
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,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>
|
||||
@@ -255,16 +334,14 @@ 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(trademark: boolean) {
|
||||
const language = useLanguage();
|
||||
|
||||
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
|
||||
return trademark
|
||||
? tString(language, 'ai_chat_assistant_name')
|
||||
: tString(language, 'ai_chat_assistant_name_unbranded');
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import type { Assistant } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { getAIChatName } from './AIChat';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
*/
|
||||
export function AIChatButton(props: { trademark: boolean }) {
|
||||
const { trademark } = props;
|
||||
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 trademark={trademark} />}
|
||||
iconOnly
|
||||
size="default"
|
||||
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">
|
||||
{getAIChatName(trademark)}
|
||||
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
<KeyboardShortcut keys={['mod', 'i']} className="border-tint-11 text-tint-1" />
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
if (chat.opened) {
|
||||
chatController.close();
|
||||
} else {
|
||||
chatController.open();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type React from 'react';
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
size?: number;
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error';
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AIChatIcon = ({
|
||||
preserveAspectRatio="xMaxYMid meet"
|
||||
className={className}
|
||||
aria-busy={state === 'thinking'}
|
||||
overflow="visible"
|
||||
{...props}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -54,8 +55,9 @@ export const AIChatIcon = ({
|
||||
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_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
||||
state === 'confirm' && 'animate-[fadeOut_.5s_both]',
|
||||
state === 'default' && 'animate-[fadeIn_0s_both]',
|
||||
state === 'error' && 'hidden'
|
||||
)}
|
||||
@@ -95,10 +97,24 @@ export const AIChatIcon = ({
|
||||
state === 'done'
|
||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||
: 'animate-[fadeOut_.5s_both]',
|
||||
state === 'intro' && 'hidden'
|
||||
state === 'intro' && 'hidden',
|
||||
state === 'confirm' && 'hidden'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Confirm */}
|
||||
<path
|
||||
className={tcls(
|
||||
'fill-primary-9',
|
||||
state === 'confirm'
|
||||
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
|
||||
: state === 'thinking'
|
||||
? 'animate-[fadeOut_.5s_both]'
|
||||
: 'hidden'
|
||||
)}
|
||||
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
||||
/>
|
||||
|
||||
{/* Background */}
|
||||
<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"
|
||||
|
||||
@@ -3,9 +3,8 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Button } from '../primitives';
|
||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { Tooltip } from '../primitives/Tooltip';
|
||||
|
||||
export function AIChatInput(props: {
|
||||
value: string;
|
||||
@@ -38,33 +37,36 @@ export function AIChatInput(props: {
|
||||
// This fixes inconsistent focus behaviour across browsers
|
||||
const timeout = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [disabled, loading]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+j',
|
||||
'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',
|
||||
@@ -98,14 +100,21 @@ export function AIChatInput(props: {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2.5 right-4 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||
<KeyboardShortcut keys={['mod', 'j']} />
|
||||
</div>
|
||||
<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" />
|
||||
@@ -122,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 rounded-corners:rounded circular-corners:rounded-2xl 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>{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,30 +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`,
|
||||
@@ -54,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`,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import type { AIChatState } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
/**
|
||||
* Display buttons to confirm tool calls.
|
||||
*/
|
||||
export function AIChatToolConfirmations(props: {
|
||||
chat: AIChatState;
|
||||
}) {
|
||||
const { chat } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
useHotkeys(
|
||||
'mod+enter',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chat.pendingTools[0]?.confirm();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[chat.pendingTools]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
{chat.pendingTools.map((tool, index) => (
|
||||
<div
|
||||
className="flex w-full animate-present-slow flex-col gap-1"
|
||||
key={index}
|
||||
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
tool.confirm();
|
||||
}}
|
||||
tabIndex={index}
|
||||
label={tool.label}
|
||||
className="w-full justify-center"
|
||||
size={index === 0 ? 'default' : 'medium'}
|
||||
variant={index === 0 ? 'primary' : 'secondary'}
|
||||
icon={tool.icon}
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div
|
||||
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
|
||||
style={{ animationDelay: '1000ms' }}
|
||||
>
|
||||
{t(
|
||||
language,
|
||||
'press_to_confirm',
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'enter']}
|
||||
className="mx-0 text-tint"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { useState } from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export function AIResponseFeedback(props: {
|
||||
className?: ClassValue;
|
||||
responseId: string;
|
||||
query: string;
|
||||
}) {
|
||||
const { className, responseId, query } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const [rating, setRating] = useState<1 | -1 | null>(null);
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
const handleRating = (rating: 1 | -1) => {
|
||||
setRating(rating);
|
||||
trackEvent({ type: 'ask_rate_response', query, responseId, rating });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={tcls('flex h-fit items-center', className)}>
|
||||
<Button
|
||||
icon="thumbs-up"
|
||||
iconOnly
|
||||
label={tString(language, 'was_this_helpful_positive_label')}
|
||||
variant="blank"
|
||||
className={tcls(
|
||||
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
||||
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
|
||||
)}
|
||||
size="medium"
|
||||
style={{ animationDuration: '.5s' }}
|
||||
onClick={() => handleRating(1)}
|
||||
disabled={rating !== null}
|
||||
active={rating === 1}
|
||||
key="positive"
|
||||
/>
|
||||
<Button
|
||||
icon="thumbs-down"
|
||||
iconOnly
|
||||
label={tString(language, 'was_this_helpful_negative_label')}
|
||||
variant="blank"
|
||||
className={tcls(
|
||||
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
||||
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
|
||||
)}
|
||||
size="medium"
|
||||
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
|
||||
onClick={() => handleRating(-1)}
|
||||
disabled={rating !== null}
|
||||
active={rating === -1}
|
||||
key="negative"
|
||||
/>
|
||||
{rating !== null ? (
|
||||
<span
|
||||
className="ml-2 animate-fade-in-slow text-tint-subtle"
|
||||
style={{ animationDelay: '.3s' }}
|
||||
>
|
||||
{t(language, 'was_this_helpful_thank_you')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-auto flex flex-row flex-wrap justify-end gap-2">
|
||||
<div className="mt-auto flex w-full flex-wrap justify-end gap-2">
|
||||
{chat.followUpSuggestions.map((suggestion, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
@@ -23,7 +23,7 @@ export function AIChatFollowupSuggestions(props: {
|
||||
chatController.postMessage({ message: suggestion });
|
||||
}}
|
||||
label={suggestion}
|
||||
className="max-w-[80%] animate-[present_500ms_both] whitespace-normal text-left ring-1 ring-tint-subtle"
|
||||
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
|
||||
size="medium"
|
||||
variant="blank"
|
||||
style={{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './AIChat';
|
||||
export * from './AIChatButton';
|
||||
export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function AdCoverRendering({
|
||||
'rounded-lg',
|
||||
'p-4',
|
||||
'overflow-hidden',
|
||||
'shadow-sm'
|
||||
'shadow-xs'
|
||||
)}
|
||||
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
|
||||
href={ad.statlink}
|
||||
@@ -68,7 +68,7 @@ export async function AdCoverRendering({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={tcls('z-[2]')}>
|
||||
<div className={tcls('z-2')}>
|
||||
<img
|
||||
alt="Large cover"
|
||||
src={largeImage}
|
||||
@@ -81,10 +81,10 @@ export async function AdCoverRendering({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={tcls('z-[2]')}>
|
||||
<div className={tcls('z-2')}>
|
||||
<img alt={ad.company} src={ad.logo} className={tcls('max-w-36', 'max-h-12')} />
|
||||
</div>
|
||||
<div className={tcls('flex', 'flex-col', 'z-[2]')}>
|
||||
<div className={tcls('flex', 'flex-col', 'z-2')}>
|
||||
<div className={tcls('text-sm', 'font-semibold', 'mb-2')}>{ad.companyTagline}</div>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -99,7 +99,7 @@ export async function AdCoverRendering({
|
||||
{ad.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={tcls('z-[2]')}>
|
||||
<div className={tcls('z-2')}>
|
||||
<span
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
@@ -119,7 +119,7 @@ export async function AdCoverRendering({
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={tcls('absolute', 'inset-0', 'backdrop-blur', 'z-[1]')}
|
||||
className={tcls('absolute', 'inset-0', 'backdrop-blur-sm', 'z-1')}
|
||||
style={{
|
||||
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
import { AdCoverRendering } from './AdCoverRendering';
|
||||
import { AdPixels } from './AdPixels';
|
||||
@@ -40,40 +41,42 @@ interface FetchPlaceholderAdOptions {
|
||||
* and properly access user-agent and IP.
|
||||
*/
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const [context, result] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||
]);
|
||||
return traceErrorOnly('Ads.renderAd', async () => {
|
||||
const [context, result] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
options.source === 'live' ? fetchAd(options) : getPlaceholderAd(),
|
||||
]);
|
||||
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ad } = result;
|
||||
const { ad } = result;
|
||||
|
||||
const insightsAd: SiteInsightsAd | null =
|
||||
options.source === 'live'
|
||||
? {
|
||||
placement: options.placement,
|
||||
zoneId: options.zoneId,
|
||||
domain: 'company' in ad ? ad.company : '',
|
||||
}
|
||||
: null;
|
||||
const insightsAd: SiteInsightsAd | null =
|
||||
options.source === 'live'
|
||||
? {
|
||||
placement: options.placement,
|
||||
zoneId: options.zoneId,
|
||||
domain: 'company' in ad ? ad.company : '',
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
children: (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
),
|
||||
insightsAd,
|
||||
};
|
||||
return {
|
||||
children: (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} context={context} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
),
|
||||
insightsAd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAd({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user