API to let integrations define custom tools for Docs Assistant (#3568)

This commit is contained in:
Samy Pessé
2025-08-17 10:03:54 +02:00
committed by GitHub
parent 1420180220
commit cbc71a56b6
18 changed files with 497 additions and 148 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/browser-types": minor
---
First version of the public package for typing script integrations.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow integrations to provide tools to the Docs Assistant
+16 -2
View File
@@ -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
View File
@@ -34,7 +34,7 @@
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@gitbook/api": "^0.134.0" "@gitbook/api": "^0.136.0"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
+1
View File
@@ -0,0 +1 @@
dist/
+3
View File
@@ -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.
+24
View File
@@ -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"]
}
+54
View File
@@ -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;
}
}
+24
View File
@@ -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"]
}
+1
View File
@@ -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,
} }
); );
+242 -100
View File
@@ -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));
} }
+8 -1
View File
@@ -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",
-24
View File
@@ -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 {};