Merge remote-tracking branch 'origin/main' into claude/exciting-brahmagupta-7cd6fa

# Conflicts:
#	packages/gitbook/src/components/AI/useAIChat.tsx
This commit is contained in:
Zeno Kapitein
2026-07-02 08:45:50 +02:00
97 changed files with 1533 additions and 353 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a hover affordance in the document margin to ask the AI Assistant about a paragraph. On devices with a fine pointer, hovering a top-level paragraph reveals a small button that stages the paragraph's text as context and opens the assistant — making the existing text-selection "Ask" flow more discoverable.
+7
View File
@@ -0,0 +1,7 @@
---
"@gitbook/openapi-parser": patch
"@gitbook/react-openapi": patch
"gitbook": patch
---
Add an "Available in MCP" badge on OpenAPI operations marked with `x-gitbook-mcp: true`. When `x-gitbook-mcp-url` is set (on the operation, path, or root — most specific wins), the badge becomes a button that copies the MCP server URL to the clipboard.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Reduce the size of `searchDocumentation` MCP responses by returning only the best-matching section per page instead of concatenating every section body.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show changed pages in preview toolbars for change requests and revisions.
+2 -2
View File
@@ -360,7 +360,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.185.0",
"@gitbook/api": "0.186.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -756,7 +756,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="],
"@gitbook/api": ["@gitbook/api@0.186.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-UAF+tVtstqyA8s9OYXoYhgHfOUQaaQBRjQX4WLEB1YxcYtJZ9YM/EM8Ouj38Tg4qW3tr76t1/OESZipCE6t8cw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -43,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.185.0",
"@gitbook/api": "0.186.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+1 -12
View File
@@ -185,17 +185,8 @@ const searchTestCases: Test[] = [
await waitForCookiesDialog(page);
const searchInput = page.locator('css=[data-testid="search-input"]');
// Focus search input, expecting recommended questions
await searchInput.focus();
await expect(page.getByTestId('search-results')).toBeVisible({
timeout: 30_000,
});
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
// Fill search input, expecting AI search option
await searchInput.focus();
await searchInput.fill(AI_PROMPT);
const aiSearchResult = page.getByTestId('search-ask-question');
await expect(aiSearchResult).toBeVisible();
@@ -1364,8 +1355,6 @@ const testCases: TestsCase[] = [
name: 'Without page actions',
url: getCustomizationURL({
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
+2 -9
View File
@@ -390,9 +390,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
default: CustomizationDefaultThemeMode.System,
toggeable: true,
},
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
@@ -405,20 +402,16 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
pageActions: {
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Assistant,
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
CustomizationPageActionType.Pdf,
],
},
trademark: {
@@ -5,6 +5,7 @@ import { getExposableError, throwIfDataError } from '@/lib/data';
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
import { resolvePagePath } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getBestScoredResult } from '@/lib/search';
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
@@ -12,6 +13,18 @@ import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
/**
* Fire-and-forget insights tracking for the MCP endpoint. A tracking failure (e.g. a 422 from the
* insights API) must never reject into the request lifecycle, or it surfaces as an MCP transport error.
*/
function trackMcpEvent(args: Parameters<typeof trackServerInsightsEvents>[0]) {
waitUntil(
trackServerInsightsEvents(args).catch((error) => {
console.error('Failed to track MCP insights event:', error);
})
);
}
export async function handleMcpRequest(
rawRequest: NextRequest,
params: RouteLayoutParams,
@@ -20,13 +33,8 @@ export async function handleMcpRequest(
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
// Use the configured `items` list when the API provides it, and fall back to the deprecated
// `mcp` flag otherwise (legacy mode), since this endpoint is called directly and cannot rely on
// any page-rendering fallback.
const { pageActions } = context.customization;
const isMcpEnabled = pageActions.items
? pageActions.items.includes(CustomizationPageActionType.Mcp)
: pageActions.mcp;
const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp);
if (!isMcpEnabled) {
return new Response('Not Found', { status: 404 });
}
@@ -36,21 +44,19 @@ export async function handleMcpRequest(
requestURL.search = rawRequest.nextUrl.search;
const request = new Request(requestURL, rawRequest);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: context.site.id,
events: [
{
type: 'mcp_request',
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
trackMcpEvent({
organizationId: context.organizationId,
siteId: context.site.id,
events: [
{
type: 'mcp_request',
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
],
request,
})
);
},
],
request,
});
const mcpHandler = createMcpHandler(
(server) => {
@@ -60,6 +66,13 @@ export async function handleMcpRequest(
{
query: z.string(),
},
{
title: 'Search documentation',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
@@ -70,22 +83,20 @@ export async function handleMcpRequest(
})
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
],
request,
})
);
},
],
request,
});
return {
content: results.flatMap((result) => {
@@ -118,9 +129,9 @@ export async function handleMcpRequest(
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
const body = getBestScoredResult(
(pageResult.sections ?? []).filter((section) => section.body)
)?.body;
return {
type: 'text',
@@ -160,6 +171,13 @@ export async function handleMcpRequest(
return z.NEVER;
}),
},
{
title: 'Get page content',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
async ({ url }) => {
try {
const match = findSiteSpaceByUrl(context.structure, url);
@@ -191,24 +209,22 @@ export async function handleMcpRequest(
resolved.page
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
],
request,
})
);
},
],
request,
});
return { content: [{ type: 'text', text: markdown }] };
} catch (error) {
@@ -8,6 +8,7 @@ import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getBestScoredResult } from '@/lib/search';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
import type {
@@ -187,10 +188,7 @@ function transformSitePageResult(args: {
}) ?? [];
// Find the best-scoring section to use as a body preview on the page result.
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
(best, section) => (!best || section.score > best.score ? section : best),
undefined
);
const bestSection = getBestScoredResult(pageSections);
if (bestSection) {
page.bestSection = {
href: bestSection.href,
@@ -119,10 +119,10 @@ export type AIChatState = {
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`.
* Draft text to pre-fill the chat input with, without sending it. The chat input consumes
* this value (seeding its editable content) and then clears it back to an empty string.
*/
inputDraft: string | null;
draft: string;
};
export type AIChatEvent =
@@ -146,8 +146,6 @@ 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 */
@@ -158,6 +156,8 @@ export type AIChatController = {
clearReferences: () => void;
/** Focus the chat input */
focus: () => void;
/** Pre-fill the chat input with draft text, without sending it. */
setDraft: (draft: string) => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
@@ -181,7 +181,7 @@ const globalState = zustand.create<AIChatState>(() => {
error: false,
initialQuery: null,
references: [],
inputDraft: null,
draft: '',
};
});
@@ -621,7 +621,6 @@ export function AIChatProvider(props: {
error: false,
initialQuery: null,
references: [],
inputDraft: null,
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
@@ -671,8 +670,8 @@ export function AIChatProvider(props: {
notify(eventsRef.current.get('focus'), {});
}, []);
const onSetDraft = React.useCallback((value: string | null) => {
globalState.setState((state) => ({ ...state, inputDraft: value }));
const onSetDraft = React.useCallback((draft: string) => {
globalState.setState({ draft });
}, []);
const onEvent = React.useCallback(
@@ -700,11 +699,11 @@ export function AIChatProvider(props: {
close: onClose,
clear: onClear,
postMessage: onPostMessage,
setDraft: onSetDraft,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
setDraft: onSetDraft,
on: onEvent,
};
}, [
@@ -712,11 +711,11 @@ export function AIChatProvider(props: {
onClose,
onClear,
onPostMessage,
onSetDraft,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onSetDraft,
onEvent,
]);
@@ -23,16 +23,29 @@ export function AIChatInput(props: {
const chatController = useAIChatController();
const inputRef = useRef<HTMLTextAreaElement>(null);
// Controlled value so a pre-filled draft can be injected without sending it.
const [value, setValue] = useState('');
// Consume a draft requested via the controller (e.g. "rewrite this code sample"),
// pre-filling the input without sending it.
// Consume a draft staged via the controller (e.g. the per-paragraph "Ask" button): seed the
// input, focus it with the cursor at the end, then clear the pending draft so it is applied
// once and not re-injected on a later mount.
useEffect(() => {
if (chat.inputDraft != null) {
setValue(chat.inputDraft);
chatController.setDraft(null);
if (!chat.draft) {
return;
}
}, [chat.inputDraft, chatController]);
setValue(chat.draft);
chatController.setDraft('');
const raf = requestAnimationFrame(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
}
});
return () => cancelAnimationFrame(raf);
}, [chat.draft, chatController]);
useEffect(() => {
if (chat.opened && !disabled && !responding) {
@@ -0,0 +1,81 @@
'use client';
import fnv1a from '@sindresorhus/fnv1a';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { Button } from '@/components/primitives';
import { t, tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getAIChatName } from '../AIChat';
import { AIChatIcon } from '../AIChatIcon';
/**
* A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on
* devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens
* the AI chat — a more discoverable variant of the text-selection "Ask" affordance.
*
* The button is absolutely positioned so it never affects the document flow, and its visibility is
* driven purely by the `group/ask-ai` hover state of its paragraph wrapper.
*/
export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) {
const { content, className } = props;
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const onClick = () => {
const text = content.trim();
if (!text) {
return;
}
chatController.addReference({
type: 'text',
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open();
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
return (
<div
className={tcls(
// Sit in the left margin, flush against the paragraph so there is no hover gap.
'absolute top-0 right-full z-10 pr-1',
// Per-block nudges: `in-[…]` matches an ancestor (tag or class) with no markup
// changes elsewhere — add a self-contained rule per block type to clear its gutter.
'in-[.hint]:-top-0.5 in-[.hint]:pr-2',
'in-[blockquote]:pr-0',
// Hover affordance only: hidden until the paragraph (or the button) is hovered.
'invisible opacity-0 transition-opacity duration-150',
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
// Never shown on touch / hover-less contexts.
'not-pointer-fine:hidden',
'in-[[role=table]]:hidden',
className
)}
>
<Button
variant="blank"
size="xsmall"
iconOnly
icon={<AIChatIcon state="default" trademark={config.trademark} />}
label={t(
language,
'ai_chat_ask_about_this',
config.assistantName ?? getAIChatName(language, config.trademark)
)}
onClick={onClick}
// Don't steal focus (and shift the scroll position) when clicked with the mouse.
onMouseDown={(event) => event.preventDefault()}
className={tcls(
'bg-tint-base',
'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning'
)}
/>
</div>
);
}
@@ -0,0 +1 @@
export * from './AskAIParagraphButton';
@@ -4,3 +4,4 @@ export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
export * from './AskAITextSelection';
export * from './AskAIParagraphButton';
@@ -1,5 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import { AdminToolbarClient } from './AdminToolbarClient';
import { getToolbarChangedPages } from './changedPages';
import type { AdminToolbarContext } from './types';
export interface AdminToolbarProps {
@@ -11,6 +12,7 @@ export interface AdminToolbarProps {
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
const changedPages = await getToolbarChangedPages(context);
// Create a minimal context to avoid serializing and passing too many data to the client
const minimalContext: AdminToolbarContext = {
@@ -57,6 +59,7 @@ export async function AdminToolbar(props: AdminToolbarProps) {
published: context.site.urls.published,
},
},
changedPages,
};
return <AdminToolbarClient context={minimalContext} />;
@@ -4,6 +4,7 @@ import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { ChangedPagesButton } from './ChangedPagesButton';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -150,6 +151,8 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
@@ -211,6 +214,9 @@ function RevisionToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Open commit in Git client */}
<ToolbarButton
title={
@@ -0,0 +1,172 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { motion, useReducedMotion } from 'motion/react';
import type { SVGProps } from 'react';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
import type { ToolbarButtonProps } from './Toolbar';
import type { MinimalChangedPage, MinimalChangedPages } from './types';
const STATUS_ICON_STYLES: Record<
MinimalChangedPage['status'],
{ chip: string; icon: IconName; iconClassName: string; iconStyle?: IconStyle }
> = {
created: {
chip: 'border-green-200 bg-green-50',
icon: 'plus',
iconClassName: 'size-3.5 text-green-600',
},
edited: {
chip: 'border-blue-200 bg-blue-50',
icon: 'pencil',
iconClassName: 'size-3 text-blue-600',
iconStyle: IconStyle.Regular,
},
moved: {
chip: 'border-blue-200 bg-blue-50',
icon: 'arrow-right',
iconClassName: 'size-3 text-blue-600',
},
deleted: {
chip: 'border-red-200 bg-red-50',
icon: 'minus',
iconClassName: 'size-3 text-red-600',
},
};
const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center rounded-md border';
/**
* A button that opens a popover with quick links to the _changed_ pages.
*/
export function ChangedPagesButton(props: {
changedPages: MinimalChangedPages | null;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { changedPages, motionValues } = props;
const reduceMotion = useReducedMotion();
if (!changedPages?.pages.length) {
return null;
}
const changedPagesCount = changedPages.pages.length + changedPages.more;
const changedPagesLabel = `${changedPagesCount} changed ${
changedPagesCount === 1 ? 'page' : 'pages'
}`;
const trigger = (
<button
type="button"
aria-label="View changed pages"
className="relative size-8 cursor-pointer border-0 bg-transparent p-0"
>
<motion.span
aria-hidden="true"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<DiffIcon className="size-4 shrink-0" />
</motion.span>
</button>
);
return (
<DropdownMenu
button={trigger}
buttonTooltip="View changed pages"
side="top"
align="end"
sideOffset={12}
className="w-80 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0"
>
<div className="px-3 py-2 font-medium text-sm text-tint">{changedPagesLabel}</div>
<DropdownMenuSeparator className="m-0" />
<div className="flex max-h-80 flex-col overflow-y-auto">
{changedPages.pages.map((page) => (
<ChangedPageMenuItem key={page.id} page={page} />
))}
</div>
{changedPages.more > 0 ? (
<>
<DropdownMenuSeparator />
<div className="px-3 py-1 text-tint-subtle text-xs">
{changedPages.more} more changes not shown
</div>
</>
) : null}
</DropdownMenu>
);
}
function ChangedPageMenuItem(props: { page: MinimalChangedPage }) {
const { page } = props;
const actionLabel = page.action === 'editor' ? 'Open in editor' : 'Open';
return (
<DropdownMenuItem
href={page.href}
className="group/changed-page relative w-full min-w-0 items-center rounded-none px-3 py-1.5"
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<ChangedPageStatusIcon status={page.status} />
<span className="min-w-0 flex-1 transition-[padding] group-hover/changed-page:pr-24 group-focus-visible/changed-page:pr-24 group-data-[highlighted]/changed-page:pr-24">
<span className="block truncate font-medium text-sm leading-4">
{page.title}
</span>
<span className="block truncate text-tint-subtle text-xs leading-4">
/{page.path || ''}
</span>
</span>
<span className="pointer-events-none absolute right-2 flex items-center gap-0.5 bg-tint-hover pl-3 text-tint-subtle text-xs opacity-0 transition-opacity group-hover/changed-page:opacity-100 group-focus-visible/changed-page:opacity-100 group-data-[highlighted]/changed-page:opacity-100">
{actionLabel}
<Icon icon="chevron-right" className="size-3 shrink-0" />
</span>
</span>
</DropdownMenuItem>
);
}
function ChangedPageStatusIcon(props: { status: MinimalChangedPage['status'] }) {
const { status } = props;
const style = STATUS_ICON_STYLES[status];
return (
<span className={tcls(STATUS_ICON_CHIP_CLASS, style.chip)}>
<Icon icon={style.icon} iconStyle={style.iconStyle} className={style.iconClassName} />
</span>
);
}
function DiffIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 16 16" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="M8 2.4a.6.6 0 0 1 .6.6v2.9h2.9a.6.6 0 1 1 0 1.2H8.6V10a.6.6 0 1 1-1.2 0V7.1H4.5a.6.6 0 0 1 0-1.2h2.9V3a.6.6 0 0 1 .6-.6M4.5 12.4a.6.6 0 1 0 0 1.2h7a.6.6 0 1 0 0-1.2z"
/>
</svg>
);
}
@@ -0,0 +1,57 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getDataOrNull, ignoreAllThrownError } from '@/lib/data';
import { getToolbarChangedPagesFromChanges } from './changedPagesMapper';
import type { MinimalChangedPages } from './types';
export const TOOLBAR_CHANGED_PAGES_LIMIT = 100;
/**
* Fetch and reduce semantic changes to the minimal list needed by the toolbar client.
*/
export async function getToolbarChangedPages(
context: GitBookSiteContext
): Promise<MinimalChangedPages | null> {
// The changed-pages list is a non-critical toolbar enhancement. Any failure to fetch or
// reduce the changes should simply hide the button, never break rendering of the site page.
return ignoreAllThrownError(fetchToolbarChangedPages(context));
}
async function fetchToolbarChangedPages(
context: GitBookSiteContext
): Promise<MinimalChangedPages | null> {
const changes = context.changeRequest
? await getDataOrNull(
context.dataFetcher.getChangeRequestChanges({
spaceId: context.space.id,
changeRequestId: context.changeRequest.id,
limit: TOOLBAR_CHANGED_PAGES_LIMIT,
})
)
: context.revisionId !== context.space.revision
? await getDataOrNull(
context.dataFetcher.getRevisionSemanticChanges({
spaceId: context.space.id,
revisionId: context.revisionId,
limit: TOOLBAR_CHANGED_PAGES_LIMIT,
})
)
: null;
if (!changes) {
return null;
}
const pages = getToolbarChangedPagesFromChanges({
changes: changes.changes,
editorBaseURL: context.changeRequest?.urls.app ?? context.revision.urls.app,
linker: context.linker,
pages: context.revision.pages,
});
return pages.length > 0
? {
pages,
more: changes.more ?? 0,
}
: null;
}
@@ -0,0 +1,114 @@
import type { GitBookLinker } from '@/lib/links';
import { getPagePath, resolvePageId } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import {
type ChangedRevisionPage,
type Revision,
RevisionPageType,
type RevisionSemanticChange,
} from '@gitbook/api';
import type { MinimalChangedPage } from './types';
// When one page has multiple semantic changes, show the label that best summarizes its final state.
const CHANGE_STATUS_SUMMARY_ORDER: Array<MinimalChangedPage['status']> = [
'edited',
'moved',
'created',
'deleted',
];
export function getToolbarChangedPagesFromChanges(input: {
changes: RevisionSemanticChange[];
linker: Pick<GitBookLinker, 'toPathForPage'>;
pages: Revision['pages'];
editorBaseURL: string;
}): MinimalChangedPage[] {
const changedPages = new Map<string, MinimalChangedPage>();
for (const change of input.changes) {
// Only real document page changes can become toolbar rows; computed/link/group pages are ignored.
const pageChange = getChangedPageChange(change);
if (!pageChange || pageChange.page.type !== RevisionPageType.Document) {
continue;
}
// Resolve against the current revision so preview links use the current title and path.
const resolved = resolvePageId(input.pages, pageChange.page.id);
const isDeleted = pageChange.status === 'deleted';
const currentPage = resolved?.page;
const path = currentPage ? getPagePath(input.pages, currentPage) : pageChange.page.path;
if (!path && !currentPage) {
continue;
}
// Missing non-deleted pages cannot be linked reliably from the preview.
if (!currentPage && !isDeleted) {
continue;
}
// Deleted pages may no longer exist in the preview, so link to their editor change.
if (!currentPage) {
changedPages.set(pageChange.page.id, {
id: pageChange.page.id,
title: pageChange.page.title,
path: path ?? '',
href: joinPathWithBaseURL(input.editorBaseURL, path ?? ''),
status: pageChange.status,
action: 'editor',
});
continue;
}
const pageId = currentPage.id;
const existing = changedPages.get(pageId);
// The API can return multiple changes for one page; keep the clearest one-row summary.
if (existing && shouldKeepExistingPageChange(existing.status, pageChange.status)) {
continue;
}
changedPages.set(pageId, {
id: pageId,
title: currentPage.title,
path: path ?? '',
href: isDeleted
? joinPathWithBaseURL(input.editorBaseURL, path ?? '')
: input.linker.toPathForPage({
pages: input.pages,
page: currentPage,
}),
status: pageChange.status,
action: isDeleted ? 'editor' : 'preview',
});
}
return Array.from(changedPages.values());
}
function getChangedPageChange(
change: RevisionSemanticChange
): { page: ChangedRevisionPage; status: MinimalChangedPage['status'] } | null {
switch (change.type) {
case 'page_created':
return { page: change.page, status: 'created' };
case 'page_edited':
return { page: change.page, status: 'edited' };
case 'page_moved':
return { page: change.page, status: 'moved' };
case 'page_deleted':
return { page: change.page, status: 'deleted' };
default:
return null;
}
}
function shouldKeepExistingPageChange(
existing: MinimalChangedPage['status'],
next: MinimalChangedPage['status']
) {
return getChangeStatusSummaryIndex(existing) >= getChangeStatusSummaryIndex(next);
}
function getChangeStatusSummaryIndex(status: MinimalChangedPage['status']) {
return CHANGE_STATUS_SUMMARY_ORDER.indexOf(status);
}
@@ -43,6 +43,20 @@ export type MinimalSite = {
};
};
export type MinimalChangedPage = {
id: string;
title: string;
path: string;
href: string;
status: 'created' | 'edited' | 'moved' | 'deleted';
action: 'preview' | 'editor';
};
export type MinimalChangedPages = {
pages: MinimalChangedPage[];
more: number;
};
export type AdminToolbarContext = {
organizationId: string;
revisionId: string;
@@ -50,6 +64,7 @@ export type AdminToolbarContext = {
changeRequest: MinimalChangeRequest | null;
revision: MinimalRevision;
site: MinimalSite;
changedPages: MinimalChangedPages | null;
};
export interface AdminToolbarClientProps {
@@ -51,6 +51,7 @@ export function getOpenAPIContext(args: {
copy: <Icon icon="copy" />,
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
mcp: <Icon icon="mcp" />,
},
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
@@ -38,11 +38,29 @@
@apply pt-0;
}
/* In headless mode the tags row sits directly above the path; match the operation-level rhythm. */
.openapi-summary-tags + .openapi-path {
@apply mt-3;
}
.openapi-deprecated,
.openapi-stability {
.openapi-stability,
.openapi-mcp {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-mcp {
@apply inline-flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/6 ring-emerald-500/5;
}
button.openapi-mcp {
@apply cursor-pointer transition-colors hover:bg-emerald-100 dark:hover:bg-emerald-900/12;
}
.openapi-mcp .gb-icon {
@apply size-3;
}
.openapi-stability-alpha {
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
}
@@ -1,5 +1,8 @@
import type { DocumentBlockParagraph } from '@gitbook/api';
import { CustomizationAIMode } from '@gitbook/api';
import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton';
import { getNodeText } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
@@ -8,14 +11,35 @@ import { getTextAlignment } from './utils';
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
const { context } = contextProps;
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
const inlineButtonStyle =
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
return (
const paragraph = (
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
</p>
);
// Offer to ask the assistant about any paragraph, in Assistant mode, on screen.
const contentContext = context.contentContext;
const aiAssistantEnabled =
context.mode !== 'print' &&
contentContext != null &&
'customization' in contentContext &&
contentContext.customization.ai.mode === CustomizationAIMode.Assistant;
const text = aiAssistantEnabled ? getNodeText(block) : '';
if (aiAssistantEnabled && text.trim()) {
return (
<div className={tcls('group/ask-ai relative', style)}>
{paragraph}
<AskAIParagraphButton content={text} />
</div>
);
}
return paragraph;
}
@@ -53,11 +53,7 @@ function getOpenInAIProviders(props: BlockProps<DocumentBlockPrompt>): boolean {
function isExternalAIPageActionEnabled(
pageActions: SiteCustomizationSettings['pageActions']
): boolean {
// Use the configured `items` list when the API provides it, and fall back to the deprecated
// `externalAI` flag otherwise (legacy mode).
return pageActions.items
? pageActions.items.includes(CustomizationPageActionType.ExternalAi)
: pageActions.externalAI;
return pageActions.items.includes(CustomizationPageActionType.ExternalAi);
}
function getPromptText(block: DocumentBlockPrompt): string {
@@ -16,14 +16,10 @@ import {
getTableRecordSearchData,
getTableSelectColumns,
} from './search';
import { shouldShowTableSearch } from './shouldShowSearch';
export type { TableRecordKV };
/**
* Only show the table search once there are enough records that searching is useful.
*/
const MIN_RECORDS_FOR_SEARCH = 7;
export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
view: View;
records: TableRecordKV[];
@@ -38,10 +34,14 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
a[1].orderIndex.localeCompare(b[1].orderIndex)
);
const showSearch =
context.mode !== 'print' &&
records.length >= MIN_RECORDS_FOR_SEARCH &&
block.data.view.type === 'grid'; // Tables only for now
// Authors can override the smart default per block with `search: true | false`; `undefined`
// keeps the default (search on grid tables with enough rows, off on cards). See the helper.
const showSearch = shouldShowTableSearch({
recordCount: records.length,
viewType: block.data.view.type,
searchOverride: block.data.search,
isPrint: context.mode === 'print',
});
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
@@ -18,11 +18,24 @@ export interface TableSelectColumn {
}
/**
* List the visible "select" columns of a table along with their options.
* All column ids of a table: visible columns first (in view order), then any hidden ones (defined
* but not shown). The filter input offers every column, so hidden select/checkbox fields are
* filterable too even though they aren't displayed.
*/
function getTableColumnIds(block: DocumentBlockTable): string[] {
const visible = block.data.view.columns;
return [
...visible,
...Object.keys(block.data.definition).filter((id) => !visible.includes(id)),
];
}
/**
* List the "select" columns of a table along with their options, including hidden fields.
* Used to render the per-column filter dropdowns next to the search input.
*/
export function getTableSelectColumns(block: DocumentBlockTable): TableSelectColumn[] {
return block.data.view.columns.flatMap((column) => {
return getTableColumnIds(block).flatMap((column) => {
const definition = block.data.definition[column];
if (definition?.type !== 'select') {
return [];
@@ -40,11 +53,11 @@ export interface TableCheckboxColumn {
}
/**
* List the visible "checkbox" columns of a table.
* List the "checkbox" columns of a table, including hidden fields.
* Used to render a filter checkbox per column next to the search input.
*/
export function getTableCheckboxColumns(block: DocumentBlockTable): TableCheckboxColumn[] {
return block.data.view.columns.flatMap((column) => {
return getTableColumnIds(block).flatMap((column) => {
const definition = block.data.definition[column];
if (definition?.type !== 'checkbox') {
return [];
@@ -63,10 +76,15 @@ export function getTableRecordSearchData(block: DocumentBlockTable, record: Docu
const selectValues: Record<string, string[]> = {};
const checkboxValues: Record<string, boolean> = {};
for (const column of block.data.view.columns) {
const text = getTableCellSearchText(block, record, column);
if (text) {
searchText.push(text);
// Free-text search only covers visible columns; select/checkbox filters also cover hidden
// columns, since those are offered in the filter input.
const visibleColumns = new Set(block.data.view.columns);
for (const column of getTableColumnIds(block)) {
if (visibleColumns.has(column)) {
const text = getTableCellSearchText(block, record, column);
if (text) {
searchText.push(text);
}
}
const value = record.values[column];
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'bun:test';
import { MIN_RECORDS_FOR_SEARCH, shouldShowTableSearch } from './shouldShowSearch';
describe('shouldShowTableSearch', () => {
describe('default (no override)', () => {
it('shows search on a grid table once it reaches the row threshold', () => {
expect(
shouldShowTableSearch({
recordCount: MIN_RECORDS_FOR_SEARCH,
viewType: 'grid',
searchOverride: undefined,
isPrint: false,
})
).toBe(true);
});
it('hides search on a grid table below the row threshold', () => {
expect(
shouldShowTableSearch({
recordCount: MIN_RECORDS_FOR_SEARCH - 1,
viewType: 'grid',
searchOverride: undefined,
isPrint: false,
})
).toBe(false);
});
it('hides search on cards regardless of row count', () => {
expect(
shouldShowTableSearch({
recordCount: 100,
viewType: 'cards',
searchOverride: undefined,
isPrint: false,
})
).toBe(false);
});
});
describe('explicit override', () => {
it('forces search on for a small grid table', () => {
expect(
shouldShowTableSearch({
recordCount: 1,
viewType: 'grid',
searchOverride: true,
isPrint: false,
})
).toBe(true);
});
it('forces search on for cards', () => {
expect(
shouldShowTableSearch({
recordCount: 1,
viewType: 'cards',
searchOverride: true,
isPrint: false,
})
).toBe(true);
});
it('forces search off for a large grid table', () => {
expect(
shouldShowTableSearch({
recordCount: 1000,
viewType: 'grid',
searchOverride: false,
isPrint: false,
})
).toBe(false);
});
});
it('never shows search when printing, even when forced on', () => {
expect(
shouldShowTableSearch({
recordCount: 1000,
viewType: 'grid',
searchOverride: true,
isPrint: true,
})
).toBe(false);
});
});
@@ -0,0 +1,36 @@
import type { DocumentBlockTable } from '@gitbook/api';
/**
* Number of records a grid table needs before the search bar is shown by default.
*
* Duplicated in `@gitbook/doc-core` (`table/search.ts`) so the editor preview matches this default.
* There's no shared module for it GBO consumes the published `@gitbook/api`, not `doc-core` so
* the two copies must be kept in sync by hand when changing the default behaviour.
*/
export const MIN_RECORDS_FOR_SEARCH = 7;
/**
* Decide whether to render the search bar for a table block.
*
* Authors can force search on or off per block via `block.data.search`. When that override is left
* unset, we fall back to a smart default: search appears on grid tables once they have enough rows,
* and stays off on cards. Search is never shown when printing, regardless of the override.
*/
export function shouldShowTableSearch(args: {
/** Number of records in the table. */
recordCount: number;
/** The table view type (`grid` or `cards`). */
viewType: DocumentBlockTable['data']['view']['type'];
/** Explicit per-block override, from `block.data.search`; `undefined` defers to the default. */
searchOverride: boolean | undefined;
/** Whether the document is being rendered for print/PDF, where search is never shown. */
isPrint: boolean;
}): boolean {
if (args.isPrint) {
return false;
}
const showByDefault = args.recordCount >= MIN_RECORDS_FOR_SEARCH && args.viewType === 'grid';
return args.searchOverride ?? showByDefault;
}
@@ -45,7 +45,12 @@ export function ActionOpenAssistant(props: {
icon={assistant.icon}
label={assistant.label}
shortLabel={tString(language, 'ask')}
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
description={tString(
language,
'ai_chat_ask_about',
assistant.label,
tString(language, 'this_page')
)}
disabled={chat.responding}
onClick={() => {
// Stage a reference to the current page so the assistant is informed about
@@ -213,7 +218,12 @@ export function ActionOpenInLLM(props: {
icon={provider}
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
description={tString(
language,
'ai_chat_ask_about',
providerLabel,
tString(language, 'this_page')
)}
href={getURLForLLM(provider, prompt)}
/>
);
@@ -30,27 +30,6 @@ import {
*/
type PageActionType = `${CustomizationPageActionType}`;
/**
* Order used to derive the list of actions from the deprecated boolean flags when the API does not
* provide `items` yet. It matches the order the page actions menu used before the `items` model, so
* existing sites keep the same dropdown ordering until they are migrated.
*/
const LEGACY_PAGE_ACTION_ORDER: PageActionType[] = [
'assistant',
'markdown',
'external-ai',
'mcp',
'git',
'pdf',
];
/**
* Default-button priority used in legacy mode (no `items`). It reproduces the previous behavior,
* which only ever surfaced the assistant, the Git edit link or the markdown copy as the default
* action never ChatGPT, MCP or PDF.
*/
const LEGACY_DEFAULT_ACTION_PRIORITY: PageActionType[] = ['assistant', 'git', 'markdown'];
export type PageActionsDropdownURLs = {
html: string;
markdown: string;
@@ -88,11 +67,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
// `items` is the source of truth when the API provides it. Until then (legacy mode), we derive
// the list from the deprecated boolean flags using the previous ordering.
const configuredItems = getConfiguredPageActionItems(props.actions);
const isLegacy = configuredItems === null;
const items = configuredItems ?? deriveLegacyPageActionItems(props.actions);
const items = props.actions.items;
let defaultAction: ReactNode = null;
let markdownIsDefault = false;
@@ -102,12 +77,8 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
// present, as a contextual override of the configured list.
defaultAction = <ActionViewAsRSS url={urls.rss} type="button" />;
} else {
// The default button is the first available action. With `items`, that is simply the first
// entry of the configured list. In legacy mode we keep the previous default-action priority
// (assistant → Git → markdown), so existing sites don't suddenly surface ChatGPT/MCP/PDF.
const defaultPriority = isLegacy ? LEGACY_DEFAULT_ACTION_PRIORITY : items;
const defaultActionType = defaultPriority.find(
(type) => items.includes(type) && isActionTypeAvailable(type, urls, assistants)
const defaultActionType = items.find(
(type) => items?.includes(type) && isActionTypeAvailable(type, urls, assistants)
);
if (defaultActionType) {
defaultAction = renderDefaultActionForType(defaultActionType, {
@@ -123,17 +94,19 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
// Build the dropdown menu items, grouped by action type. RSS is appended as its own group
// since it is not part of the configurable `items` list.
const groups: { key: string; items: ReactNode[] }[] = items
.map((type) => ({
key: type,
items: renderDropdownActionsForType(type, {
siteTitle,
urls,
markdownIsDefault,
assistants,
page: props.page,
}),
}))
.filter((group) => group.items.length > 0);
? items
.map((type) => ({
key: type,
items: renderDropdownActionsForType(type, {
siteTitle,
urls,
markdownIsDefault,
assistants,
page: props.page,
}),
}))
.filter((group) => group.items.length > 0)
: [];
if (urls.rss) {
groups.push({
@@ -178,43 +151,6 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
) : null;
}
/**
* Return the configured ordered list of enabled page actions, or `null` when the API does not
* provide it yet (legacy mode).
*/
function getConfiguredPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] | null {
return actions.items ?? null;
}
/**
* Derive the ordered list of enabled page actions from the deprecated boolean flags, following the
* ordering used before the `items` model. Used only when the API does not provide `items`.
*/
function deriveLegacyPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] {
return LEGACY_PAGE_ACTION_ORDER.filter((type) => {
switch (type) {
case 'external-ai':
return actions.externalAI;
case 'markdown':
return actions.markdown;
case 'mcp':
return actions.mcp;
// `assistant` is governed by the AI mode setting, and `git`/`pdf` are not represented
// by the legacy `pageActions` flags; all three are gated by availability at render time.
case 'assistant':
case 'git':
case 'pdf':
return true;
default:
return false;
}
});
}
/**
* Whether an action type can be rendered given the available URLs and assistants.
*/
@@ -3,7 +3,6 @@ import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import {
CustomizationAIMode,
CustomizationPageActionType,
type RevisionPageDocument,
SiteVisibility,
@@ -191,9 +190,7 @@ function getPageActionsURLs({
}
/**
* Whether a given built-in page action is enabled. Uses the configured `items` list when the API
* provides it, and falls back to the deprecated boolean flags otherwise (legacy mode), matching the
* fallback used by the page actions dropdown.
* Whether a given built-in page action is enabled.
*/
function isPageActionEnabled(
customization: GitBookSiteContext['customization'],
@@ -203,22 +200,7 @@ function isPageActionEnabled(
if (pageActions.items) {
return pageActions.items.includes(type);
}
switch (type) {
case CustomizationPageActionType.ExternalAi:
return pageActions.externalAI;
case CustomizationPageActionType.Markdown:
return pageActions.markdown;
case CustomizationPageActionType.Mcp:
return pageActions.mcp;
case CustomizationPageActionType.Pdf:
return customization.pdf.enabled;
case CustomizationPageActionType.Git:
return customization.git.showEditLink;
case CustomizationPageActionType.Assistant:
return customization.ai.mode === CustomizationAIMode.Assistant;
default:
return false;
}
return false;
}
/**
@@ -114,7 +114,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error) {
if (error && results.length === 0) {
return (
<output
className={tcls(
@@ -157,6 +157,26 @@ export const SearchResults = React.forwardRef(function SearchResults(
</output>
);
const partialError = error ? (
<div
className={tcls(
'mt-3',
'rounded-corners:rounded-md',
'circular-corners:rounded-2xl',
'bg-tint-subtle',
'px-3',
'py-2',
'text-center',
'text-sm',
'text-tint-subtle',
'animate-blur-in-slow'
)}
role="status"
>
{t(language, 'search_partial_error')}
</div>
) : null;
return (
<output className="flex grow flex-col" aria-busy={fetching}>
{children}
@@ -318,6 +338,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{partialError}
{fetching ? (
<div
className={tcls(
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'bun:test';
import { type MergedPageResult, getResultKey, reciprocalRankFusion } from './reciprocalRankFusion';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
function localPage(id: string, title = id): LocalPageResult {
return {
type: 'local-page',
id,
title,
pathname: `/${id}`,
description: `Local description for ${title}`,
breadcrumbs: [{ label: 'Local', icon: 'book-open' }],
};
}
function remotePage(id: string, title = id): OrderedComputedResult {
return {
type: 'page',
id: `remote-${id}`,
pageId: id,
spaceId: 'space',
title,
href: `/${id}`,
score: 0,
breadcrumbs: [{ label: 'Remote' }],
};
}
function remoteRecord(id: string, title = id): OrderedComputedResult {
return {
type: 'record',
id,
title,
href: `/records/${id}`,
score: 0,
description: undefined,
};
}
describe('reciprocalRankFusion', () => {
it('keeps the first 3 remote results first and in remote order', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remoteRecord('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'record:remote-2',
'page:remote-3',
]);
});
it('applies fusion only after the pinned remote results', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'page:remote-2',
'page:remote-3',
]);
const firstFusedResult = results[3];
if (!firstFusedResult) {
throw new Error('Expected a fused result after the pinned remote results');
}
expect(getResultKey(firstFusedResult)).toBe('page:local-match');
});
it('pins all remote results when fewer than 3 are present', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[remoteRecord('remote-1'), remotePage('remote-2')],
'alpha'
);
expect(results.map(getResultKey)).toEqual([
'record:remote-1',
'page:remote-2',
'page:local-match',
]);
});
it('merges a pinned remote page with the matching local page without duplicating it', () => {
const results = reciprocalRankFusion(
[localPage('remote-1', 'Local title')],
[
remotePage('remote-1', 'Remote title'),
remotePage('remote-2'),
remotePage('remote-3'),
],
'remote'
);
const pinnedResult = results[0] as MergedPageResult;
expect(pinnedResult.type).toBe('page');
expect(pinnedResult.title).toBe('Remote title');
expect(pinnedResult.pathname).toBe('/remote-1');
expect(pinnedResult.description).toBe('Local description for Local title');
expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]);
expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1);
});
it('does not duplicate pinned records in the fused tail', () => {
const results = reciprocalRankFusion(
[],
[
remoteRecord('record-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remoteRecord('record-1'),
remotePage('remote-4'),
],
'remote'
);
expect(results.map(getResultKey).filter((key) => key === 'record:record-1')).toHaveLength(
1
);
});
});
@@ -66,6 +66,8 @@ function jaroWinkler(s1: string, s2: string, p = 0.1): number {
const RRF_K = 60;
/** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */
const RRF_K_LOCAL = 50;
/** Number of remote results that should keep their API order before fusion. */
const PINNED_REMOTE_RESULTS_COUNT = 3;
/**
* A page result that was present in both local and remote lists.
@@ -73,11 +75,12 @@ const RRF_K_LOCAL = 50;
* and remote fields (href, pageId, spaceId, title) override them.
* Breadcrumbs prefer local (has icon + emoji) and fall back to remote.
*/
export type MergedPageResult = ComputedPageResult & {
export type MergedPageResult = Omit<ComputedPageResult, 'breadcrumbs'> & {
pathname?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs'];
};
/**
@@ -100,16 +103,47 @@ export function getResultKey(
type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult;
function mergeLocalPageWithRemotePage(
localResult: LocalPageResult,
remoteResult: ComputedPageResult
): MergedPageResult {
return {
...localResult,
...remoteResult,
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
breadcrumbs: localResult.breadcrumbs ?? remoteResult.breadcrumbs,
};
}
function mergePinnedRemoteResult(
result: OrderedComputedResult,
localResultsByKey: Map<string, LocalPageResult>
): RRFResult {
if (result.type !== 'page') {
return result;
}
const localResult = localResultsByKey.get(getResultKey(result));
if (!localResult) {
return result;
}
return mergeLocalPageWithRemotePage(localResult, result);
}
/**
* Merge local (FlexSearch) and remote (API) search results using
* Reciprocal Rank Fusion (RRF).
* Reciprocal Rank Fusion (RRF), while preserving the API order for the first
* three remote results.
*
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
*
* Pages present in both lists are deep-merged: local fields act as the base
* The pinned remote results are excluded from fusion and returned first. Pages
* present in both lists are deep-merged: local fields act as the base
* (preserving description, icon, emoji, pathname) and remote fields override
* (providing href, pageId, spaceId, title). Breadcrumbs prefer local (has icon
* + emoji) and fall back to remote. Their rank contributions from both lists are summed.
* + emoji) and fall back to remote. In the fused tail, their rank contributions
* from both lists are summed.
*
* Sections and records have no local equivalent and only accumulate their own
* rank contribution.
@@ -123,12 +157,22 @@ export function reciprocalRankFusion(
remoteResults: OrderedComputedResult[],
query: string
): Array<RRFResult> {
const localResultsByKey = new Map(localResults.map((result) => [getResultKey(result), result]));
const pinnedRemoteResults = remoteResults.slice(0, PINNED_REMOTE_RESULTS_COUNT);
const pinnedResultKeys = new Set(pinnedRemoteResults.map((result) => getResultKey(result)));
const remainingLocalResults = localResults.filter(
(result) => !pinnedResultKeys.has(getResultKey(result))
);
const remainingRemoteResults = remoteResults
.slice(PINNED_REMOTE_RESULTS_COUNT)
.filter((result) => !pinnedResultKeys.has(getResultKey(result)));
// Map from dedup key → { result, score }
const scoreMap = new Map<string, { result: RRFResult; score: number }>();
// Process local results first (1-indexed rank)
// Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones.
localResults.forEach((result, index) => {
remainingLocalResults.forEach((result, index) => {
const rank = index + 1;
const key = getResultKey(result);
const contribution = 1 / (RRF_K_LOCAL + rank);
@@ -142,7 +186,7 @@ export function reciprocalRankFusion(
});
// Process remote results, deduplicating against local pages
remoteResults.forEach((result, index) => {
remainingRemoteResults.forEach((result, index) => {
const rank = index + 1;
const contribution = 1 / (RRF_K + rank);
@@ -155,12 +199,7 @@ export function reciprocalRankFusion(
// (href, pageId, spaceId, breadcrumbs, title).
existing.score += contribution;
if (existing.result.type === 'local-page' && result.type === 'page') {
existing.result = {
...existing.result,
...result,
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
breadcrumbs: existing.result.breadcrumbs ?? result.breadcrumbs,
} as MergedPageResult;
existing.result = mergeLocalPageWithRemotePage(existing.result, result);
} else {
existing.result = result;
}
@@ -197,7 +236,12 @@ export function reciprocalRankFusion(
}
// Sort descending by RRF score
return Array.from(scoreMap.values())
const fusedResults = Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
.map(({ result }) => result);
return [
...pinnedRemoteResults.map((result) => mergePinnedRemoteResult(result, localResultsByKey)),
...fusedResults,
];
}
@@ -11,6 +11,7 @@ import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
import { assert } from 'ts-essentials';
import { Link, type LinkInsightsProps } from '.';
import { ToggleChevron } from './ToggleChevron';
import { Tooltip } from './Tooltip';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
@@ -36,6 +37,8 @@ const DROPDOWN_CONTENT_INNER_CLASS =
export function DropdownMenu(props: {
/** Content of the button */
button: React.ReactNode;
/** Tooltip label for the button */
buttonTooltip?: React.ReactNode;
/** Content of the dropdown */
children: React.ReactNode;
/** Custom styles */
@@ -52,32 +55,53 @@ export function DropdownMenu(props: {
* @default "start"
*/
align?: RadixDropdownMenu.DropdownMenuContentProps['align'];
/**
* Distance between the trigger and the dropdown.
* @default 0
*/
sideOffset?: RadixDropdownMenu.DropdownMenuContentProps['sideOffset'];
}) {
const {
button,
buttonTooltip,
children,
className,
openOnHover = false,
side = 'bottom',
align = 'start',
sideOffset = 0,
} = props;
const [hovered, setHovered] = useState(false);
const [open, setOpen] = useState(false);
const isOpen = openOnHover ? open || hovered : open;
const trigger = (
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => (openOnHover ? setOpen(!open) : null)}
className="group/dropdown"
>
{button}
</RadixDropdownMenu.Trigger>
);
return (
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => (openOnHover ? setOpen(!open) : null)}
className="group/dropdown"
>
{button}
</RadixDropdownMenu.Trigger>
{buttonTooltip ? (
<Tooltip
label={buttonTooltip}
pinOnClick={false}
rootProps={{ disableHoverableContent: true }}
>
{trigger}
</Tooltip>
) : (
trigger
)}
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
@@ -88,6 +112,7 @@ export function DropdownMenu(props: {
onMouseLeave={() => setHovered(false)}
align={align}
side={side}
sideOffset={sideOffset}
className={DROPDOWN_CONTENT_OUTER_CLASS}
>
<div className={tcls(DROPDOWN_CONTENT_INNER_CLASS, className)}>
@@ -20,6 +20,7 @@ export function Tooltip(props: {
arrowProps?: RadixTooltip.TooltipArrowProps;
arrow?: boolean;
className?: string;
pinOnClick?: boolean;
}) {
const {
children,
@@ -31,6 +32,7 @@ export function Tooltip(props: {
arrowProps,
arrow = false,
className,
pinOnClick = true,
} = props;
const [open, setOpen] = useState(false);
@@ -56,7 +58,11 @@ export function Tooltip(props: {
return (
<RadixTooltip.Root open={open || clicked} onOpenChange={setOpen} {...rootProps}>
<RadixTooltip.Trigger asChild onClick={() => setClicked(true)} {...triggerProps}>
<RadixTooltip.Trigger
asChild
onClick={pinOnClick ? () => setClicked(true) : undefined}
{...triggerProps}
>
{children}
</RadixTooltip.Trigger>
<RadixTooltip.Portal {...portalProps}>
+5 -1
View File
@@ -40,6 +40,7 @@ export const ar: TranslationLanguage = {
search_ask_no_answer:
'تعذر العثور على إجابة لسؤالك. يمكنك محاولة إعادة صياغته أو جعله أكثر تحديدا.',
search_ask_error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقا.',
search_partial_error: 'تعذر تحميل بعض النتائج. يتم عرض النتائج المتاحة.',
on_this_page: 'في هذه الصفحة',
next_page: 'التالي',
previous_page: 'السابق',
@@ -144,7 +145,10 @@ export const ar: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'تعذّر فتح الصفحة',
ai_chat_tools_mcp_tool: 'تم استدعاء ${1}',
ai_chat_ask: 'اسأل ${1}',
ai_chat_ask_about_page: 'اسأل ${1} عن هذه الصفحة',
ai_chat_ask_about: 'اسأل ${1} عن ${2}',
ai_chat_ask_about_this: 'اسأل ${1} عن هذا',
this_page: 'هذه الصفحة',
ai_chat_paragraph_draft: 'أخبرني المزيد عن هذا',
ai_chat_ask_query: 'اسأل ${1} "${2}"',
copy_for_llms: 'نسخ للنماذج اللغوية الكبيرة',
copy_page_markdown: 'نسخ الصفحة بصيغة Markdown للنماذج اللغوية الكبيرة',
+6 -1
View File
@@ -40,6 +40,8 @@ export const bg: TranslationLanguage = {
search_ask_no_answer:
'Не беше намерен отговор на въпроса ви. Можете да опитате да го преформулирате или да бъдете по-конкретни.',
search_ask_error: 'Нещо се обърка. Моля, опитайте отново по-късно.',
search_partial_error:
'Някои резултати не можаха да се заредят. Показват се наличните резултати.',
on_this_page: 'На тази страница',
next_page: 'Следваща',
previous_page: 'Предишна',
@@ -147,7 +149,10 @@ export const bg: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Страницата не може да бъде отворена',
ai_chat_tools_mcp_tool: 'Извика ${1}',
ai_chat_ask: 'Попитайте ${1}',
ai_chat_ask_about_page: 'Попитайте ${1} за тази страница',
ai_chat_ask_about: 'Попитайте ${1} за ${2}',
ai_chat_ask_about_this: 'Попитайте ${1} за това',
this_page: 'тази страница',
ai_chat_paragraph_draft: 'Кажи ми повече за това',
ai_chat_ask_query: 'Попитайте ${1} "${2}"',
copy_for_llms: 'Копиране за LLM',
copy_page_markdown: 'Копиране на страницата като Markdown за LLM',
+5 -1
View File
@@ -40,6 +40,7 @@ export const cs: TranslationLanguage = {
search_ask_no_answer:
'Na vaši otázku se nepodařilo najít odpověď. Zkuste ji přeformulovat nebo být konkrétnější.',
search_ask_error: 'Něco se pokazilo. Zkuste to prosím znovu později.',
search_partial_error: 'Některé výsledky se nepodařilo načíst. Zobrazují se dostupné výsledky.',
on_this_page: 'Na této stránce',
next_page: 'Další',
previous_page: 'Předchozí',
@@ -146,7 +147,10 @@ export const cs: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Stránku se nepodařilo otevřít',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Zeptat se ${1}',
ai_chat_ask_about_page: 'Zeptat se ${1} na tuto stránku',
ai_chat_ask_about: 'Zeptat se ${1} na ${2}',
ai_chat_ask_about_this: 'Zeptat se ${1} na to',
this_page: 'tuto stránku',
ai_chat_paragraph_draft: 'Řekni mi o tom více',
ai_chat_ask_query: 'Zeptat se ${1} "${2}"',
copy_for_llms: 'Kopírovat pro LLM',
copy_page_markdown: 'Kopírovat stránku jako Markdown pro LLM',
+5 -1
View File
@@ -40,6 +40,7 @@ export const da: TranslationLanguage = {
search_ask_no_answer:
'Der kunne ikke findes et svar på dit spørgsmål. Prøv at omformulere det eller være mere specifik.',
search_ask_error: 'Noget gik galt. Prøv igen senere.',
search_partial_error: 'Nogle resultater kunne ikke indlæses. Viser tilgængelige resultater.',
on_this_page: 'På denne side',
next_page: 'Næste',
previous_page: 'Forrige',
@@ -145,7 +146,10 @@ export const da: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Kunne ikke åbne siden',
ai_chat_tools_mcp_tool: 'Kaldte ${1}',
ai_chat_ask: 'Spørg ${1}',
ai_chat_ask_about_page: 'Spørg ${1} om denne side',
ai_chat_ask_about: 'Spørg ${1} om ${2}',
ai_chat_ask_about_this: 'Spørg ${1} om dette',
this_page: 'denne side',
ai_chat_paragraph_draft: 'Fortæl mig mere om dette',
ai_chat_ask_query: 'Spørg ${1} "${2}"',
copy_for_llms: 'Kopiér til LLMer',
copy_page_markdown: 'Kopiér siden som Markdown til LLMer',
+9 -2
View File
@@ -1,4 +1,6 @@
export const de = {
import type { TranslationLanguage } from './types';
export const de: TranslationLanguage = {
locale: 'de',
language: 'Deutsch',
flag: '🇩🇪',
@@ -39,6 +41,8 @@ export const de = {
search_ask_no_answer:
'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.',
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
search_partial_error:
'Einige Ergebnisse konnten nicht geladen werden. Verfügbare Ergebnisse werden angezeigt.',
on_this_page: 'Auf dieser Seite',
next_page: 'Nächste',
previous_page: 'Vorherige',
@@ -149,7 +153,10 @@ export const de = {
ai_chat_tools_navigate_failed: 'Seite konnte nicht geöffnet werden',
ai_chat_tools_mcp_tool: '${1} aufgerufen',
ai_chat_ask: '${1} fragen',
ai_chat_ask_about_page: '${1} zu dieser Seite befragen',
ai_chat_ask_about: '${1} zu ${2} befragen',
ai_chat_ask_about_this: '${1} dazu befragen',
this_page: 'dieser Seite',
ai_chat_paragraph_draft: 'Erzähl mir mehr darüber',
copy_for_llms: 'Für LLMs kopieren',
copy_page_markdown: 'Seite als Markdown für LLMs kopieren',
copy_page: 'Seite kopieren',
+6 -1
View File
@@ -40,6 +40,8 @@ export const el: TranslationLanguage = {
search_ask_no_answer:
'Δεν ήταν δυνατή η εύρεση απάντησης στην ερώτησή σας. Δοκιμάστε να τη διατυπώσετε διαφορετικά ή πιο συγκεκριμένα.',
search_ask_error: 'Κάτι πήγε στραβά. Δοκιμάστε ξανά αργότερα.',
search_partial_error:
'Δεν ήταν δυνατή η φόρτωση ορισμένων αποτελεσμάτων. Εμφανίζονται τα διαθέσιμα αποτελέσματα.',
on_this_page: 'Σε αυτήν τη σελίδα',
next_page: 'Επόμενη',
previous_page: 'Προηγούμενη',
@@ -149,7 +151,10 @@ export const el: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Αποτυχία ανοίγματος της σελίδας',
ai_chat_tools_mcp_tool: 'Κλήθηκε ${1}',
ai_chat_ask: 'Ρωτήστε ${1}',
ai_chat_ask_about_page: 'Ρωτήστε ${1} για αυτήν τη σελίδα',
ai_chat_ask_about: 'Ρωτήστε ${1} για ${2}',
ai_chat_ask_about_this: 'Ρωτήστε ${1} για αυτό',
this_page: 'αυτή τη σελίδα',
ai_chat_paragraph_draft: 'Πες μου περισσότερα για αυτό',
ai_chat_ask_query: 'Ρωτήστε ${1} "${2}"',
copy_for_llms: 'Αντιγραφή για LLM',
copy_page_markdown: 'Αντιγραφή σελίδας ως Markdown για LLM',
+5 -1
View File
@@ -38,6 +38,7 @@ export const en = {
search_ask_no_answer:
'An answer could not be found for your question. You could try rephrasing it, or be more specific.',
search_ask_error: 'Something went wrong. Please try again later.',
search_partial_error: "Some results couldn't load. Showing available results.",
on_this_page: 'On this page',
next_page: 'Next',
previous_page: 'Previous',
@@ -143,8 +144,11 @@ export const en = {
ai_chat_tools_navigate_failed: 'Failed to open the page',
ai_chat_tools_mcp_tool: 'Called ${1}',
ai_chat_ask: 'Ask ${1}',
ai_chat_ask_about_page: 'Ask ${1} about this page',
ai_chat_ask_about: 'Ask ${1} about ${2}',
ai_chat_ask_about_this: 'Ask ${1} about this',
ai_chat_ask_query: 'Ask ${1} "${2}"',
this_page: 'this page',
ai_chat_paragraph_draft: 'Tell me more about this',
copy_for_llms: 'Copy for LLMs',
copy_page_markdown: 'Copy page as Markdown for LLMs',
copy_page: 'Copy page',
+6 -1
View File
@@ -41,6 +41,8 @@ export const es: TranslationLanguage = {
search_ask_no_answer:
'No se pudo encontrar una respuesta para su pregunta. Puede intentar reformularla o ser más específico.',
search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.',
search_partial_error:
'No se pudieron cargar algunos resultados. Se muestran los resultados disponibles.',
on_this_page: 'En esta página',
next_page: 'Siguiente',
previous_page: 'Anterior',
@@ -149,7 +151,10 @@ export const es: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'No se pudo abrir la página',
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
ai_chat_ask: 'Preguntar a ${1}',
ai_chat_ask_about_page: 'Preguntar a ${1} sobre esta página',
ai_chat_ask_about: 'Preguntar a ${1} sobre ${2}',
ai_chat_ask_about_this: 'Preguntar a ${1} sobre esto',
this_page: 'esta página',
ai_chat_paragraph_draft: 'Cuéntame más sobre esto',
copy_for_llms: 'Copiar para LLMs',
copy_page_markdown: 'Copiar página como Markdown para LLMs',
copy_page: 'Copiar página',
+5 -1
View File
@@ -40,6 +40,7 @@ export const et: TranslationLanguage = {
search_ask_no_answer:
'Teie küsimusele ei leitud vastust. Proovige seda ümber sõnastada või olla täpsem.',
search_ask_error: 'Midagi läks valesti. Palun proovige hiljem uuesti.',
search_partial_error: 'Mõnda tulemust ei saanud laadida. Kuvatakse saadaolevad tulemused.',
on_this_page: 'Sellel lehel',
next_page: 'Järgmine',
previous_page: 'Eelmine',
@@ -145,7 +146,10 @@ export const et: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Lehe avamine ebaõnnestus',
ai_chat_tools_mcp_tool: 'Kutsus ${1}',
ai_chat_ask: 'Küsi ${1}',
ai_chat_ask_about_page: 'Küsi ${1} selle lehe kohta',
ai_chat_ask_about: 'Küsi ${1}: ${2}',
ai_chat_ask_about_this: 'Küsi ${1} käest selle kohta',
this_page: 'seda lehte',
ai_chat_paragraph_draft: 'Räägi mulle sellest lähemalt',
ai_chat_ask_query: 'Küsi ${1} "${2}"',
copy_for_llms: 'Kopeeri LLM-idele',
copy_page_markdown: 'Kopeeri leht Markdownina LLM-idele',
+6 -1
View File
@@ -40,6 +40,8 @@ export const fi: TranslationLanguage = {
search_ask_no_answer:
'Kysymykseesi ei löytynyt vastausta. Voit yrittää muotoilla sen uudelleen tai olla tarkempi.',
search_ask_error: 'Jokin meni pieleen. Yritä myöhemmin uudelleen.',
search_partial_error:
'Joitakin tuloksia ei voitu ladata. Näytetään saatavilla olevat tulokset.',
on_this_page: 'Tällä sivulla',
next_page: 'Seuraava',
previous_page: 'Edellinen',
@@ -146,7 +148,10 @@ export const fi: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Sivun avaaminen epäonnistui',
ai_chat_tools_mcp_tool: 'Kutsuttiin ${1}',
ai_chat_ask: 'Kysy ${1}',
ai_chat_ask_about_page: 'Kysy ${1} tältä sivulta',
ai_chat_ask_about: 'Kysy ${1}: ${2}',
ai_chat_ask_about_this: 'Kysy ${1}:lta tästä',
this_page: 'tätä sivua',
ai_chat_paragraph_draft: 'Kerro minulle tästä lisää',
ai_chat_ask_query: 'Kysy ${1}: "${2}"',
copy_for_llms: 'Kopioi LLM:ille',
copy_page_markdown: 'Kopioi sivu Markdownina LLM:ille',
+9 -2
View File
@@ -1,4 +1,6 @@
export const fr = {
import type { TranslationLanguage } from './types';
export const fr: TranslationLanguage = {
locale: 'fr',
language: 'Français',
flag: '🇫🇷',
@@ -38,6 +40,8 @@ export const fr = {
search_ask_sources_no_answer: 'Pages associées',
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
search_ask_error: 'Une erreur est survenue. Veuillez réessayer plus tard.',
search_partial_error:
'Certains résultats nont pas pu être chargés. Les résultats disponibles sont affichés.',
on_this_page: 'Sur cette page',
next_page: 'Suivant',
previous_page: 'Précédent',
@@ -143,7 +147,10 @@ export const fr = {
ai_chat_tools_navigate_failed: "Échec de l'ouverture de la page",
ai_chat_tools_mcp_tool: 'A appelé ${1}',
ai_chat_ask: 'Demander à ${1}',
ai_chat_ask_about_page: 'Demander à ${1} à propos de cette page',
ai_chat_ask_about: 'Demander à ${1} à propos de ${2}',
ai_chat_ask_about_this: 'Interroger ${1} à ce sujet',
this_page: 'cette page',
ai_chat_paragraph_draft: 'En savoir plus à ce sujet',
copy_for_llms: 'Copier pour un LLM',
copy_page_markdown: 'Copier la page en Markdown pour un LLM',
copy_page: 'Copier la page',
+5 -1
View File
@@ -40,6 +40,7 @@ export const he: TranslationLanguage = {
search_ask_no_answer:
'לא נמצאה תשובה לשאלה שלך. אפשר לנסות לנסח אותה מחדש או להיות מדויקים יותר.',
search_ask_error: 'משהו השתבש. נסה שוב מאוחר יותר.',
search_partial_error: 'לא ניתן היה לטעון חלק מהתוצאות. מוצגות התוצאות הזמינות.',
on_this_page: 'בדף הזה',
next_page: 'הבא',
previous_page: 'הקודם',
@@ -143,7 +144,10 @@ export const he: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'פתיחת הדף נכשלה',
ai_chat_tools_mcp_tool: 'קרא ל-${1}',
ai_chat_ask: 'שאל את ${1}',
ai_chat_ask_about_page: 'שאל את ${1} על הדף הזה',
ai_chat_ask_about: 'שאל את ${1} על ${2}',
ai_chat_ask_about_this: 'שאל את ${1} על זה',
this_page: 'הדף הזה',
ai_chat_paragraph_draft: 'ספר לי עוד על זה',
ai_chat_ask_query: 'שאל את ${1} "${2}"',
copy_for_llms: 'העתקה עבור LLMs',
copy_page_markdown: 'העתקת הדף כ-Markdown עבור LLMs',
+5 -1
View File
@@ -40,6 +40,7 @@ export const hi: TranslationLanguage = {
search_ask_no_answer:
'आपके प्रश्न का उत्तर नहीं मिल सका। आप इसे फिर से लिखने या अधिक विशिष्ट बनाने की कोशिश कर सकते हैं।',
search_ask_error: 'कुछ गलत हो गया। कृपया बाद में फिर कोशिश करें।',
search_partial_error: 'कुछ परिणाम लोड नहीं हो सके। उपलब्ध परिणाम दिखाए जा रहे हैं।',
on_this_page: 'इस पृष्ठ पर',
next_page: 'अगला',
previous_page: 'पिछला',
@@ -144,7 +145,10 @@ export const hi: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'पेज खोलने में विफल',
ai_chat_tools_mcp_tool: '${1} को कॉल किया',
ai_chat_ask: '${1} से पूछें',
ai_chat_ask_about_page: 'इस पृष्ठ के बारे में ${1} से पूछें',
ai_chat_ask_about: '${1} से ${2} के बारे में पूछें',
ai_chat_ask_about_this: '${1} से इसके बारे में पूछें',
this_page: 'इस पृष्ठ',
ai_chat_paragraph_draft: 'इसके बारे में और बताएं',
ai_chat_ask_query: '${1} से "${2}" पूछें',
copy_for_llms: 'LLM के लिए कॉपी करें',
copy_page_markdown: 'पृष्ठ को LLM के लिए Markdown के रूप में कॉपी करें',
+5 -1
View File
@@ -40,6 +40,7 @@ export const hr: TranslationLanguage = {
search_ask_no_answer:
'Nije bilo moguće pronaći odgovor na vaše pitanje. Pokušajte ga preformulirati ili biti konkretniji.',
search_ask_error: 'Nešto je pošlo po zlu. Pokušajte ponovno kasnije.',
search_partial_error: 'Neki se rezultati nisu mogli učitati. Prikazuju se dostupni rezultati.',
on_this_page: 'Na ovoj stranici',
next_page: 'Sljedeća',
previous_page: 'Prethodna',
@@ -146,7 +147,10 @@ export const hr: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Stranicu nije moguće otvoriti',
ai_chat_tools_mcp_tool: 'Pozvao ${1}',
ai_chat_ask: 'Pitaj ${1}',
ai_chat_ask_about_page: 'Pitaj ${1} o ovoj stranici',
ai_chat_ask_about: 'Pitaj ${1} o ${2}',
ai_chat_ask_about_this: 'Pitaj ${1} o ovome',
this_page: 'ova stranica',
ai_chat_paragraph_draft: 'Reci mi više o ovome',
ai_chat_ask_query: 'Pitaj ${1} "${2}"',
copy_for_llms: 'Kopiraj za LLM-ove',
copy_page_markdown: 'Kopiraj stranicu kao Markdown za LLM-ove',
+6 -1
View File
@@ -40,6 +40,8 @@ export const hu: TranslationLanguage = {
search_ask_no_answer:
'Nem található válasz a kérdésére. Próbálja meg átfogalmazni, vagy legyen pontosabb.',
search_ask_error: 'Valami hiba történt. Kérjük, próbálja újra később.',
search_partial_error:
'Néhány találatot nem sikerült betölteni. Az elérhető találatok jelennek meg.',
on_this_page: 'Ezen az oldalon',
next_page: 'Következő',
previous_page: 'Előző',
@@ -146,7 +148,10 @@ export const hu: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Az oldal megnyitása sikertelen',
ai_chat_tools_mcp_tool: '${1} meghívva',
ai_chat_ask: '${1} kérdezése',
ai_chat_ask_about_page: '${1} kérdezése erről az oldalról',
ai_chat_ask_about: '${1} megkérdezése ${2} témában',
ai_chat_ask_about_this: '${1} megkérdezése erről',
this_page: 'ez az oldal',
ai_chat_paragraph_draft: 'Mesélj erről többet',
ai_chat_ask_query: '${1} kérdezése: "${2}"',
copy_for_llms: 'Másolás LLM-ekhez',
copy_page_markdown: 'Oldal másolása Markdownként LLM-ekhez',
+5 -1
View File
@@ -40,6 +40,7 @@ export const id: TranslationLanguage = {
search_ask_no_answer:
'Jawaban untuk pertanyaan Anda tidak dapat ditemukan. Anda dapat mencoba menyusun ulang atau membuatnya lebih spesifik.',
search_ask_error: 'Terjadi kesalahan. Silakan coba lagi nanti.',
search_partial_error: 'Beberapa hasil tidak dapat dimuat. Menampilkan hasil yang tersedia.',
on_this_page: 'Di halaman ini',
next_page: 'Berikutnya',
previous_page: 'Sebelumnya',
@@ -145,7 +146,10 @@ export const id: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Gagal membuka halaman',
ai_chat_tools_mcp_tool: 'Memanggil ${1}',
ai_chat_ask: 'Tanya ${1}',
ai_chat_ask_about_page: 'Tanya ${1} tentang halaman ini',
ai_chat_ask_about: 'Tanya ${1} tentang ${2}',
ai_chat_ask_about_this: 'Tanya ${1} tentang ini',
this_page: 'halaman ini',
ai_chat_paragraph_draft: 'Ceritakan lebih banyak tentang ini',
ai_chat_ask_query: 'Tanya ${1} "${2}"',
copy_for_llms: 'Salin untuk LLM',
copy_page_markdown: 'Salin halaman sebagai Markdown untuk LLM',
+6 -1
View File
@@ -41,6 +41,8 @@ export const it: TranslationLanguage = {
search_ask_no_answer:
'Non è stato possibile trovare una risposta alla tua domanda. Prova a riformularla o a essere più specifico.',
search_ask_error: 'Qualcosa è andato storto. Riprova più tardi.',
search_partial_error:
'Non è stato possibile caricare alcuni risultati. Visualizziamo i risultati disponibili.',
on_this_page: 'In questa pagina',
next_page: 'Successivo',
previous_page: 'Precedente',
@@ -148,7 +150,10 @@ export const it: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Impossibile aprire la pagina',
ai_chat_tools_mcp_tool: 'Ha chiamato ${1}',
ai_chat_ask: 'Chiedi a ${1}',
ai_chat_ask_about_page: 'Chiedi a ${1} riguardo a questa pagina',
ai_chat_ask_about: 'Chiedi a ${1} riguardo a ${2}',
ai_chat_ask_about_this: 'Chiedi a ${1} riguardo a questo',
this_page: 'questa pagina',
ai_chat_paragraph_draft: 'Dimmi di più a riguardo',
copy_for_llms: 'Copia per LLM',
copy_page_markdown: 'Copia la pagina come Markdown per LLM',
copy_page: 'Copia pagina',
+5 -1
View File
@@ -41,6 +41,7 @@ export const ja: TranslationLanguage = {
search_ask_no_answer:
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
search_partial_error: '一部の結果を読み込めませんでした。利用可能な結果を表示しています。',
on_this_page: 'このページ内',
next_page: '次へ',
previous_page: '前へ',
@@ -146,7 +147,10 @@ export const ja: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'ページを開けませんでした',
ai_chat_tools_mcp_tool: '${1} を呼び出しました',
ai_chat_ask: '${1} に質問する',
ai_chat_ask_about_page: 'このページについて ${1} に質問する',
ai_chat_ask_about: '${1}に${2}について質問する',
ai_chat_ask_about_this: '${1}にこれについて質問する',
this_page: 'このページ',
ai_chat_paragraph_draft: 'これについてもっと教えて',
copy_for_llms: 'LLM 用にコピー',
copy_page_markdown: 'ページを Markdown として LLM 用にコピー',
copy_page: 'ページをコピー',
+5 -1
View File
@@ -41,6 +41,7 @@ export const ko: TranslationLanguage = {
search_ask_no_answer:
'질문에 대한 답변을 찾을 수 없습니다. 질문을 다시 표현하거나 더 구체적으로 작성해 보세요.',
search_ask_error: '문제가 발생했습니다. 나중에 다시 시도해 주세요.',
search_partial_error: '일부 결과를 불러오지 못했습니다. 사용 가능한 결과를 표시합니다.',
on_this_page: '이 페이지에서',
next_page: '다음',
previous_page: '이전',
@@ -145,7 +146,10 @@ export const ko: TranslationLanguage = {
ai_chat_tools_navigate_failed: '페이지를 열지 못했습니다',
ai_chat_tools_mcp_tool: '${1} 호출함',
ai_chat_ask: '${1}에게 질문',
ai_chat_ask_about_page: '이 페이지에 대해 ${1}에 질문',
ai_chat_ask_about: '${1}에게 ${2}에 대해 질문',
ai_chat_ask_about_this: '${1}에게 이에 대해 질문',
this_page: '이 페이지',
ai_chat_paragraph_draft: '이것에 대해 더 알려줘',
copy_for_llms: 'LLM용 복사',
copy_page_markdown: 'LLM용으로 페이지를 Markdown으로 복사',
copy_page: '페이지 복사',
+5 -1
View File
@@ -40,6 +40,7 @@ export const lt: TranslationLanguage = {
search_ask_no_answer:
'Nepavyko rasti atsakymo į jūsų klausimą. Galite pabandyti jį performuluoti arba pateikti konkrečiau.',
search_ask_error: 'Kažkas nepavyko. Bandykite dar kartą vėliau.',
search_partial_error: 'Nepavyko įkelti kai kurių rezultatų. Rodomi pasiekiami rezultatai.',
on_this_page: 'Šiame puslapyje',
next_page: 'Kitas',
previous_page: 'Ankstesnis',
@@ -146,7 +147,10 @@ export const lt: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Nepavyko atidaryti puslapio',
ai_chat_tools_mcp_tool: 'Iškviesta ${1}',
ai_chat_ask: 'Klausti ${1}',
ai_chat_ask_about_page: 'Klausti ${1} apie šį puslapį',
ai_chat_ask_about: 'Klausti ${1} apie ${2}',
ai_chat_ask_about_this: 'Klausti ${1} apie tai',
this_page: 'šį puslapį',
ai_chat_paragraph_draft: 'Papasakok apie tai daugiau',
ai_chat_ask_query: 'Klausti ${1} "${2}"',
copy_for_llms: 'Kopijuoti LLM',
copy_page_markdown: 'Kopijuoti puslapį kaip Markdown LLM',
+5 -1
View File
@@ -40,6 +40,7 @@ export const lv: TranslationLanguage = {
search_ask_no_answer:
'Neizdevās atrast atbildi uz jūsu jautājumu. Varat mēģināt to pārfrāzēt vai būt konkrētāks.',
search_ask_error: 'Kaut kas nogāja greizi. Lūdzu, mēģiniet vēlāk vēlreiz.',
search_partial_error: 'Dažus rezultātus neizdevās ielādēt. Tiek rādīti pieejamie rezultāti.',
on_this_page: 'Šajā lapā',
next_page: 'Nākamā',
previous_page: 'Iepriekšējā',
@@ -144,7 +145,10 @@ export const lv: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Neizdevās atvērt lapu',
ai_chat_tools_mcp_tool: 'Izsauca ${1}',
ai_chat_ask: 'Jautāt ${1}',
ai_chat_ask_about_page: 'Jautāt ${1} par šo lapu',
ai_chat_ask_about: 'Jautāt ${1} par ${2}',
ai_chat_ask_about_this: 'Jautāt ${1} par to',
this_page: 'šo lapu',
ai_chat_paragraph_draft: 'Pastāsti man par to vairāk',
ai_chat_ask_query: 'Jautāt ${1} "${2}"',
copy_for_llms: 'Kopēt LLM vajadzībām',
copy_page_markdown: 'Kopēt lapu kā Markdown LLM vajadzībām',
+5 -1
View File
@@ -40,6 +40,7 @@ export const ms: TranslationLanguage = {
search_ask_no_answer:
'Jawapan untuk soalan anda tidak ditemui. Anda boleh cuba menyusunnya semula atau bertanya dengan lebih khusus.',
search_ask_error: 'Ada sesuatu yang tidak kena. Sila cuba lagi kemudian.',
search_partial_error: 'Sesetengah hasil tidak dapat dimuatkan. Memaparkan hasil yang tersedia.',
on_this_page: 'Pada halaman ini',
next_page: 'Seterusnya',
previous_page: 'Sebelumnya',
@@ -145,7 +146,10 @@ export const ms: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Gagal membuka halaman',
ai_chat_tools_mcp_tool: 'Memanggil ${1}',
ai_chat_ask: 'Tanya ${1}',
ai_chat_ask_about_page: 'Tanya ${1} tentang halaman ini',
ai_chat_ask_about: 'Tanya ${1} tentang ${2}',
ai_chat_ask_about_this: 'Tanya ${1} tentang ini',
this_page: 'halaman ini',
ai_chat_paragraph_draft: 'Beritahu saya lebih lanjut tentang ini',
ai_chat_ask_query: 'Tanya ${1} "${2}"',
copy_for_llms: 'Salin untuk LLM',
copy_page_markdown: 'Salin halaman sebagai Markdown untuk LLM',
+6 -1
View File
@@ -41,6 +41,8 @@ export const nl: TranslationLanguage = {
search_ask_no_answer:
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
search_partial_error:
'Sommige resultaten konden niet worden geladen. Beschikbare resultaten worden weergegeven.',
on_this_page: 'Op deze pagina',
next_page: 'Volgende',
previous_page: 'Vorige',
@@ -147,7 +149,10 @@ export const nl: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Kan de pagina niet openen',
ai_chat_tools_mcp_tool: '${1} aangeroepen',
ai_chat_ask: 'Vraag het aan ${1}',
ai_chat_ask_about_page: 'Vraag ${1} naar deze pagina',
ai_chat_ask_about: 'Stel ${1} een vraag over ${2}',
ai_chat_ask_about_this: 'Stel ${1} een vraag hierover',
this_page: 'deze pagina',
ai_chat_paragraph_draft: 'Vertel me hier meer over',
copy_for_llms: 'Kopiëren voor LLMs',
copy_page_markdown: 'Pagina kopiëren als Markdown voor LLMs',
copy_page: 'Pagina kopiëren',
+5 -1
View File
@@ -41,6 +41,7 @@ export const no: TranslationLanguage = {
search_ask_no_answer:
'Et svar på spørsmålet ditt kunne ikke finnes. Prøv å omformulere det eller være mer spesifikk.',
search_ask_error: 'Noe gikk galt. Vennligst prøv igjen senere.',
search_partial_error: 'Noen resultater kunne ikke lastes inn. Viser tilgjengelige resultater.',
on_this_page: 'På denne siden',
next_page: 'Neste',
previous_page: 'Forrige',
@@ -147,7 +148,10 @@ export const no: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Kunne ikke åpne siden',
ai_chat_tools_mcp_tool: 'Kalte ${1}',
ai_chat_ask: 'Spør ${1}',
ai_chat_ask_about_page: 'Spør ${1} om denne siden',
ai_chat_ask_about: 'Spør ${1} om ${2}',
ai_chat_ask_about_this: 'Spør ${1} om dette',
this_page: 'denne siden',
ai_chat_paragraph_draft: 'Fortell meg mer om dette',
copy_for_llms: 'Kopier for LLMs',
copy_page_markdown: 'Kopier siden som Markdown for LLMs',
copy_page: 'Kopier side',
+5 -1
View File
@@ -40,6 +40,7 @@ export const pl: TranslationLanguage = {
search_ask_no_answer:
'Nie udało się znaleźć odpowiedzi na Twoje pytanie. Spróbuj sformułować je inaczej lub bardziej szczegółowo.',
search_ask_error: 'Coś poszło nie tak. Spróbuj ponownie później.',
search_partial_error: 'Nie udało się wczytać niektórych wyników. Wyświetlamy dostępne wyniki.',
on_this_page: 'Na tej stronie',
next_page: 'Dalej',
previous_page: 'Wstecz',
@@ -146,7 +147,10 @@ export const pl: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Nie udało się otworzyć strony',
ai_chat_tools_mcp_tool: 'Wywołano ${1}',
ai_chat_ask: 'Zapytaj ${1}',
ai_chat_ask_about_page: 'Zapytaj ${1} o tę stronę',
ai_chat_ask_about: 'Zapytaj ${1} o ${2}',
ai_chat_ask_about_this: 'Zapytaj ${1} o to',
this_page: 'tę stronę',
ai_chat_paragraph_draft: 'Powiedz mi o tym więcej',
ai_chat_ask_query: 'Zapytaj ${1} "${2}"',
copy_for_llms: 'Kopiuj dla LLM',
copy_page_markdown: 'Kopiuj stronę jako Markdown dla LLM',
@@ -1,4 +1,6 @@
export const pt_br = {
import type { TranslationLanguage } from './types';
export const pt_br: TranslationLanguage = {
locale: 'pt-br',
language: 'Português (Brasil)',
flag: '🇧🇷',
@@ -39,6 +41,8 @@ export const pt_br = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo deu errado. Por favor, tente novamente mais tarde.',
search_partial_error:
'Alguns resultados não puderam ser carregados. Mostrando os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Próximo',
previous_page: 'Anterior',
@@ -147,7 +151,10 @@ export const pt_br = {
ai_chat_tools_navigate_failed: 'Falha ao abrir a página',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página',
ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}',
ai_chat_ask_about_this: 'Perguntar a ${1} sobre isso',
this_page: 'esta página',
ai_chat_paragraph_draft: 'Conte-me mais sobre isso',
copy_for_llms: 'Copiar para LLMs',
copy_page_markdown: 'Copiar página como Markdown para LLMs',
copy_page: 'Copiar página',
+6 -1
View File
@@ -40,6 +40,8 @@ export const pt: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para a sua pergunta. Pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo correu mal. Tente novamente mais tarde.',
search_partial_error:
'Não foi possível carregar alguns resultados. A mostrar os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Seguinte',
previous_page: 'Anterior',
@@ -146,7 +148,10 @@ export const pt: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Falha ao abrir a página',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página',
ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}',
ai_chat_ask_about_this: 'Perguntar a ${1} sobre isto',
this_page: 'esta página',
ai_chat_paragraph_draft: 'Conta-me mais sobre isto',
ai_chat_ask_query: 'Perguntar a ${1} "${2}"',
copy_for_llms: 'Copiar para LLMs',
copy_page_markdown: 'Copiar página como Markdown para LLMs',
+6 -1
View File
@@ -40,6 +40,8 @@ export const ro: TranslationLanguage = {
search_ask_no_answer:
'Nu s-a putut găsi un răspuns la întrebarea ta. Poți încerca să o reformulezi sau să fii mai specific.',
search_ask_error: 'Ceva nu a mers bine. Te rugăm să încerci din nou mai târziu.',
search_partial_error:
'Unele rezultate nu au putut fi încărcate. Se afișează rezultatele disponibile.',
on_this_page: 'Pe această pagină',
next_page: 'Următoarea',
previous_page: 'Anterioara',
@@ -148,7 +150,10 @@ export const ro: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Deschiderea paginii a eșuat',
ai_chat_tools_mcp_tool: 'A apelat ${1}',
ai_chat_ask: 'Întreabă ${1}',
ai_chat_ask_about_page: 'Întreabă ${1} despre această pagină',
ai_chat_ask_about: 'Întreabă ${1} despre ${2}',
ai_chat_ask_about_this: 'Întreabă ${1} despre asta',
this_page: 'această pagină',
ai_chat_paragraph_draft: 'Spune-mi mai multe despre asta',
ai_chat_ask_query: 'Întreabă ${1} "${2}"',
copy_for_llms: 'Copiază pentru LLM-uri',
copy_page_markdown: 'Copiază pagina ca Markdown pentru LLM-uri',
+9 -2
View File
@@ -1,4 +1,6 @@
export const ru = {
import type { TranslationLanguage } from './types';
export const ru: TranslationLanguage = {
locale: 'ru',
language: 'Русский',
flag: '🇷🇺',
@@ -39,6 +41,8 @@ export const ru = {
search_ask_no_answer:
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
search_partial_error:
'Не удалось загрузить некоторые результаты. Показаны доступные результаты.',
on_this_page: 'На этой странице',
next_page: 'Следующая',
previous_page: 'Предыдущая',
@@ -146,7 +150,10 @@ export const ru = {
ai_chat_tools_navigate_failed: 'Не удалось открыть страницу',
ai_chat_tools_mcp_tool: 'Вызван ${1}',
ai_chat_ask: 'Спросить у ${1}',
ai_chat_ask_about_page: 'Спросить у ${1} об этой странице',
ai_chat_ask_about: 'Спросить у ${1} о ${2}',
ai_chat_ask_about_this: 'Спросить у ${1} об этом',
this_page: 'этой странице',
ai_chat_paragraph_draft: 'Расскажи мне об этом подробнее',
copy_for_llms: 'Скопировать для LLM',
copy_page_markdown: 'Скопировать страницу как Markdown для LLM',
copy_page: 'Скопировать страницу',
+6 -1
View File
@@ -40,6 +40,8 @@ export const sk: TranslationLanguage = {
search_ask_no_answer:
'Na vašu otázku sa nepodarilo nájsť odpoveď. Skúste ju preformulovať alebo byť konkrétnejší.',
search_ask_error: 'Niečo sa pokazilo. Skúste to znova neskôr.',
search_partial_error:
'Niektoré výsledky sa nepodarilo načítať. Zobrazujú sa dostupné výsledky.',
on_this_page: 'Na tejto stránke',
next_page: 'Ďalej',
previous_page: 'Predchádzajúca',
@@ -147,7 +149,10 @@ export const sk: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Stránku sa nepodarilo otvoriť',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Opýtať sa ${1}',
ai_chat_ask_about_page: 'Opýtať sa ${1} na túto stránku',
ai_chat_ask_about: 'Opýtať sa ${1} na ${2}',
ai_chat_ask_about_this: 'Opýtať sa ${1} na to',
this_page: 'túto stránku',
ai_chat_paragraph_draft: 'Povedz mi o tom viac',
ai_chat_ask_query: 'Opýtať sa ${1} "${2}"',
copy_for_llms: 'Kopírovať pre LLM',
copy_page_markdown: 'Kopírovať stránku ako Markdown pre LLM',
+6 -1
View File
@@ -40,6 +40,8 @@ export const sl: TranslationLanguage = {
search_ask_no_answer:
'Odgovora na vaše vprašanje ni bilo mogoče najti. Poskusite ga preoblikovati ali biti bolj natančni.',
search_ask_error: 'Nekaj je šlo narobe. Poskusite znova pozneje.',
search_partial_error:
'Nekaterih rezultatov ni bilo mogoče naložiti. Prikazani so razpoložljivi rezultati.',
on_this_page: 'Na tej strani',
next_page: 'Naslednja',
previous_page: 'Prejšnja',
@@ -145,7 +147,10 @@ export const sl: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Strani ni bilo mogoče odpreti',
ai_chat_tools_mcp_tool: 'Poklicano ${1}',
ai_chat_ask: 'Vprašaj ${1}',
ai_chat_ask_about_page: 'Vprašaj ${1} o tej strani',
ai_chat_ask_about: 'Vprašaj ${1} o ${2}',
ai_chat_ask_about_this: 'Vprašaj ${1} o tem',
this_page: 'to stran',
ai_chat_paragraph_draft: 'Povej mi več o tem',
ai_chat_ask_query: 'Vprašaj ${1} "${2}"',
copy_for_llms: 'Kopiraj za LLM',
copy_page_markdown: 'Kopiraj stran kot Markdown za LLM',
+5 -1
View File
@@ -40,6 +40,7 @@ export const sv: TranslationLanguage = {
search_ask_no_answer:
'Det gick inte att hitta ett svar på din fråga. Du kan försöka formulera om den eller vara mer specifik.',
search_ask_error: 'Något gick fel. Försök igen senare.',
search_partial_error: 'Vissa resultat kunde inte läsas in. Visar tillgängliga resultat.',
on_this_page: 'På den här sidan',
next_page: 'Nästa',
previous_page: 'Föregående',
@@ -146,7 +147,10 @@ export const sv: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Det gick inte att öppna sidan',
ai_chat_tools_mcp_tool: 'Anropade ${1}',
ai_chat_ask: 'Fråga ${1}',
ai_chat_ask_about_page: 'Fråga ${1} om den här sidan',
ai_chat_ask_about: 'Fråga ${1} om ${2}',
ai_chat_ask_about_this: 'Fråga ${1} om det här',
this_page: 'den här sidan',
ai_chat_paragraph_draft: 'Berätta mer om det här',
ai_chat_ask_query: 'Fråga ${1} "${2}"',
copy_for_llms: 'Kopiera för LLM:er',
copy_page_markdown: 'Kopiera sidan som Markdown för LLM:er',
+5 -1
View File
@@ -39,6 +39,7 @@ export const th: TranslationLanguage = {
search_ask_sources_no_answer: 'หน้าที่เกี่ยวข้อง',
search_ask_no_answer: 'ไม่พบคำตอบสำหรับคำถามของคุณ ลองเรียบเรียงใหม่หรือถามให้เฉพาะเจาะจงขึ้น',
search_ask_error: 'มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง',
search_partial_error: 'โหลดผลลัพธ์บางรายการไม่ได้ กำลังแสดงผลลัพธ์ที่พร้อมใช้งาน',
on_this_page: 'ในหน้านี้',
next_page: 'ถัดไป',
previous_page: 'ก่อนหน้า',
@@ -142,7 +143,10 @@ export const th: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'ไม่สามารถเปิดหน้าได้',
ai_chat_tools_mcp_tool: 'เรียกใช้ ${1}',
ai_chat_ask: 'ถาม ${1}',
ai_chat_ask_about_page: 'ถาม ${1} เกี่ยวกับหน้านี้',
ai_chat_ask_about: 'ถาม ${1} เกี่ยวกับ ${2}',
ai_chat_ask_about_this: 'ถาม ${1} เกี่ยวกับเรื่องนี้',
this_page: 'หน้านี้',
ai_chat_paragraph_draft: 'บอกฉันเพิ่มเติมเกี่ยวกับเรื่องนี้',
ai_chat_ask_query: 'ถาม ${1} "${2}"',
copy_for_llms: 'คัดลอกสำหรับ LLM',
copy_page_markdown: 'คัดลอกหน้าเป็น Markdown สำหรับ LLM',
+5 -1
View File
@@ -40,6 +40,7 @@ export const tr: TranslationLanguage = {
search_ask_no_answer:
'Sorunuz için bir cevap bulunamadı. Soruyu yeniden ifade etmeyi veya daha spesifik olmayı deneyebilirsiniz.',
search_ask_error: 'Bir şeyler ters gitti. Lütfen daha sonra tekrar deneyin.',
search_partial_error: 'Bazı sonuçlar yüklenemedi. Kullanılabilir sonuçlar gösteriliyor.',
on_this_page: 'Bu sayfada',
next_page: 'Sonraki',
previous_page: 'Önceki',
@@ -144,7 +145,10 @@ export const tr: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Sayfa açılamadı',
ai_chat_tools_mcp_tool: '${1} çağrıldı',
ai_chat_ask: '${1} sor',
ai_chat_ask_about_page: 'Bu sayfa hakkında ${1} sor',
ai_chat_ask_about: '${1} için ${2} hakkında sor',
ai_chat_ask_about_this: '${1} için bununla ilgili sor',
this_page: 'bu sayfa',
ai_chat_paragraph_draft: 'Bana bundan daha fazla bahset',
ai_chat_ask_query: '${1} için "${2}" sor',
copy_for_llms: 'LLM için kopyala',
copy_page_markdown: 'Sayfayı LLM için Markdown olarak kopyala',
+5 -1
View File
@@ -40,6 +40,7 @@ export const uk: TranslationLanguage = {
search_ask_no_answer:
'Не вдалося знайти відповідь на ваше запитання. Спробуйте переформулювати його або зробити конкретнішим.',
search_ask_error: 'Щось пішло не так. Спробуйте ще раз пізніше.',
search_partial_error: 'Не вдалося завантажити деякі результати. Показано доступні результати.',
on_this_page: 'На цій сторінці',
next_page: 'Наступна',
previous_page: 'Попередня',
@@ -144,7 +145,10 @@ export const uk: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Не вдалося відкрити сторінку',
ai_chat_tools_mcp_tool: 'Викликано ${1}',
ai_chat_ask: 'Запитати ${1}',
ai_chat_ask_about_page: 'Запитати ${1} про цю сторінку',
ai_chat_ask_about: 'Запитати ${1} про ${2}',
ai_chat_ask_about_this: 'Запитати ${1} про це',
this_page: 'цю сторінку',
ai_chat_paragraph_draft: 'Розкажи мені про це більше',
ai_chat_ask_query: 'Запитати ${1} "${2}"',
copy_for_llms: 'Копіювати для LLM',
copy_page_markdown: 'Копіювати сторінку як Markdown для LLM',
+5 -1
View File
@@ -40,6 +40,7 @@ export const vi: TranslationLanguage = {
search_ask_no_answer:
'Không tìm thấy câu trả lời cho câu hỏi của bạn. Bạn có thể thử diễn đạt lại hoặc hỏi cụ thể hơn.',
search_ask_error: 'Đã xảy ra lỗi. Vui lòng thử lại sau.',
search_partial_error: 'Không thể tải một số kết quả. Đang hiển thị các kết quả có sẵn.',
on_this_page: 'Trên trang này',
next_page: 'Tiếp theo',
previous_page: 'Trước',
@@ -144,7 +145,10 @@ export const vi: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Không thể mở trang',
ai_chat_tools_mcp_tool: 'Đã gọi ${1}',
ai_chat_ask: 'Hỏi ${1}',
ai_chat_ask_about_page: 'Hỏi ${1} về trang này',
ai_chat_ask_about: 'Hỏi ${1} về ${2}',
ai_chat_ask_about_this: 'Hỏi ${1} về điều này',
this_page: 'trang này',
ai_chat_paragraph_draft: 'Cho tôi biết thêm về điều này',
ai_chat_ask_query: 'Hỏi ${1} "${2}"',
copy_for_llms: 'Sao chép cho LLM',
copy_page_markdown: 'Sao chép trang dưới dạng Markdown cho LLM',
@@ -39,6 +39,7 @@ export const yue: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '搵唔到你問題嘅答案。你可以試下改寫,或者問得更具體。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果載入唔到。顯示緊可用嘅結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -141,7 +142,10 @@ export const yue: TranslationLanguage = {
ai_chat_tools_navigate_failed: '無法開啟頁面',
ai_chat_tools_mcp_tool: '已呼叫 ${1}',
ai_chat_ask: '問 ${1}',
ai_chat_ask_about_page: '問 ${1} 關於此頁面',
ai_chat_ask_about: '問 ${1} 關於${2}',
ai_chat_ask_about_this: '問 ${1} 關於呢樣嘢',
this_page: '此頁面',
ai_chat_paragraph_draft: '同我講多啲呢方面嘅嘢',
ai_chat_ask_query: '問 ${1}「${2}」',
copy_for_llms: '複製俾 LLM 用',
copy_page_markdown: '以 Markdown 複製頁面俾 LLM 用',
@@ -39,6 +39,7 @@ export const zh_tw: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '找不到您問題的答案。您可以嘗試改寫問題,或提出更具體的內容。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果無法載入。正在顯示可用結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -141,7 +142,10 @@ export const zh_tw: TranslationLanguage = {
ai_chat_tools_navigate_failed: '無法開啟頁面',
ai_chat_tools_mcp_tool: '已呼叫 ${1}',
ai_chat_ask: '詢問 ${1}',
ai_chat_ask_about_page: '詢問 ${1} 關於此頁面',
ai_chat_ask_about: '詢問 ${1} 關於${2}',
ai_chat_ask_about_this: '詢問 ${1} 關於這個',
this_page: '此頁面',
ai_chat_paragraph_draft: '告訴我更多相關資訊',
ai_chat_ask_query: '詢問 ${1}「${2}」',
copy_for_llms: '複製給 LLM 使用',
copy_page_markdown: '將頁面以 Markdown 複製給 LLM 使用',
+5 -1
View File
@@ -40,6 +40,7 @@ export const zh: TranslationLanguage = {
search_ask_sources_no_answer: '相关页面',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。',
search_partial_error: '部分结果无法加载。正在显示可用结果。',
on_this_page: '在本页',
next_page: '下一页',
previous_page: '上一页',
@@ -142,7 +143,10 @@ export const zh: TranslationLanguage = {
ai_chat_tools_navigate_failed: '无法打开页面',
ai_chat_tools_mcp_tool: '调用了 ${1}',
ai_chat_ask: '向 ${1} 提问',
ai_chat_ask_about_page: '向 ${1} 提问有关此页面的问题',
ai_chat_ask_about: '向 ${1} 询问${2}',
ai_chat_ask_about_this: '向 ${1} 询问这个',
this_page: '此页面',
ai_chat_paragraph_draft: '告诉我更多相关信息',
copy_for_llms: '复制供 LLMs 使用',
copy_page_markdown: '将页面以 Markdown 格式复制供 LLMs 使用',
copy_page: '复制页面',
+102 -18
View File
@@ -10,7 +10,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { cacheLife, cacheTag } from 'next/cache';
import { cache } from '../cache';
import { DataFetcherError, wrapCacheDataFetcherError } from './errors';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
@@ -139,6 +139,20 @@ export function createDataFetcher(
changeRequestId: params.changeRequestId,
});
},
getChangeRequestChanges(params) {
return getChangeRequestChanges(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
limit: params.limit,
});
},
getRevisionSemanticChanges(params) {
return getRevisionSemanticChanges(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
limit: params.limit,
});
},
getDocument(params) {
return getDocument(input, {
spaceId: params.spaceId,
@@ -223,7 +237,7 @@ function cacheLifeFromResponse(
const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(`getUserById(${params.userId})`, async () => {
const api = apiClient(input);
const res = await api.users.getUserById(params.userId, {
@@ -246,7 +260,7 @@ const getSpace = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
const api = apiClient(input);
const res = await api.spaces.getSpaceById(
@@ -277,7 +291,7 @@ const getChangeRequest = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getChangeRequest(${params.spaceId}, ${params.changeRequestId})`,
async () => {
@@ -302,7 +316,7 @@ const getChangeRequest = cache(
const getRevision = cache(
async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
const api = apiClient(input);
const res = await api.spaces.getRevisionById(
@@ -323,6 +337,76 @@ const getRevision = cache(
}
);
const getChangeRequestChanges = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; changeRequestId: string; limit?: number }
) => {
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: params.changeRequestId,
})
);
return wrapDataFetcherError(async () => {
return trace(
`getChangeRequestChanges(${params.spaceId}, ${params.changeRequestId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.getChangeRequestChanges(
params.spaceId,
params.changeRequestId,
{
limit: params.limit,
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data;
}
);
});
}
);
const getRevisionSemanticChanges = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; limit?: number }
) => {
'use cache: remote';
return wrapDataFetcherError(async () => {
return trace(
`getRevisionSemanticChanges(${params.spaceId}, ${params.revisionId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.getRevisionSemanticChanges(
params.spaceId,
params.revisionId,
{
computed: false,
limit: params.limit,
metadata: false,
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
);
});
}
);
const getRevisionPageMarkdown = cache(
async (
input: DataFetcherInput,
@@ -331,7 +415,7 @@ const getRevisionPageMarkdown = cache(
_functionName: string
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
async () => {
@@ -373,7 +457,7 @@ const getRevisionPageDocument = cache(
_functionName: string
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
async () => {
@@ -407,7 +491,7 @@ const getRevisionReusableContentDocument = cache(
params: { spaceId: string; revisionId: string; reusableContentId: string }
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getRevisionReusableContentDocument(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`,
async () => {
@@ -440,7 +524,7 @@ const getRevisionPageByPath = cache(
params: { spaceId: string; revisionId: string; path: string }
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`,
async () => {
@@ -467,7 +551,7 @@ const getRevisionPageByPath = cache(
const getDocument = cache(
async (input: DataFetcherInput, params: { spaceId: string; documentId: string }) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => {
const api = apiClient(input);
const res = await api.spaces.getDocumentById(
@@ -507,7 +591,7 @@ const getComputedDocument = cache(
)
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`,
async () => {
@@ -544,7 +628,7 @@ const getLatestOpenAPISpecVersionContent = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`,
async () => {
@@ -579,7 +663,7 @@ const getPublishedContentSite = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`,
async () => {
@@ -621,7 +705,7 @@ const getSiteRedirectBySource = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`,
async () => {
@@ -656,7 +740,7 @@ const getEmbedByUrl = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => {
const api = apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(
@@ -690,7 +774,7 @@ const searchSiteContent = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`,
async () => {
@@ -730,7 +814,7 @@ const renderIntegrationUi = cache(
})
);
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(`renderIntegrationUi(${params.integrationName})`, async () => {
const api = apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost(
@@ -757,7 +841,7 @@ const listRevisionPageMetaLinks = cache(
params: { spaceId: string; revisionId: string; pageId: string }
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`listRevisionPageMetaLinks(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
async () => {
-29
View File
@@ -1,6 +1,5 @@
import { GitBookAPIError } from '@gitbook/api';
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { cacheLife } from 'next/cache';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
export class DataFetcherError extends Error {
@@ -92,34 +91,6 @@ export async function wrapDataFetcherError<T>(
}
}
/**
* Wrap an async execution to handle errors and return a DataFetcherResponse.
* This should be used inside 'use cache' functions.
*/
export async function wrapCacheDataFetcherError<T>(
fn: () => Promise<T>
): Promise<DataFetcherResponse<T>> {
const result = await wrapDataFetcherError(fn);
if (result.error) {
const cacheValue = result.error.cache;
// We only want to cache 404 errors for "long", because that's an "expected" error.
if (result.error.code === 404) {
cacheLife({
stale: 60,
revalidate: cacheValue?.maxAge ?? 60 * 60, // 1 hour
expire: cacheValue?.staleWhileRevalidate ?? 60 * 60 * 24, // 1 day
});
} else {
cacheLife({
stale: 60, // This one is only for the client
revalidate: cacheValue?.maxAge ?? 30, // we don't want to cache it for too long, but at least 30 seconds to avoid hammering the API
expire: cacheValue?.staleWhileRevalidate ?? 90, // we want to revalidate this error after 90 seconds for sure
});
}
}
return result;
}
/**
* Ignore some data fetcher errors.
*/
+18
View File
@@ -65,6 +65,15 @@ export interface GitBookDataFetcher {
changeRequestId: string;
}): Promise<DataFetcherResponse<api.ChangeRequest>>;
/**
* Get the semantic changes for a change request.
*/
getChangeRequestChanges(params: {
spaceId: string;
changeRequestId: string;
limit?: number;
}): Promise<DataFetcherResponse<api.RevisionSemanticChanges>>;
/**
* Get the revision by its space ID and revision ID.
*/
@@ -73,6 +82,15 @@ export interface GitBookDataFetcher {
revisionId: string;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
* Get the semantic changes for a revision.
*/
getRevisionSemanticChanges(params: {
spaceId: string;
revisionId: string;
limit?: number;
}): Promise<DataFetcherResponse<api.RevisionSemanticChanges>>;
/**
* Get a revision page by its path.
*/
+11
View File
@@ -0,0 +1,11 @@
/**
* Return the highest-scoring item in the list, or undefined when empty.
*/
export function getBestScoredResult<T extends { score: number }>(
items: readonly T[]
): T | undefined {
return items.reduce<T | undefined>(
(best, item) => (best === undefined || item.score > best.score ? item : best),
undefined
);
}
+2 -9
View File
@@ -54,9 +54,6 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
default: api.CustomizationDefaultThemeMode.Light,
toggeable: true,
},
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
@@ -69,20 +66,16 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
pageActions: {
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Assistant,
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
CustomizationPageActionType.Pdf,
],
},
trademark: {
+18
View File
@@ -37,6 +37,12 @@ export interface OpenAPICustomSpecProperties {
* Description in HTML format.
*/
'x-gitbook-description-html'?: string;
/**
* Default URL of the MCP server exposing the operations in this spec.
* Can be overridden at the path or operation level via the same extension.
*/
'x-gitbook-mcp-url'?: string;
}
/**
@@ -100,6 +106,18 @@ export interface OpenAPICustomOperationProperties {
* @enum 'experimental' | 'alpha' | 'beta'
*/
'x-stability'?: OpenAPIStability;
/**
* If `true`, indicates this operation is available as a tool in an MCP server.
*/
'x-gitbook-mcp'?: boolean;
/**
* URL of the MCP server exposing this operation. When set here, or inherited from
* the path or root level (operation > path > root) the "Available in MCP" badge
* becomes a button that copies this URL to the clipboard.
*/
'x-gitbook-mcp-url'?: string;
}
/**
@@ -0,0 +1,29 @@
import { OpenAPICopyButton } from '../OpenAPICopyButton';
import { type OpenAPIContext, getOpenAPIClientContext } from '../context';
import { t, tString } from '../translate';
export function OpenAPIMcpBadge(props: { url?: string; context: OpenAPIContext }) {
const { url, context } = props;
const content = (
<>
{context.icons.mcp}
{t(context.translation, 'available_in_mcp')}
</>
);
if (!url) {
return <div className="openapi-mcp">{content}</div>;
}
return (
<OpenAPICopyButton
value={url}
context={getOpenAPIClientContext(context)}
className="openapi-mcp"
label={tString(context.translation, 'copy_url')}
>
{content}
</OpenAPICopyButton>
);
}
@@ -1,6 +1,7 @@
import { OpenAPIPath } from '../OpenAPIPath';
import type { OpenAPIContext } from '../context';
import type { OpenAPIOperationData, OpenAPIWebhookData } from '../types';
import { OpenAPIMcpBadge } from './OpenAPIMcpBadge';
import { OpenAPIStability } from './OpenAPIStability';
export function OpenAPISummary(props: {
@@ -27,12 +28,15 @@ export function OpenAPISummary(props: {
className="openapi-summary"
id={!context.headless && operation.summary ? undefined : context.id}
>
{(operation.deprecated || operation['x-stability']) && (
{(operation.deprecated || operation['x-stability'] || operation['x-gitbook-mcp']) && (
<div className="openapi-summary-tags">
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
{operation['x-stability'] && (
<OpenAPIStability stability={operation['x-stability']} />
)}
{operation['x-gitbook-mcp'] && (
<OpenAPIMcpBadge url={operation['x-gitbook-mcp-url']} context={context} />
)}
</div>
)}
{!context.headless && title
+1
View File
@@ -16,6 +16,7 @@ export interface OpenAPIClientContext {
copy: React.ReactNode;
check: React.ReactNode;
lock: React.ReactNode;
mcp: React.ReactNode;
};
/**
@@ -6,7 +6,7 @@ import type {
} from '@gitbook/openapi-parser';
import { dereferenceFilesystem } from './dereference';
import type { OpenAPIOperationData, OpenAPISecurityScope } from './types';
import { checkIsReference } from './utils';
import { checkIsReference, readMcpUrl } from './utils';
export { fromJSON, toJSON } from 'flatted';
@@ -71,7 +71,11 @@ export async function resolveOpenAPIOperation(
return {
servers,
operation: { ...operation, security },
operation: {
...operation,
security,
'x-gitbook-mcp-url': getMcpUrl(schema, path, operation),
},
method,
path,
securities: Array.from(securitiesMap.entries()),
@@ -148,6 +152,18 @@ function getServers(
return 'servers' in schema ? (schema.servers ?? []) : [];
}
/**
* Resolve the MCP server URL for an operation, following the same precedence as servers:
* operation > path > root.
*/
function getMcpUrl(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
path: string,
operation: OpenAPIV3.OperationObject
): string | undefined {
return readMcpUrl(operation) ?? readMcpUrl(getPathObject(schema, path)) ?? readMcpUrl(schema);
}
/**
* Get an operation by its path and method.
*/
@@ -6,6 +6,7 @@ import type {
} from '@gitbook/openapi-parser';
import { dereferenceFilesystem } from './dereference';
import type { OpenAPIWebhookData } from './types';
import { readMcpUrl } from './utils';
export { fromJSON, toJSON } from 'flatted';
@@ -40,7 +41,13 @@ export async function resolveOpenAPIWebhook(
return {
servers,
operation,
operation: {
...operation,
'x-gitbook-mcp-url':
readMcpUrl(operation) ??
readMcpUrl(getPathObject(schema, name)) ??
readMcpUrl(schema),
},
method,
name,
'x-expandAllResponses':
@@ -5,6 +5,8 @@ export const de = {
stability_experimental: 'Experimentell',
stability_alpha: 'Alpha',
stability_beta: 'Beta',
available_in_mcp: 'Im MCP verfügbar',
copy_url: 'URL kopieren',
discriminator: 'Diskriminator',
copy_to_clipboard: 'In die Zwischenablage kopieren',
copied: 'Kopiert',
@@ -5,6 +5,8 @@ export const en = {
stability_experimental: 'Experimental',
stability_alpha: 'Alpha',
stability_beta: 'Beta',
available_in_mcp: 'Available in MCP',
copy_url: 'Copy URL',
discriminator: 'Discriminator',
copy_to_clipboard: 'Copy to clipboard',
copied: 'Copied',
@@ -5,6 +5,8 @@ export const es = {
stability_experimental: 'Experimental',
stability_alpha: 'Alfa',
stability_beta: 'Beta',
available_in_mcp: 'Disponible en el MCP',
copy_url: 'Copiar URL',
discriminator: 'Discriminador',
copy_to_clipboard: 'Copiar al portapapeles',
copied: 'Copiado',
@@ -5,6 +5,8 @@ export const fr = {
stability_experimental: 'Expérimental',
stability_alpha: 'Alpha',
stability_beta: 'Bêta',
available_in_mcp: 'Disponible dans le MCP',
copy_url: "Copier l'URL",
discriminator: 'Discriminateur',
copy_to_clipboard: 'Copier dans le presse-papiers',
copied: 'Copié',
@@ -5,6 +5,8 @@ export const ja = {
stability_experimental: '実験的',
stability_alpha: 'アルファ',
stability_beta: 'ベータ',
available_in_mcp: 'MCPで利用可能',
copy_url: 'URLをコピー',
discriminator: 'ディスクリミネーター',
copy_to_clipboard: 'クリップボードにコピー',
copied: 'コピー済み',
@@ -5,6 +5,8 @@ export const nl = {
stability_experimental: 'Experimenteel',
stability_alpha: 'Alfa',
stability_beta: 'Bèta',
available_in_mcp: 'Beschikbaar in MCP',
copy_url: 'URL kopiëren',
discriminator: 'Discriminator',
copy_to_clipboard: 'Kopiëren naar klembord',
copied: 'Gekopieerd',
@@ -5,6 +5,8 @@ export const no = {
stability_experimental: 'Eksperimentell',
stability_alpha: 'Alfa',
stability_beta: 'Beta',
available_in_mcp: 'Tilgjengelig i MCP',
copy_url: 'Kopier URL',
discriminator: 'Diskriminator',
copy_to_clipboard: 'Kopier til utklippstavle',
copied: 'Kopiert',
@@ -5,6 +5,8 @@ export const pt_br = {
stability_experimental: 'Experimental',
stability_alpha: 'Alfa',
stability_beta: 'Beta',
available_in_mcp: 'Disponível no MCP',
copy_url: 'Copiar URL',
discriminator: 'Discriminador',
copy_to_clipboard: 'Copiar para a área de transferência',
copied: 'Copiado',
@@ -5,6 +5,8 @@ export const zh = {
stability_experimental: '实验性',
stability_alpha: 'Alpha',
stability_beta: 'Beta',
available_in_mcp: '可在 MCP 中使用',
copy_url: '复制 URL',
discriminator: '判别器',
copy_to_clipboard: '复制到剪贴板',
copied: '已复制',
+12
View File
@@ -16,6 +16,18 @@ export function createStateKey(key: string, scope?: string) {
return scope ? `${scope}_${key}` : key;
}
/**
* Read the `x-gitbook-mcp-url` extension from any spec object, treating an empty string as unset
* so it falls through to the next level when resolving the operation > path > root cascade.
*/
export function readMcpUrl(obj: unknown): string | undefined {
if (obj && typeof obj === 'object' && 'x-gitbook-mcp-url' in obj) {
const value = obj['x-gitbook-mcp-url'];
return typeof value === 'string' && value ? value : undefined;
}
return undefined;
}
/**
* Check if an object has a description. Either at the root level or in items.
*/