diff --git a/.changeset/openapi-custom-language-rewrite.md b/.changeset/openapi-custom-language-rewrite.md new file mode 100644 index 000000000..7f47804e2 --- /dev/null +++ b/.changeset/openapi-custom-language-rewrite.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": minor +"gitbook": patch +--- + +OpenAPI code samples: add a "Custom" option to the language dropdown that opens the assistant pre-filled (as a draft) to rewrite the request in any language, and show a language icon next to every option. diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 61255982a..12408e162 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -101,6 +101,12 @@ export type AIChatState = { * References staged on the next user message. */ references: AIChatReference[]; + + /** + * Draft text to pre-fill into the chat input without sending it. Consumed by + * the input on the next render and then reset to `null`. + */ + inputDraft: string | null; }; export type AIChatEvent = @@ -124,6 +130,8 @@ export type AIChatController = { close: () => void; /** Post a message to the session */ postMessage: (input: { message: string }) => void; + /** Pre-fill the chat input with a draft message, without sending it */ + setDraft: (value: string | null) => void; /** Clear the conversation */ clear: () => void; /** Stage a reference on the next message */ @@ -156,6 +164,7 @@ const globalState = zustand.create(() => { error: false, initialQuery: null, references: [], + inputDraft: null, }; }); @@ -566,6 +575,7 @@ export function AIChatProvider(props: { error: false, initialQuery: null, references: [], + inputDraft: null, })); // Reset ask parameter to empty string (keeps chat open but clears content) @@ -615,6 +625,10 @@ export function AIChatProvider(props: { notify(eventsRef.current.get('focus'), {}); }, []); + const onSetDraft = React.useCallback((value: string | null) => { + globalState.setState((state) => ({ ...state, inputDraft: value })); + }, []); + const onEvent = React.useCallback( ( event: T, @@ -640,6 +654,7 @@ export function AIChatProvider(props: { close: onClose, clear: onClear, postMessage: onPostMessage, + setDraft: onSetDraft, addReference: onAddReference, removeReference: onRemoveReference, clearReferences: onClearReferences, @@ -651,6 +666,7 @@ export function AIChatProvider(props: { onClose, onClear, onPostMessage, + onSetDraft, onAddReference, onRemoveReference, onClearReferences, diff --git a/packages/gitbook/src/components/AIChat/AIChatInput.tsx b/packages/gitbook/src/components/AIChat/AIChatInput.tsx index ea1616404..21f3d7536 100644 --- a/packages/gitbook/src/components/AIChat/AIChatInput.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatInput.tsx @@ -1,7 +1,7 @@ import { t, tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useAIChatController, useAIChatState } from '../AI/useAIChat'; import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives'; @@ -23,6 +23,16 @@ export function AIChatInput(props: { const chatController = useAIChatController(); const inputRef = useRef(null); + const [value, setValue] = useState(''); + + // Consume a draft requested via the controller (e.g. "rewrite this code sample"), + // pre-filling the input without sending it. + useEffect(() => { + if (chat.inputDraft != null) { + setValue(chat.inputDraft); + chatController.setDraft(null); + } + }, [chat.inputDraft, chatController]); useEffect(() => { if (chat.opened && !disabled && !loading) { @@ -63,6 +73,8 @@ export function AIChatInput(props: { sizing="large" label="Assistant chat input" placeholder={tString(language, 'ai_chat_input_placeholder')} + value={value} + onValueChange={setValue} onSubmit={(val) => onSubmit(val as string)} submitButton={{ size: 'small', diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPICodeSampleAIProvider.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPICodeSampleAIProvider.tsx new file mode 100644 index 000000000..c6f2ac404 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPICodeSampleAIProvider.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useAIChatController, useAIConfig } from '@/components/AI'; +import { AIChatIcon, getAIChatName } from '@/components/AIChat'; +import { useLanguage } from '@/intl/client'; +import { CustomizationAIMode } from '@gitbook/api'; +import { + type OpenAPICodeSampleAssistant, + OpenAPICodeSampleAssistantProvider, +} from '@gitbook/react-openapi'; +import { useMemo } from 'react'; + +/** + * Bridge the GitBook AI assistant into the OpenAPI code sample selector, so it can + * offer a "Custom" option that opens the assistant pre-filled to rewrite a sample. + * + * When the assistant is not enabled, the provider passes `null` and the option is hidden. + */ +export function OpenAPICodeSampleAIProvider(props: { children: React.ReactNode }) { + const { children } = props; + const config = useAIConfig(); + const language = useLanguage(); + const chatController = useAIChatController(); + + const assistant = useMemo(() => { + if (config.aiMode !== CustomizationAIMode.Assistant) { + return null; + } + + return { + label: config.assistantName ?? getAIChatName(language, config.trademark), + icon: ( + + ), + onRewrite: ({ id, code, syntax, label, prompt }) => { + if (!code.trim()) { + return; + } + chatController.addReference({ + type: 'code-block', + id, + label: label ?? 'Code', + content: code, + syntax, + }); + chatController.setDraft(prompt); + chatController.open(); + chatController.focus(); + }, + }; + }, [config.aiMode, config.assistantName, config.trademark, language, chatController]); + + return ( + + {children} + + ); +} diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 035f0fc3d..3afa4abd9 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -1,5 +1,5 @@ import type { JSONDocument } from '@gitbook/api'; -import { Icon } from '@gitbook/icons'; +import { Icon, type IconName } from '@gitbook/icons'; import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi'; import type { BlockProps } from '../Block'; @@ -71,6 +71,9 @@ export function getOpenAPIContext(args: { blockStyle="max-w-full" /> ), + getCodeSampleIcon: (sample) => ( + + ), renderHeading: (headingProps) => ( button > span.react-aria-SelectValue { - @apply shrink truncate flex items-center; + @apply shrink truncate flex items-center gap-1.5; } .openapi-select > button > .react-aria-SelectValue [slot="description"] { @@ -657,7 +662,7 @@ body:has(.openapi-select-popover) { } .openapi-select-item { - @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none; + @apply text-sm flex items-center gap-2 cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none; @apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current; } @@ -665,8 +670,13 @@ body:has(.openapi-select-popover) { @apply flex flex-col gap-1 justify-start items-start; } +/* The "Custom" AI rewrite option: assistant icon next to a stacked title + description. */ +.openapi-select-item-custom .openapi-select-item-text { + @apply flex flex-col min-w-0 gap-0.5; +} + .openapi-select-item [slot="description"] { - @apply text-xs text-tint-subtle; + @apply text-xs text-tint-subtle whitespace-normal; } .openapi-select button .openapi-markdown, diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 20673d49f..198f3492c 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -17,6 +17,7 @@ import type { RenderAIMessageOptions } from '../AI'; import { AIChat } from '../AIChat'; import { AdaptiveVisitorContextProvider } from '../Adaptive'; import { Announcement } from '../Announcement'; +import { OpenAPICodeSampleAIProvider } from '../DocumentView/OpenAPI/OpenAPICodeSampleAIProvider'; import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown'; import { InsightsProvider, VisitorProvider } from '../Insights'; import { SearchContainer, getSearchBaseProps } from '../Search'; @@ -91,7 +92,9 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) { > - {children} + + {children} + diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 8b35688f2..8983f1a1c 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -165,11 +165,18 @@ function generateCodeSamples(props: { ); return codeSampleGenerators.map((generator) => { + const icon = context.getCodeSampleIcon?.({ + id: generator.id, + syntax: generator.syntax, + label: generator.label, + }); if (mediaTypeRendererFactories.length > 0) { const renderers = mediaTypeRendererFactories.map((generate) => generate(generator)); return { key: `default-${generator.id}`, label: generator.label, + icon, + syntax: generator.syntax, body: ( = null; @@ -312,6 +323,11 @@ function getCustomCodeSamples(props: { .map((sample, index) => ({ key: `custom-sample-${sample.lang}-${index}`, label: sample.label || sample.lang, + icon: context.getCodeSampleIcon?.({ + syntax: sample.lang, + label: sample.label || sample.lang, + }), + syntax: sample.lang, body: context.renderCodeBlock({ code: sample.source, syntax: sample.lang, diff --git a/packages/react-openapi/src/OpenAPICodeSampleAssistant.tsx b/packages/react-openapi/src/OpenAPICodeSampleAssistant.tsx new file mode 100644 index 000000000..12e6070b3 --- /dev/null +++ b/packages/react-openapi/src/OpenAPICodeSampleAssistant.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +/** + * A request to rewrite a code sample with the assistant. + */ +export interface OpenAPICodeSampleRewriteInput { + /** Stable identifier of the code sample, used to stage the reference. */ + id: string; + /** Source code of the currently displayed code sample. */ + code: string; + /** Syntax/language of the code sample (e.g. `bash`, `python`). */ + syntax?: string; + /** Human-readable label of the code sample (e.g. `cURL`). */ + label?: string; + /** Localized prompt to pre-fill in the assistant input as a draft. */ + prompt: string; +} + +/** + * Capability provided by the host app to let the assistant rewrite a code sample. + * When present, the code sample selector exposes a "Custom" option. + */ +export interface OpenAPICodeSampleAssistant { + /** Display name of the assistant (e.g. `GitBook Assistant`). */ + label: string; + /** Logo/icon of the assistant, displayed next to the "Custom" option. */ + icon: React.ReactNode; + /** + * Open the assistant with the given code sample staged as a reference and the + * prompt pre-filled as a draft (not sent). + */ + onRewrite: (input: OpenAPICodeSampleRewriteInput) => void; +} + +const OpenAPICodeSampleAssistantContext = createContext(null); + +/** + * Provide the assistant capability to the code sample selector. + * Pass `null` to disable the "Custom" rewrite option. + */ +export function OpenAPICodeSampleAssistantProvider(props: { + value: OpenAPICodeSampleAssistant | null; + children: React.ReactNode; +}) { + return ( + + {props.children} + + ); +} + +/** + * Access the assistant capability, or `null` when no assistant is available. + */ +export function useOpenAPICodeSampleAssistant() { + return useContext(OpenAPICodeSampleAssistantContext); +} diff --git a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx index d8b80a9f7..2b0390905 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx @@ -1,15 +1,25 @@ 'use client'; -import { useCallback } from 'react'; +import { Fragment, useCallback, useRef } from 'react'; import type { Key } from 'react-aria'; +import { Separator, Text } from 'react-aria-components'; import { useStore } from 'zustand'; +import { useOpenAPICodeSampleAssistant } from './OpenAPICodeSampleAssistant'; import { OpenAPIPath } from './OpenAPIPath'; import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect'; import { StaticSection } from './StaticSection'; import type { OpenAPIClientContext } from './context'; import { getOrCreateStoreByKey } from './getOrCreateStoreByKey'; +import { tString } from './translate'; import type { OpenAPIOperationData } from './types'; +/** + * Key of the synthetic "Custom" option that opens the assistant to rewrite the + * code sample. Chosen to avoid colliding with generated (`default-*`) or custom + * (`custom-sample-*`) sample keys. + */ +const CUSTOM_CODE_SAMPLE_KEY = 'gitbook-ai-rewrite'; + function useCodeSampleState(initialKey: Key = 'default') { const store = useStore(getOrCreateStoreByKey('codesample', initialKey)); return { @@ -19,36 +29,104 @@ function useCodeSampleState(initialKey: Key = 'default') { } type CodeSampleItem = OpenAPISelectItem & { + icon?: React.ReactNode; + syntax?: string; body: React.ReactNode; footer?: React.ReactNode; }; function OpenAPICodeSampleHeader(props: { items: CodeSampleItem[]; + selected: CodeSampleItem; data: OpenAPIOperationData; selectIcon?: React.ReactNode; context: OpenAPIClientContext; + getSelectedCode: () => string; }) { - const { data, items, selectIcon, context } = props; + const { data, items, selected, selectIcon, context, getSelectedCode } = props; + const assistant = useOpenAPICodeSampleAssistant(); + + // When an assistant is available, append a "Custom" option that opens it + // pre-filled to rewrite the currently displayed sample in any language. + const customItem: CodeSampleItem | null = assistant + ? { + key: CUSTOM_CODE_SAMPLE_KEY, + label: tString(context.translation, 'code_sample_custom'), + action: true, + body: null, + } + : null; + const allItems = customItem ? [...items, customItem] : items; + + const onCustomRewrite = () => { + if (!assistant) { + return; + } + const code = getSelectedCode(); + if (!code.trim()) { + return; + } + assistant.onRewrite({ + id: `${context.blockKey ?? 'openapi'}-${String(selected.key)}`, + code, + syntax: selected.syntax, + label: typeof selected.label === 'string' ? selected.label : undefined, + prompt: tString(context.translation, 'code_sample_rewrite_prompt'), + }); + }; return ( <> - {items.length > 1 ? ( + {allItems.length > 1 ? ( - {items.map((item) => ( - - {item.label} - - ))} + {allItems.map((item) => + item.key === CUSTOM_CODE_SAMPLE_KEY && assistant ? ( + + + + {assistant.icon} + + {item.label} + + {tString( + context.translation, + 'code_sample_custom_description', + assistant.label + )} + + + + + ) : ( + + {item.icon ?? null} + {item.label} + + ) + )} ) : items[0] ? ( - {items[0].label} + + {items[0].icon ?? null} + {items[0].label} + ) : null} ); @@ -66,6 +144,7 @@ export function OpenAPICodeSampleBody(props: { } const state = useCodeSampleState(items[0]?.key); + const panelRef = useRef(null); const selected = items.find((item) => item.key === state.key) || items[0]; @@ -81,14 +160,48 @@ export function OpenAPICodeSampleBody(props: { selectIcon={selectIcon} data={data} items={items} + selected={selected} + getSelectedCode={() => readPanelCodeText(panelRef.current)} /> } className="openapi-codesample" > -
+
{selected.body ? selected.body : null} {selected.footer ? selected.footer : null}
); } + +/** + * Extract the plain code text of the code block currently displayed in a panel. + * Mirrors the host code block rendering, where empty lines are represented with + * a span of class "ew". + */ +function readPanelCodeText(panel: HTMLElement | null): string { + const code = panel?.querySelector('code'); + if (!code) { + return ''; + } + + let text = ''; + const iterate = (node: Node) => { + if (node instanceof HTMLBRElement) { + text += '\n'; + } else if (node instanceof HTMLSpanElement) { + if (node.classList.contains('ew')) { + text += '\n'; + } else { + text += node.innerText; + } + } else if (node instanceof HTMLElement) { + node.childNodes.forEach(iterate); + } else { + text += node.textContent ?? ''; + } + }; + iterate(code); + + return text; +} diff --git a/packages/react-openapi/src/OpenAPISelect.tsx b/packages/react-openapi/src/OpenAPISelect.tsx index a4546f5b5..af8337321 100644 --- a/packages/react-openapi/src/OpenAPISelect.tsx +++ b/packages/react-openapi/src/OpenAPISelect.tsx @@ -20,6 +20,11 @@ import { getOrCreateStoreByKey } from './getOrCreateStoreByKey'; export type OpenAPISelectItem = { key: Key; label: string | React.ReactNode; + /** + * If `true`, selecting this item runs `onAction` instead of changing the selection, + * leaving the current selection unchanged (e.g. an item that opens a dialog). + */ + action?: boolean; }; interface OpenAPISelectProps extends Omit, 'children'> { @@ -31,6 +36,10 @@ interface OpenAPISelectProps extends Omit void; } export function useSelectState(stateKey = 'select-state', initialKey: Key = 'default') { @@ -42,8 +51,19 @@ export function useSelectState(stateKey = 'select-state', initialKey: Key = 'def } export function OpenAPISelect(props: OpenAPISelectProps) { - const { icon, items, children, className, placement, stateKey, value, onChange, defaultValue } = - props; + const { + icon, + items, + children, + className, + placement, + stateKey, + value, + onChange, + defaultValue, + onAction, + ...selectProps + } = props; const state = useSelectState(stateKey, defaultValue ?? items[0]?.key); @@ -52,9 +72,14 @@ export function OpenAPISelect(props: OpenAPISelectP return (