mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 02:53:29 +00:00
Add AI chat reference button on text selection (#4326)
This commit is contained in:
@@ -19,7 +19,13 @@ export type PageReference = BaseAIChatReference & {
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type AIChatReference = CodeBlockReference | PageReference;
|
||||
export type TextReference = BaseAIChatReference & {
|
||||
type: 'text';
|
||||
/** The selected text content. */
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AIChatReference = CodeBlockReference | PageReference | TextReference;
|
||||
|
||||
/**
|
||||
* Serialize the staged references into a preamble prepended to the user's message,
|
||||
@@ -42,6 +48,11 @@ export function serializeReferences(refs: AIChatReference[]): string {
|
||||
sections.push(serializeCodeBlockReferences(codeRefs));
|
||||
}
|
||||
|
||||
const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
|
||||
if (textRefs.length > 0) {
|
||||
sections.push(serializeTextReferences(textRefs));
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
@@ -66,6 +77,19 @@ function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
|
||||
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function serializeTextReferences(refs: TextReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
|
||||
return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function quoteText(content: string): string {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildCodeBlockFence(ref: CodeBlockReference): string {
|
||||
const { label, content, syntax } = ref;
|
||||
let max = 2;
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AIChatReferenceChips(props: {
|
||||
ref.type === 'code-block' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{ref.label}
|
||||
{ref.type === 'text' ? ref.content : ref.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -55,6 +55,10 @@ export function AIChatReferenceChips(props: {
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : ref.type === 'text' ? (
|
||||
// A text selection has no persistent DOM anchor to navigate to, so the
|
||||
// excerpt is shown as plain (non-interactive) content.
|
||||
<span className={triggerClassName}>{content}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -94,6 +98,8 @@ function getReferenceIcon(ref: AIChatReference): IconName {
|
||||
return 'code';
|
||||
case 'page':
|
||||
return 'memo';
|
||||
case 'text':
|
||||
return 'quote-left';
|
||||
default:
|
||||
assertNever(ref);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import fnv1a from '@sindresorhus/fnv1a';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { useIsMobile } from '@/components/hooks/useIsMobile';
|
||||
import { useIsMounted } from '@/components/hooks/useIsMounted';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
|
||||
import { AIChatIcon } from '../AIChatIcon';
|
||||
import { useStableTextSelection } from './useStableTextSelection';
|
||||
|
||||
/** Gap between the selection and the button. */
|
||||
const GAP = 8;
|
||||
/** Minimum distance to the viewport edges. */
|
||||
const MARGIN = 8;
|
||||
|
||||
/**
|
||||
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
|
||||
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
|
||||
*/
|
||||
export function AskAITextSelection() {
|
||||
const config = useAIConfig();
|
||||
const language = useLanguage();
|
||||
const chatController = useAIChatController();
|
||||
const isMobile = useIsMobile();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
|
||||
|
||||
const toolbarRef = React.useRef<HTMLDivElement>(null);
|
||||
const { selection, clear } = useStableTextSelection({
|
||||
rootSelector: '[data-content-ref-root]',
|
||||
enabled,
|
||||
ignoreRef: toolbarRef,
|
||||
});
|
||||
|
||||
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Position once the button has been measured, so it can be centered and clamped to the viewport.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const el = toolbarRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = el.offsetWidth;
|
||||
const height = el.offsetHeight;
|
||||
const { anchor } = selection;
|
||||
|
||||
let top = anchor.top - GAP - height;
|
||||
if (top < MARGIN) {
|
||||
// Not enough room above the selection: drop below it.
|
||||
top = anchor.bottom + GAP;
|
||||
}
|
||||
top = Math.min(top, window.innerHeight - height - MARGIN);
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(anchor.centerX - width / 2, MARGIN),
|
||||
window.innerWidth - width - MARGIN
|
||||
);
|
||||
|
||||
setCoords({ top, left });
|
||||
}, [selection]);
|
||||
|
||||
const onClick = () => {
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const content = selection.text;
|
||||
if (!content.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatController.addReference({
|
||||
type: 'text',
|
||||
id: `text-${fnv1a(content, { size: 32 })}`,
|
||||
content,
|
||||
});
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
clear();
|
||||
};
|
||||
|
||||
if (!enabled || !isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{selection ? (
|
||||
<motion.div
|
||||
ref={toolbarRef}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.92 }}
|
||||
transition={{ duration: 0.12, ease: 'easeOut' }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? 0,
|
||||
left: coords?.left ?? 0,
|
||||
zIndex: 40,
|
||||
}}
|
||||
// Keep the selection alive: prevent the button from stealing focus on click.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
icon={<AIChatIcon state="default" trademark={config.trademark} />}
|
||||
onClick={onClick}
|
||||
className="shadow-sm"
|
||||
>
|
||||
{t(language, 'ask')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AskAITextSelection';
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
type SelectionAnchor = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
centerX: number;
|
||||
};
|
||||
|
||||
export type StableTextSelection = {
|
||||
anchor: SelectionAnchor;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
/** Selector of the content root; both selection endpoints must be inside it. */
|
||||
rootSelector: string;
|
||||
enabled: boolean;
|
||||
/** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
|
||||
ignoreRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
const KEYBOARD_DEBOUNCE_MS = 250;
|
||||
const SCROLL_SETTLE_MS = 200;
|
||||
// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
|
||||
const POINTER_SETTLE_MS = 120;
|
||||
|
||||
/**
|
||||
* Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
|
||||
* doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
|
||||
* when a new gesture starts.
|
||||
*/
|
||||
export function useStableTextSelection(options: Options): {
|
||||
selection: StableTextSelection | null;
|
||||
clear: () => void;
|
||||
} {
|
||||
const { rootSelector, enabled, ignoreRef } = options;
|
||||
const [selection, setSelection] = React.useState<StableTextSelection | null>(null);
|
||||
|
||||
const clear = React.useCallback(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelection(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let pointerDown = false;
|
||||
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearKeyboardTimer = () => {
|
||||
if (keyboardTimer) {
|
||||
clearTimeout(keyboardTimer);
|
||||
keyboardTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearFinalizeTimer = () => {
|
||||
if (finalizeTimer) {
|
||||
clearTimeout(finalizeTimer);
|
||||
finalizeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = () => {
|
||||
// A new gesture started; it will reschedule its own finalize.
|
||||
if (pointerDown) {
|
||||
return;
|
||||
}
|
||||
setSelection(readStableSelection(rootSelector));
|
||||
};
|
||||
|
||||
const schedulePointerFinalize = () => {
|
||||
clearKeyboardTimer();
|
||||
clearFinalizeTimer();
|
||||
finalizeTimer = setTimeout(() => {
|
||||
finalizeTimer = null;
|
||||
finalize();
|
||||
}, POINTER_SETTLE_MS);
|
||||
};
|
||||
|
||||
const isIgnored = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!ignoreRef.current?.contains(target);
|
||||
|
||||
const onSelectionChange = () => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
|
||||
clearKeyboardTimer();
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
// Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
|
||||
if (pointerDown || finalizeTimer) {
|
||||
return;
|
||||
}
|
||||
clearKeyboardTimer();
|
||||
keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (isIgnored(event.target)) {
|
||||
return;
|
||||
}
|
||||
// New gesture: hide and cancel a pending show so it can't fire mid double-click.
|
||||
pointerDown = true;
|
||||
clearFinalizeTimer();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (isIgnored(event.target)) {
|
||||
return;
|
||||
}
|
||||
pointerDown = false;
|
||||
schedulePointerFinalize();
|
||||
};
|
||||
|
||||
// pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
|
||||
const onPointerCancel = () => {
|
||||
pointerDown = false;
|
||||
schedulePointerFinalize();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
setSelection(null);
|
||||
if (scrollTimer) {
|
||||
clearTimeout(scrollTimer);
|
||||
}
|
||||
scrollTimer = setTimeout(() => {
|
||||
if (!pointerDown) {
|
||||
finalize();
|
||||
}
|
||||
}, SCROLL_SETTLE_MS);
|
||||
};
|
||||
|
||||
const onWindowBlur = () => {
|
||||
clearKeyboardTimer();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', onPointerCancel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', onPointerCancel, true);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
clearKeyboardTimer();
|
||||
clearFinalizeTimer();
|
||||
if (scrollTimer) {
|
||||
clearTimeout(scrollTimer);
|
||||
}
|
||||
};
|
||||
}, [enabled, rootSelector, ignoreRef]);
|
||||
|
||||
return { selection, clear };
|
||||
}
|
||||
|
||||
function readStableSelection(rootSelector: string): StableTextSelection | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = sel.toString().trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = document.querySelector(rootSelector);
|
||||
if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = getSelectionAnchor(sel);
|
||||
return anchor ? { anchor, text } : null;
|
||||
}
|
||||
|
||||
/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
|
||||
function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) {
|
||||
return null;
|
||||
}
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
return null;
|
||||
}
|
||||
return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './AIChatButton';
|
||||
export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
export * from './AIChatControlButton';
|
||||
export * from './AskAITextSelection';
|
||||
|
||||
@@ -106,18 +106,20 @@ export async function PageBody(props: {
|
||||
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
|
||||
>
|
||||
<SuspenseLoadedHint />
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
}}
|
||||
/>
|
||||
<div className="contents" data-content-ref-root="">
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</OptionalSuspense>
|
||||
) : (
|
||||
<PageBodyBlankslate page={page} context={context} />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { GITBOOK_APP_URL } from '@/lib/env';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIChatProvider } from '../AI';
|
||||
import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat } from '../AIChat';
|
||||
import { AIChat, AskAITextSelection } from '../AIChat';
|
||||
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
@@ -127,7 +127,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
|
||||
<NavigationLoader />
|
||||
{customization.ai?.mode === CustomizationAIMode.Assistant ? <AIChat /> : null}
|
||||
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
|
||||
<>
|
||||
<AIChat />
|
||||
<AskAITextSelection />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Chat panel shifts content left when open */}
|
||||
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
|
||||
|
||||
Reference in New Issue
Block a user