mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +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",
|
||||
},
|
||||
},
|
||||
"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"],
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.134.0"
|
||||
"@gitbook/api": "^0.136.0"
|
||||
}
|
||||
},
|
||||
"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/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
|
||||
@@ -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 <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}</>;
|
||||
}
|
||||
@@ -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: {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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<AIChatState>) => void;
|
||||
}>((set) => {
|
||||
const globalState = zustand.create<AIChatState>(() => {
|
||||
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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
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';
|
||||
|
||||
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') {
|
||||
window.GitBook = {
|
||||
events: new Map(),
|
||||
const gitbookGlobal: GitBookGlobal = {
|
||||
addEventListener: (event, callback) => {
|
||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
||||
const handlers = events.get(event) ?? [];
|
||||
handlers.push(callback);
|
||||
window.GitBook?.events.set(event, handlers);
|
||||
events.set(event, handlers);
|
||||
},
|
||||
removeEventListener: (event, callback) => {
|
||||
const handlers = window.GitBook?.events.get(event) ?? [];
|
||||
const handlers = events.get(event) ?? [];
|
||||
const index = handlers.indexOf(callback);
|
||||
if (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.
|
||||
*/
|
||||
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
|
||||
const handlers = window.GitBook?.events.get(type) || [];
|
||||
handlers.forEach((handler) => handler(...args));
|
||||
events.get(type)?.forEach((handler) => handler(...args));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,14 @@
|
||||
"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": [
|
||||
"node_modules",
|
||||
"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