diff --git a/.changeset/eleven-melons-smash.md b/.changeset/eleven-melons-smash.md new file mode 100644 index 000000000..93979555d --- /dev/null +++ b/.changeset/eleven-melons-smash.md @@ -0,0 +1,5 @@ +--- +"@gitbook/browser-types": minor +--- + +First version of the public package for typing script integrations. diff --git a/.changeset/modern-laws-kick.md b/.changeset/modern-laws-kick.md new file mode 100644 index 000000000..1822d52e6 --- /dev/null +++ b/.changeset/modern-laws-kick.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Allow integrations to provide tools to the Docs Assistant diff --git a/bun.lock b/bun.lock index 3002e6855..d02c7cac9 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,17 @@ "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": { "name": "@gitbook/cache-tags", "version": "0.3.1", @@ -51,6 +62,7 @@ "version": "0.15.0", "dependencies": { "@gitbook/api": "catalog:", + "@gitbook/browser-types": "workspace:*", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", @@ -246,7 +258,7 @@ "react-dom": "^19.0.0", }, "catalog": { - "@gitbook/api": "^0.134.0", + "@gitbook/api": "^0.136.0", }, "packages": { "@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=="], - "@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"], diff --git a/package.json b/package.json index 93bda48d3..2e08e717a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "workspaces": { "packages": ["packages/*"], "catalog": { - "@gitbook/api": "^0.134.0" + "@gitbook/api": "^0.136.0" } }, "patchedDependencies": { diff --git a/packages/browser-types/.gitignore b/packages/browser-types/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/browser-types/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/browser-types/README.md b/packages/browser-types/README.md new file mode 100644 index 000000000..b2846c9d0 --- /dev/null +++ b/packages/browser-types/README.md @@ -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. diff --git a/packages/browser-types/package.json b/packages/browser-types/package.json new file mode 100644 index 000000000..8b56718e0 --- /dev/null +++ b/packages/browser-types/package.json @@ -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"] +} diff --git a/packages/browser-types/src/index.ts b/packages/browser-types/src/index.ts new file mode 100644 index 000000000..8133f3045 --- /dev/null +++ b/packages/browser-types/src/index.ts @@ -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>; +}; + +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; + } +} diff --git a/packages/browser-types/tsconfig.json b/packages/browser-types/tsconfig.json new file mode 100644 index 000000000..92db2d902 --- /dev/null +++ b/packages/browser-types/tsconfig.json @@ -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"] +} diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 1497cc2a5..6da113245 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -6,6 +6,7 @@ "@gitbook/api": "catalog:", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", + "@gitbook/browser-types": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/fonts": "workspace:*", "@gitbook/icons": "workspace:*", diff --git a/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx b/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx index ff8caf621..27863fd80 100644 --- a/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx +++ b/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx @@ -10,6 +10,7 @@ import type { AIToolCallGetPageContent, AIToolCallGetPages, AIToolCallMCP, + AIToolCallOther, AIToolCallSearch, ContentRef, } from '@gitbook/api'; @@ -58,6 +59,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon return ; case 'mcp': return ; + case 'other': + return ; default: return <>{toolCall.tool}; } @@ -112,6 +115,15 @@ function DescriptionForMCPToolCall(props: { ); } +function DescriptionForOtherToolCall(props: { + toolCall: AIToolCallOther; + context: GitBookSiteContext; +}) { + const { toolCall } = props; + + return

{toolCall.summary.text}

; +} + async function DescriptionForSearchToolCall(props: { toolCall: AIToolCallSearch; context: GitBookSiteContext; @@ -247,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'; } diff --git a/packages/gitbook/src/components/AI/server-actions/chat.ts b/packages/gitbook/src/components/AI/server-actions/chat.ts index 86c67579d..ecfc519b6 100644 --- a/packages/gitbook/src/components/AI/server-actions/chat.ts +++ b/packages/gitbook/src/components/AI/server-actions/chat.ts @@ -2,7 +2,13 @@ import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; import { getServerActionBaseContext } from '@/lib/server-actions'; 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 type { RenderAIMessageOptions } from './types'; @@ -13,11 +19,15 @@ 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 { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => { @@ -29,17 +39,19 @@ export async function* streamAIChatResponse({ siteURLData.organization, siteURLData.site, { - mode: 'assistant', - input: [ - { - role: AIMessageRole.User, - content: message, - context: messageContext, - }, - ], - output: { type: 'document' }, + input: message + ? [ + { + role: AIMessageRole.User, + content: message, + context: messageContext, + }, + ] + : [], model: AIModel.ReasoningLow, previousResponseId, + toolCall, + tools, } ); diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 46b551832..5c8cc3a6c 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -2,9 +2,15 @@ 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 { useTrackEvent } from '../Insights'; +import { integrationsAssistantTools } from '../Integrations'; import { useSearch } from '../Search'; import { streamAIChatResponse } from './server-actions'; import { useAIMessageContextRef } from './useAIMessageContext'; @@ -15,6 +21,21 @@ export type AIChatMessage = { query?: string; }; +export type AIChatPendingTool = { + icon?: IconName; + label: string; + + /** + * Confirm the tool call by calling this function. + */ + confirm: () => Promise; + + /** + * Tool call result to cancel it. + */ + cancelToolCall: AIToolCallResult; +}; + export type AIChatState = { /** * If true, the chat is open. @@ -46,6 +67,11 @@ export type AIChatState = { */ followUpSuggestions: string[]; + /** + * Tools that are pending confirmation to be executed. + */ + pendingTools: AIChatPendingTool[]; + /** * If true, the session is in progress. */ @@ -71,22 +97,17 @@ export type AIChatController = { }; // Global state store for AI chat -const globalState = zustand.create<{ - state: AIChatState; - setState: (fn: (state: AIChatState) => Partial) => void; -}>((set) => { +const globalState = zustand.create(() => { return { - state: { - opened: false, - responseId: null, - messages: [], - query: null, - followUpSuggestions: [], - loading: false, - error: false, - initialQuery: null, - }, - 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, }; }); @@ -94,7 +115,7 @@ 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; } @@ -104,14 +125,13 @@ export function useAIChatState(): AIChatState { */ export function useAIChatController(): AIChatController { const messageContextRef = useAIMessageContextRef(); - const setState = zustand.useStore(globalState, (state) => state.setState); const trackEvent = useTrackEvent(); const [searchState, setSearchState] = useSearch(true); // Open AI chat and sync with search state const onOpen = React.useCallback(() => { - const { initialQuery } = globalState.getState().state; - setState((state) => ({ ...state, opened: true })); + 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) => ({ @@ -120,11 +140,11 @@ export function useAIChatController(): AIChatController { global: prev?.global ?? false, open: false, // Close search popover when opening chat })); - }, [setState, setSearchState]); + }, [setSearchState]); // Close AI chat and clear ask parameter const onClose = React.useCallback(() => { - setState((state) => ({ ...state, opened: false })); + globalState.setState((state) => ({ ...state, opened: false })); // Clear ask parameter but keep other search state setSearchState((prev) => ({ @@ -133,12 +153,196 @@ export function useAIChatController(): AIChatController { global: prev?.global ?? 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 const onPostMessage = React.useCallback( 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 if (messages.length === 0) { @@ -158,7 +362,7 @@ export function useAIChatController(): AIChatController { trackEvent({ type: 'ask_question', query: input.message }); // Add user message and placeholder for AI response - setState((state) => { + globalState.setState((state) => { return { ...state, messages: [ @@ -168,95 +372,34 @@ export function useAIChatController(): AIChatController { content: input.message, query: input.message, }, - { - role: AIMessageRole.Assistant, - content: null, // Placeholder for streaming response - }, ], query: input.message, - responseId: null, followUpSuggestions: [], loading: true, error: false, }; }); - try { - const stream = await streamAIChatResponse({ - message: input.message, - messageContext: messageContextRef.current, - previousResponseId: globalState.getState().state.responseId ?? undefined, - }); - - // 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, - })); - } + 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 } : {}), + }); }, - [messageContextRef.current, setState, setSearchState, trackEvent] + [setSearchState, trackEvent, streamResponse] ); // Clear the conversation and reset ask parameter const onClear = React.useCallback(() => { - setState((state) => ({ + globalState.setState((state) => ({ opened: state.opened, loading: false, messages: [], query: null, followUpSuggestions: [], + pendingTools: [], responseId: null, error: false, initialQuery: null, @@ -269,7 +412,7 @@ export function useAIChatController(): AIChatController { global: prev?.global ?? false, open: false, })); - }, [setState, setSearchState]); + }, [setSearchState]); // Auto-trigger AI chat when ?ask= parameter appears in URL (only once) React.useEffect(() => { @@ -286,7 +429,7 @@ export function useAIChatController(): AIChatController { // Auto-post the message if ask has content if (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 if (loading) return; @@ -298,7 +441,7 @@ export function useAIChatController(): AIChatController { if (!messageContextRef.current?.location) return; // Mark this ask value as processed - setState((state) => ({ ...state, initialQuery: trimmedAsk })); + globalState.setState((state) => ({ ...state, initialQuery: trimmedAsk })); onPostMessage({ message: trimmedAsk }); } }, [ @@ -307,7 +450,6 @@ export function useAIChatController(): AIChatController { searchState?.open, messageContextRef, onOpen, - setState, onPostMessage, ]); diff --git a/packages/gitbook/src/components/AIChat/AIChatMessages.tsx b/packages/gitbook/src/components/AIChat/AIChatMessages.tsx index 20ca60010..a5accb571 100644 --- a/packages/gitbook/src/components/AIChat/AIChatMessages.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatMessages.tsx @@ -2,6 +2,7 @@ import { tcls } from '@/lib/tailwind'; import { AIMessageRole } from '@gitbook/api'; import type React from 'react'; import type { AIChatController, AIChatState } from '../AI'; +import { AIChatToolConfirmations } from './AIChatToolConfirmations'; import { AIResponseFeedback } from './AIResponseFeedback'; import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions'; @@ -60,13 +61,18 @@ export function AIChatMessages(props: { {isLastMessage ? ( <> - {!chat.loading && !chat.error && chat.query && chat.responseId && ( + {!chat.loading && + !chat.error && + chat.query && + chat.responseId && + chat.pendingTools.length === 0 ? ( - )} + ) : null} + + {chat.pendingTools.map((tool, index) => ( +