mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 11:26:31 +00:00
API to let integrations define custom tools for Docs Assistant (#3568)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/browser-types": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
First version of the public package for typing script integrations.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Allow integrations to provide tools to the Docs Assistant
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
"vercel": "^39.3.0",
|
"vercel": "^39.3.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/browser-types": {
|
||||||
|
"name": "@gitbook/browser-types",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/icons": "workspace:",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/cache-tags": {
|
"packages/cache-tags": {
|
||||||
"name": "@gitbook/cache-tags",
|
"name": "@gitbook/cache-tags",
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
@@ -51,6 +62,7 @@
|
|||||||
"version": "0.15.0",
|
"version": "0.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
@@ -246,7 +258,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.134.0",
|
"@gitbook/api": "^0.136.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||||
@@ -611,7 +623,9 @@
|
|||||||
|
|
||||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||||
|
|
||||||
"@gitbook/api": ["@gitbook/api@0.134.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-WMdLsA0ZOTbOyoloevPs0qa/VR2xmfp+YB6T/a2o8fkFUv5fMXxDVfCAcIxB2q9NCmkriMSCohWKmxLfz44s6w=="],
|
"@gitbook/api": ["@gitbook/api@0.136.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-IxNqmXE6yUEUq0IzbenN8S/PcMfgxr4+akY8xh6V5ShI/+U37ixujJHZp2zJq6NdZOdBQoR3UQmhPnvtWrkF7g=="],
|
||||||
|
|
||||||
|
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||||
|
|
||||||
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
|
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "^0.134.0"
|
"@gitbook/api": "^0.136.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"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,54 @@
|
|||||||
|
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 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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"]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
"@gitbook/icons": "workspace:*",
|
"@gitbook/icons": "workspace:*",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AIToolCallGetPageContent,
|
AIToolCallGetPageContent,
|
||||||
AIToolCallGetPages,
|
AIToolCallGetPages,
|
||||||
AIToolCallMCP,
|
AIToolCallMCP,
|
||||||
|
AIToolCallOther,
|
||||||
AIToolCallSearch,
|
AIToolCallSearch,
|
||||||
ContentRef,
|
ContentRef,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
@@ -58,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
|||||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||||
case 'mcp':
|
case 'mcp':
|
||||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||||
|
case 'other':
|
||||||
|
return <DescriptionForOtherToolCall toolCall={toolCall} context={context} />;
|
||||||
default:
|
default:
|
||||||
return <>{toolCall.tool}</>;
|
return <>{toolCall.tool}</>;
|
||||||
}
|
}
|
||||||
@@ -112,6 +115,15 @@ function DescriptionForMCPToolCall(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DescriptionForOtherToolCall(props: {
|
||||||
|
toolCall: AIToolCallOther;
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
}) {
|
||||||
|
const { toolCall } = props;
|
||||||
|
|
||||||
|
return <p>{toolCall.summary.text}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
async function DescriptionForSearchToolCall(props: {
|
async function DescriptionForSearchToolCall(props: {
|
||||||
toolCall: AIToolCallSearch;
|
toolCall: AIToolCallSearch;
|
||||||
context: GitBookSiteContext;
|
context: GitBookSiteContext;
|
||||||
@@ -247,6 +259,8 @@ function getIconForToolCall(toolCall: AIToolCall): IconName {
|
|||||||
return 'magnifying-glass';
|
return 'magnifying-glass';
|
||||||
case 'getPages':
|
case 'getPages':
|
||||||
return 'files';
|
return 'files';
|
||||||
|
case 'other':
|
||||||
|
return (toolCall.summary.icon as IconName) ?? 'hammer';
|
||||||
default:
|
default:
|
||||||
return 'hammer';
|
return 'hammer';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
import { traceErrorOnly } from '@/lib/tracing';
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
import { type AIMessageContext, AIMessageRole, AIModel } from '@gitbook/api';
|
import {
|
||||||
|
type AIMessageContext,
|
||||||
|
AIMessageRole,
|
||||||
|
AIModel,
|
||||||
|
type AIToolCallResult,
|
||||||
|
type AIToolDefinition,
|
||||||
|
} from '@gitbook/api';
|
||||||
import { streamRenderAIMessage } from './api';
|
import { streamRenderAIMessage } from './api';
|
||||||
import type { RenderAIMessageOptions } from './types';
|
import type { RenderAIMessageOptions } from './types';
|
||||||
|
|
||||||
@@ -13,11 +19,15 @@ export async function* streamAIChatResponse({
|
|||||||
message,
|
message,
|
||||||
messageContext,
|
messageContext,
|
||||||
previousResponseId,
|
previousResponseId,
|
||||||
|
toolCall,
|
||||||
|
tools,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message?: string;
|
||||||
messageContext: AIMessageContext;
|
messageContext: AIMessageContext;
|
||||||
previousResponseId?: string;
|
previousResponseId?: string;
|
||||||
|
toolCall?: AIToolCallResult;
|
||||||
|
tools?: AIToolDefinition[];
|
||||||
options?: RenderAIMessageOptions;
|
options?: RenderAIMessageOptions;
|
||||||
}) {
|
}) {
|
||||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||||
@@ -29,17 +39,19 @@ export async function* streamAIChatResponse({
|
|||||||
siteURLData.organization,
|
siteURLData.organization,
|
||||||
siteURLData.site,
|
siteURLData.site,
|
||||||
{
|
{
|
||||||
mode: 'assistant',
|
input: message
|
||||||
input: [
|
? [
|
||||||
{
|
{
|
||||||
role: AIMessageRole.User,
|
role: AIMessageRole.User,
|
||||||
content: message,
|
content: message,
|
||||||
context: messageContext,
|
context: messageContext,
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
output: { type: 'document' },
|
: [],
|
||||||
model: AIModel.ReasoningLow,
|
model: AIModel.ReasoningLow,
|
||||||
previousResponseId,
|
previousResponseId,
|
||||||
|
toolCall,
|
||||||
|
tools,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
import * as zustand from 'zustand';
|
import * as zustand from 'zustand';
|
||||||
|
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import {
|
||||||
|
AIMessageRole,
|
||||||
|
type AIStreamResponseToolCallPending,
|
||||||
|
type AIToolCallResult,
|
||||||
|
} from '@gitbook/api';
|
||||||
|
import type { IconName } from '@gitbook/icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { integrationsAssistantTools } from '../Integrations';
|
||||||
import { useSearch } from '../Search';
|
import { useSearch } from '../Search';
|
||||||
import { streamAIChatResponse } from './server-actions';
|
import { streamAIChatResponse } from './server-actions';
|
||||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||||
@@ -15,6 +21,21 @@ export type AIChatMessage = {
|
|||||||
query?: string;
|
query?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AIChatPendingTool = {
|
||||||
|
icon?: IconName;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the tool call by calling this function.
|
||||||
|
*/
|
||||||
|
confirm: () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call result to cancel it.
|
||||||
|
*/
|
||||||
|
cancelToolCall: AIToolCallResult;
|
||||||
|
};
|
||||||
|
|
||||||
export type AIChatState = {
|
export type AIChatState = {
|
||||||
/**
|
/**
|
||||||
* If true, the chat is open.
|
* If true, the chat is open.
|
||||||
@@ -46,6 +67,11 @@ export type AIChatState = {
|
|||||||
*/
|
*/
|
||||||
followUpSuggestions: string[];
|
followUpSuggestions: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools that are pending confirmation to be executed.
|
||||||
|
*/
|
||||||
|
pendingTools: AIChatPendingTool[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If true, the session is in progress.
|
* If true, the session is in progress.
|
||||||
*/
|
*/
|
||||||
@@ -71,22 +97,17 @@ export type AIChatController = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Global state store for AI chat
|
// Global state store for AI chat
|
||||||
const globalState = zustand.create<{
|
const globalState = zustand.create<AIChatState>(() => {
|
||||||
state: AIChatState;
|
|
||||||
setState: (fn: (state: AIChatState) => Partial<AIChatState>) => void;
|
|
||||||
}>((set) => {
|
|
||||||
return {
|
return {
|
||||||
state: {
|
opened: false,
|
||||||
opened: false,
|
responseId: null,
|
||||||
responseId: null,
|
messages: [],
|
||||||
messages: [],
|
query: null,
|
||||||
query: null,
|
followUpSuggestions: [],
|
||||||
followUpSuggestions: [],
|
pendingTools: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
initialQuery: null,
|
initialQuery: null,
|
||||||
},
|
|
||||||
setState: (fn) => set((state) => ({ state: { ...state.state, ...fn(state.state) } })),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +115,7 @@ const globalState = zustand.create<{
|
|||||||
* Get the current state of the AI chat.
|
* Get the current state of the AI chat.
|
||||||
*/
|
*/
|
||||||
export function useAIChatState(): AIChatState {
|
export function useAIChatState(): AIChatState {
|
||||||
const state = zustand.useStore(globalState, (state) => state.state);
|
const state = zustand.useStore(globalState);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,14 +125,13 @@ export function useAIChatState(): AIChatState {
|
|||||||
*/
|
*/
|
||||||
export function useAIChatController(): AIChatController {
|
export function useAIChatController(): AIChatController {
|
||||||
const messageContextRef = useAIMessageContextRef();
|
const messageContextRef = useAIMessageContextRef();
|
||||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
const [searchState, setSearchState] = useSearch(true);
|
const [searchState, setSearchState] = useSearch(true);
|
||||||
|
|
||||||
// Open AI chat and sync with search state
|
// Open AI chat and sync with search state
|
||||||
const onOpen = React.useCallback(() => {
|
const onOpen = React.useCallback(() => {
|
||||||
const { initialQuery } = globalState.getState().state;
|
const { initialQuery } = globalState.getState();
|
||||||
setState((state) => ({ ...state, opened: true }));
|
globalState.setState((state) => ({ ...state, opened: true }));
|
||||||
|
|
||||||
// Update search state to show ask mode with first message or current ask value
|
// Update search state to show ask mode with first message or current ask value
|
||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
@@ -120,11 +140,11 @@ export function useAIChatController(): AIChatController {
|
|||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: false, // Close search popover when opening chat
|
open: false, // Close search popover when opening chat
|
||||||
}));
|
}));
|
||||||
}, [setState, setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
// Close AI chat and clear ask parameter
|
// Close AI chat and clear ask parameter
|
||||||
const onClose = React.useCallback(() => {
|
const onClose = React.useCallback(() => {
|
||||||
setState((state) => ({ ...state, opened: false }));
|
globalState.setState((state) => ({ ...state, opened: false }));
|
||||||
|
|
||||||
// Clear ask parameter but keep other search state
|
// Clear ask parameter but keep other search state
|
||||||
setSearchState((prev) => ({
|
setSearchState((prev) => ({
|
||||||
@@ -133,12 +153,196 @@ export function useAIChatController(): AIChatController {
|
|||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}, [setState, setSearchState]);
|
}, [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: [],
|
||||||
|
pendingTools: [],
|
||||||
|
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 result = await toolDef.execute(event.toolCall.input);
|
||||||
|
streamResponse({
|
||||||
|
toolCall: {
|
||||||
|
tool: event.toolCall.tool,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
output: result.output,
|
||||||
|
summary: result.summary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
streamResponse({
|
||||||
|
toolCall: {
|
||||||
|
tool: event.toolCall.tool,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
output: {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
icon: 'bomb',
|
||||||
|
text: 'An error occurred while executing the tool',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||||
|
try {
|
||||||
|
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||||
|
const stream = await streamAIChatResponse({
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process streaming response
|
||||||
|
for await (const data of stream) {
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
|
if (input.message && globalState.getState().query !== input.message) {
|
||||||
|
// Chat was cleared, stop processing the stream
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: `Skipped confirmation of "${confirmation.label}"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
confirm: async () => {
|
||||||
|
await executeToolCall(event);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toolToExecute = event;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the assistant message with streamed content
|
||||||
|
globalState.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
// Post a message to the AI chat
|
// Post a message to the AI chat
|
||||||
const onPostMessage = React.useCallback(
|
const onPostMessage = React.useCallback(
|
||||||
async (input: { message: string }) => {
|
async (input: { message: string }) => {
|
||||||
const { query, messages } = globalState.getState().state;
|
const { query, messages, pendingTools } = globalState.getState();
|
||||||
|
|
||||||
// For first message, update the ask parameter in URL
|
// For first message, update the ask parameter in URL
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
@@ -158,7 +362,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
trackEvent({ type: 'ask_question', query: input.message });
|
trackEvent({ type: 'ask_question', query: input.message });
|
||||||
|
|
||||||
// Add user message and placeholder for AI response
|
// Add user message and placeholder for AI response
|
||||||
setState((state) => {
|
globalState.setState((state) => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -168,95 +372,34 @@ export function useAIChatController(): AIChatController {
|
|||||||
content: input.message,
|
content: input.message,
|
||||||
query: input.message,
|
query: input.message,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
role: AIMessageRole.Assistant,
|
|
||||||
content: null, // Placeholder for streaming response
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
query: input.message,
|
query: input.message,
|
||||||
responseId: null,
|
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const pendingTool = pendingTools[0];
|
||||||
const stream = await streamAIChatResponse({
|
streamResponse({
|
||||||
message: input.message,
|
message: input.message,
|
||||||
messageContext: messageContextRef.current,
|
// If we had a pending tool call, we need to send it as being cancelled
|
||||||
previousResponseId: globalState.getState().state.responseId ?? undefined,
|
// otherwise the AI will fail to process the message
|
||||||
});
|
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
|
||||||
|
});
|
||||||
// Process streaming response
|
|
||||||
for await (const data of stream) {
|
|
||||||
if (!data) continue;
|
|
||||||
|
|
||||||
if (globalState.getState().state.query !== input.message) break; // Chat was cleared, stop processing the stream
|
|
||||||
|
|
||||||
const event = data.event;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the assistant message with streamed content
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
messages: [
|
|
||||||
...state.messages.slice(0, -1),
|
|
||||||
{
|
|
||||||
role: AIMessageRole.Assistant,
|
|
||||||
content: data.content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: false,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setState((state) => ({
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
error: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[messageContextRef.current, setState, setSearchState, trackEvent]
|
[setSearchState, trackEvent, streamResponse]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear the conversation and reset ask parameter
|
// Clear the conversation and reset ask parameter
|
||||||
const onClear = React.useCallback(() => {
|
const onClear = React.useCallback(() => {
|
||||||
setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
opened: state.opened,
|
opened: state.opened,
|
||||||
loading: false,
|
loading: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
query: null,
|
query: null,
|
||||||
followUpSuggestions: [],
|
followUpSuggestions: [],
|
||||||
|
pendingTools: [],
|
||||||
responseId: null,
|
responseId: null,
|
||||||
error: false,
|
error: false,
|
||||||
initialQuery: null,
|
initialQuery: null,
|
||||||
@@ -269,7 +412,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
global: prev?.global ?? false,
|
global: prev?.global ?? false,
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
}, [setState, setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
// Auto-trigger AI chat when ?ask= parameter appears in URL (only once)
|
// Auto-trigger AI chat when ?ask= parameter appears in URL (only once)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -286,7 +429,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
// Auto-post the message if ask has content
|
// Auto-post the message if ask has content
|
||||||
if (searchState?.ask?.trim()) {
|
if (searchState?.ask?.trim()) {
|
||||||
const trimmedAsk = searchState.ask.trim();
|
const trimmedAsk = searchState.ask.trim();
|
||||||
const { loading, initialQuery } = globalState.getState().state;
|
const { loading, initialQuery } = globalState.getState();
|
||||||
|
|
||||||
// Don't trigger if we're already posting a message
|
// Don't trigger if we're already posting a message
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -298,7 +441,7 @@ export function useAIChatController(): AIChatController {
|
|||||||
if (!messageContextRef.current?.location) return;
|
if (!messageContextRef.current?.location) return;
|
||||||
|
|
||||||
// Mark this ask value as processed
|
// Mark this ask value as processed
|
||||||
setState((state) => ({ ...state, initialQuery: trimmedAsk }));
|
globalState.setState((state) => ({ ...state, initialQuery: trimmedAsk }));
|
||||||
onPostMessage({ message: trimmedAsk });
|
onPostMessage({ message: trimmedAsk });
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -307,7 +450,6 @@ export function useAIChatController(): AIChatController {
|
|||||||
searchState?.open,
|
searchState?.open,
|
||||||
messageContextRef,
|
messageContextRef,
|
||||||
onOpen,
|
onOpen,
|
||||||
setState,
|
|
||||||
onPostMessage,
|
onPostMessage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { AIChatController, AIChatState } from '../AI';
|
import type { AIChatController, AIChatState } from '../AI';
|
||||||
|
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||||
|
|
||||||
@@ -60,13 +61,18 @@ export function AIChatMessages(props: {
|
|||||||
|
|
||||||
{isLastMessage ? (
|
{isLastMessage ? (
|
||||||
<>
|
<>
|
||||||
{!chat.loading && !chat.error && chat.query && chat.responseId && (
|
{!chat.loading &&
|
||||||
|
!chat.error &&
|
||||||
|
chat.query &&
|
||||||
|
chat.responseId &&
|
||||||
|
chat.pendingTools.length === 0 ? (
|
||||||
<AIResponseFeedback
|
<AIResponseFeedback
|
||||||
responseId={chat.responseId}
|
responseId={chat.responseId}
|
||||||
query={chat.query}
|
query={chat.query}
|
||||||
className="-ml-1 -mt-4"
|
className="-ml-1 -mt-4"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
<AIChatToolConfirmations chat={chat} />
|
||||||
<AIChatFollowupSuggestions
|
<AIChatFollowupSuggestions
|
||||||
chat={chat}
|
chat={chat}
|
||||||
chatController={chatController}
|
chatController={chatController}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { AIChatState } from '../AI';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display buttons to confirm tool calls.
|
||||||
|
*/
|
||||||
|
export function AIChatToolConfirmations(props: {
|
||||||
|
chat: AIChatState;
|
||||||
|
}) {
|
||||||
|
const { chat } = props;
|
||||||
|
|
||||||
|
if (chat.pendingTools.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
{chat.pendingTools.map((tool, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
tool.confirm();
|
||||||
|
}}
|
||||||
|
label={tool.label}
|
||||||
|
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
|
||||||
|
size="medium"
|
||||||
|
variant="primary"
|
||||||
|
icon={tool.icon}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import * as zustand from 'zustand';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GitBookGlobal,
|
||||||
|
GitBookIntegrationEvent,
|
||||||
|
GitBookIntegrationEventCallback,
|
||||||
|
GitBookIntegrationTool,
|
||||||
|
} from '@gitbook/browser-types';
|
||||||
|
|
||||||
|
const events = new Map<GitBookIntegrationEvent, GitBookIntegrationEventCallback[]>();
|
||||||
|
|
||||||
|
export const integrationsAssistantTools = zustand.createStore<{
|
||||||
|
/**
|
||||||
|
* Tools exposed to the assistant by integrations
|
||||||
|
*/
|
||||||
|
tools: GitBookIntegrationTool[];
|
||||||
|
}>(() => {
|
||||||
|
return {
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.GitBook = {
|
const gitbookGlobal: GitBookGlobal = {
|
||||||
events: new Map(),
|
|
||||||
addEventListener: (event, callback) => {
|
addEventListener: (event, callback) => {
|
||||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
const handlers = events.get(event) ?? [];
|
||||||
handlers.push(callback);
|
handlers.push(callback);
|
||||||
window.GitBook?.events.set(event, handlers);
|
events.set(event, handlers);
|
||||||
},
|
},
|
||||||
removeEventListener: (event, callback) => {
|
removeEventListener: (event, callback) => {
|
||||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
const handlers = events.get(event) ?? [];
|
||||||
const index = handlers.indexOf(callback);
|
const index = handlers.indexOf(callback);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
handlers.splice(index, 1);
|
handlers.splice(index, 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
registerTool: (tool) => {
|
||||||
|
integrationsAssistantTools.setState((state) => ({
|
||||||
|
tools: [...state.tools, tool],
|
||||||
|
}));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
window.GitBook = gitbookGlobal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +60,5 @@ export function LoadIntegrations() {
|
|||||||
* Client function to dispatch a GitBook event.
|
* Client function to dispatch a GitBook event.
|
||||||
*/
|
*/
|
||||||
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
||||||
const handlers = window.GitBook?.events.get(type) || [];
|
events.get(type)?.forEach((handler) => handler(...args));
|
||||||
handlers.forEach((handler) => handler(...args));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@
|
|||||||
"bun-types" // add Bun global
|
"bun-types" // add Bun global
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx", "types/**/*.d.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"types/**/*.d.ts",
|
||||||
|
"@gitbook/browser-types"
|
||||||
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"packages/openapi-parser",
|
"packages/openapi-parser",
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
declare global {
|
|
||||||
type GitBookIntegrationEvent = 'load' | 'unload';
|
|
||||||
|
|
||||||
type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
|
||||||
|
|
||||||
interface Window {
|
|
||||||
/**
|
|
||||||
* Global `window.GitBook` object accessible by integrations.
|
|
||||||
*/
|
|
||||||
GitBook?: {
|
|
||||||
events: Map<GitBookIntegrationEvent, GitBookIntegrationEventCallback[]>;
|
|
||||||
addEventListener: (
|
|
||||||
type: GitBookIntegrationEvent,
|
|
||||||
func: GitBookIntegrationEventCallback
|
|
||||||
) => void;
|
|
||||||
removeEventListener: (
|
|
||||||
type: GitBookIntegrationEvent,
|
|
||||||
func: GitBookIntegrationEventCallback
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
Reference in New Issue
Block a user