mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 18:43:29 +00:00
Merge remote-tracking branch 'origin/main' into claude/exciting-brahmagupta-7cd6fa
# Conflicts: # packages/gitbook/src/components/AI/useAIChat.tsx
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+71
-55
@@ -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) {
|
||||
|
||||
+2
-4
@@ -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}>
|
||||
|
||||
@@ -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 للنماذج اللغوية الكبيرة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 n’ont 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 के रूप में कॉपी करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ページをコピー',
|
||||
|
||||
@@ -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: '페이지 복사',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Скопировать страницу',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 使用',
|
||||
|
||||
@@ -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: '复制页面',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '已复制',
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user