From 98b36616cbd65828604e1c19818eb335f906d69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 6 Mar 2026 16:58:48 +0100 Subject: [PATCH] Add interactive controls to docs assistant (#4073) --- bun.lock | 15 +- packages/gitbook/e2e/internal.spec.ts | 2 +- packages/gitbook/package.json | 1 + .../components/AI/controls/ConfirmControl.tsx | 55 +++++ .../AI/controls/SingleChoiceControl.tsx | 203 ++++++++++++++++++ .../src/components/AI/controls/common.tsx | 15 ++ .../src/components/AI/controls/helpers.ts | 80 +++++++ .../src/components/AI/controls/index.ts | 11 + packages/gitbook/src/components/AI/tools.ts | 8 + .../gitbook/src/components/AI/useAIChat.tsx | 156 ++++++++------ .../gitbook/src/components/AIChat/AIChat.tsx | 25 ++- .../src/components/AIChat/AIChatControl.tsx | 8 + .../src/components/AIChat/AIChatMessages.tsx | 5 +- .../AIChat/AIChatToolConfirmations.tsx | 70 ------ .../src/components/primitives/Input.tsx | 4 +- 15 files changed, 501 insertions(+), 157 deletions(-) create mode 100644 packages/gitbook/src/components/AI/controls/ConfirmControl.tsx create mode 100644 packages/gitbook/src/components/AI/controls/SingleChoiceControl.tsx create mode 100644 packages/gitbook/src/components/AI/controls/common.tsx create mode 100644 packages/gitbook/src/components/AI/controls/helpers.ts create mode 100644 packages/gitbook/src/components/AI/controls/index.ts create mode 100644 packages/gitbook/src/components/AI/tools.ts create mode 100644 packages/gitbook/src/components/AIChat/AIChatControl.tsx delete mode 100644 packages/gitbook/src/components/AIChat/AIChatToolConfirmations.tsx diff --git a/bun.lock b/bun.lock index e7186c086..d59fabc6c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,9 @@ "name": "gitbook", "devDependencies": { "@biomejs/biome": "^1.9.4", - "@changesets/cli": "^2.29.8", - "turbo": "^2.8.10", - "vercel": "^50.23.2", + "@changesets/cli": "^2.30.0", + "turbo": "^2.8.13", + "vercel": "^50.26.1", }, }, "packages/browser-types": { @@ -188,6 +188,7 @@ "usehooks-ts": "catalog:", "warn-once": "^0.1.1", "zod": "^3", + "zod-to-json-schema": "^3.25.1", "zustand": "^5.0.3", }, "devDependencies": { @@ -3502,7 +3503,7 @@ "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], - "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zustand": ["zustand@5.0.3", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg=="], @@ -3510,6 +3511,8 @@ "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], + "@ai-sdk/ui-utils/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + "@ampproject/remapping/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="], "@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], @@ -4132,6 +4135,8 @@ "@modelcontextprotocol/sdk/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], "@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], @@ -4818,6 +4823,8 @@ "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "zod-to-json-schema/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@ampproject/remapping/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@ampproject/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 4b52103c8..0f36b3882 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -2315,7 +2315,7 @@ const testCases: TestsCase[] = [ 'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.' ); const toolConfirmation = iframe - .getByTestId('ai-chat-tool-confirmation') + .getByTestId('ai-chat-tool-confirm-accept') .first(); await expect(toolConfirmation).toBeVisible({ timeout: 30000, diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 8f77eb000..a91c0ef2a 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -80,6 +80,7 @@ "usehooks-ts": "catalog:", "warn-once": "^0.1.1", "zod": "^3", + "zod-to-json-schema": "^3.25.1", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/packages/gitbook/src/components/AI/controls/ConfirmControl.tsx b/packages/gitbook/src/components/AI/controls/ConfirmControl.tsx new file mode 100644 index 000000000..46421f874 --- /dev/null +++ b/packages/gitbook/src/components/AI/controls/ConfirmControl.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { Button } from '@/components/primitives'; +import { z } from 'zod'; +import { AIToolContainer } from './common'; +import { type GetAIControlProps, createAIControl } from './helpers'; + +export const ConfirmControlOutputSchema = z.object({ + result: z.enum(['confirmed', 'cancelled']), +}); + +export const ConfirmControlDef = createAIControl({ + name: 'confirm', + exposeAsTool: false, + description: + 'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the user’s click.', + inputSchema: z.object({ + icon: z + .string() + .optional() + .describe('Icon to be shown in the confirm button (any Fontawesome icon name).'), + label: z.string().describe('Label to be shown in the confirm button.'), + }), + outputSchema: ConfirmControlOutputSchema, + render: (props) => { + return ; + }, +}); + +function ConfirmControl(props: GetAIControlProps) { + const { label, icon, onSubmit } = props; + return ( + + + ); + })} + + {allowOther ? ( + + ) : null} + + +