mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-29 08:37:05 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b2a1757a | |||
| 867cc4d6a9 | |||
| 87d61c6f29 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
|
||||
+7
-2
@@ -1,6 +1,10 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getSiteURLDataFromParams,
|
||||
getStaticSiteContext,
|
||||
} from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
@@ -18,8 +22,9 @@ export async function GET(
|
||||
) {
|
||||
const { question: encodedQuestion, goal: encodedGoal } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
const goal = decodeURIComponent(encodedGoal);
|
||||
|
||||
return serveAskMarkdown(context, question, { goal });
|
||||
return serveAskMarkdown(context, question, { goal, visitorAgent: askVisitorAgent });
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,6 +1,10 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getSiteURLDataFromParams,
|
||||
getStaticSiteContext,
|
||||
} from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
@@ -11,7 +15,8 @@ export async function GET(
|
||||
) {
|
||||
const { question: encodedQuestion } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
|
||||
return serveAskMarkdown(context, question);
|
||||
return serveAskMarkdown(context, question, { visitorAgent: askVisitorAgent });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
'use server';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import {
|
||||
type AIMessageContext,
|
||||
AIMessageRole,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -72,7 +75,8 @@ export async function* streamAIChatResponse({
|
||||
? SiteCoreChannelType.Embed
|
||||
: SiteCoreChannelType.Site,
|
||||
},
|
||||
}
|
||||
},
|
||||
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
|
||||
);
|
||||
|
||||
return await streamRenderAIMessage(context, rawStream, options);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { createStreamableValue } from 'ai/rsc';
|
||||
import { headers } from 'next/headers';
|
||||
import type * as React from 'react';
|
||||
|
||||
import type {
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { isAIEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
@@ -77,7 +79,8 @@ export async function streamAskQuestion({
|
||||
},
|
||||
session,
|
||||
},
|
||||
{ format: 'document' }
|
||||
{ format: 'document' },
|
||||
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
|
||||
);
|
||||
|
||||
const spacePromises = new Map<string, Promise<Revision>>();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getAskForwardedHeaders, getAskVisitorAgent } from './ask-visitor';
|
||||
|
||||
describe('getAskVisitorAgent', () => {
|
||||
it('should read the User-Agent and Signature-Agent headers', () => {
|
||||
expect(
|
||||
getAskVisitorAgent(
|
||||
new Headers({
|
||||
'user-agent': 'claude-code/2.0.0',
|
||||
'signature-agent': '"https://chatgpt.com"',
|
||||
})
|
||||
)
|
||||
).toEqual({ userAgent: 'claude-code/2.0.0', signatureAgent: '"https://chatgpt.com"' });
|
||||
});
|
||||
|
||||
it('should omit missing or empty headers', () => {
|
||||
const agent = getAskVisitorAgent(new Headers({ 'user-agent': '' }));
|
||||
expect(agent).toEqual({});
|
||||
expect('userAgent' in agent).toBe(false);
|
||||
});
|
||||
|
||||
it('should truncate overly long headers', () => {
|
||||
const agent = getAskVisitorAgent(new Headers({ 'user-agent': 'a'.repeat(2000) }));
|
||||
expect(agent.userAgent).toHaveLength(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAskForwardedHeaders', () => {
|
||||
it('should forward the visitor agent in X-Original headers', () => {
|
||||
expect(
|
||||
getAskForwardedHeaders({
|
||||
userAgent: 'claude-code/2.0.0',
|
||||
signatureAgent: '"https://chatgpt.com"',
|
||||
})
|
||||
).toEqual({
|
||||
'X-Original-User-Agent': 'claude-code/2.0.0',
|
||||
'X-Original-Signature-Agent': '"https://chatgpt.com"',
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit headers the visitor did not send', () => {
|
||||
expect(getAskForwardedHeaders({ userAgent: 'curl/8.7.1' })).toEqual({
|
||||
'X-Original-User-Agent': 'curl/8.7.1',
|
||||
});
|
||||
expect(getAskForwardedHeaders()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Headers identifying who asked a question, forwarded so the API can classify the asker
|
||||
* (human, agent, bot) the same way it classifies insights events.
|
||||
*/
|
||||
export interface AskVisitorAgent {
|
||||
userAgent?: string;
|
||||
signatureAgent?: string;
|
||||
}
|
||||
|
||||
// Bounds the header values, as they end up in the static route path of `?ask=`.
|
||||
const MAX_HEADER_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* Read the visitor's `User-Agent` and `Signature-Agent` from the request headers.
|
||||
* Missing headers are omitted rather than set to undefined, so the result can be rison-encoded.
|
||||
*/
|
||||
export function getAskVisitorAgent(headers: Pick<Headers, 'get'>): AskVisitorAgent {
|
||||
const userAgent = headers.get('user-agent')?.slice(0, MAX_HEADER_LENGTH);
|
||||
const signatureAgent = headers.get('signature-agent')?.slice(0, MAX_HEADER_LENGTH);
|
||||
|
||||
return {
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(signatureAgent ? { signatureAgent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request headers forwarding the visitor's agent on ask and Assistant API calls.
|
||||
* Our own `User-Agent` is left as is: the API relies on it to know the published site is calling.
|
||||
*/
|
||||
export function getAskForwardedHeaders(agent: AskVisitorAgent = {}): Record<string, string> {
|
||||
return {
|
||||
...(agent.userAgent ? { 'X-Original-User-Agent': agent.userAgent } : {}),
|
||||
...(agent.signatureAgent ? { 'X-Original-Signature-Agent': agent.signatureAgent } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api';
|
||||
|
||||
import { type AskVisitorAgent, getAskForwardedHeaders } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
@@ -15,6 +16,11 @@ export interface StreamSiteAskOptions {
|
||||
* Used by the backend to steer the answer.
|
||||
*/
|
||||
goal?: string;
|
||||
|
||||
/**
|
||||
* Headers of the visitor asking the question, used by the backend to classify the asker.
|
||||
*/
|
||||
visitorAgent?: AskVisitorAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +51,8 @@ export async function streamSiteAskAnswer(
|
||||
currentSiteSpace: context.siteSpace.id,
|
||||
},
|
||||
},
|
||||
{ format: 'markdown' }
|
||||
{ format: 'markdown' },
|
||||
{ headers: getAskForwardedHeaders(options.visitorAgent) }
|
||||
);
|
||||
|
||||
let latestAnswer: SearchAIAnswer | null = null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import { GITBOOK_URL } from './env';
|
||||
import { type ImageResizer, createImageResizer } from './images';
|
||||
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
|
||||
import type { AskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import {
|
||||
type GitBookDataFetcher,
|
||||
createDataFetcher,
|
||||
@@ -104,6 +105,12 @@ export type SiteURLData = Pick<
|
||||
|
||||
/** Whether the request comes from ChatGPT. Only set for Markdown and LLM routes. */
|
||||
isChatGPT?: boolean;
|
||||
|
||||
/**
|
||||
* Headers of the visitor asking through `?ask=`, forwarded to the API to classify the asker.
|
||||
* Only set for the ask route, which is static and can't read the request headers.
|
||||
*/
|
||||
askVisitorAgent?: AskVisitorAgent;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/agentFeedback';
|
||||
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import { getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
|
||||
@@ -279,8 +280,11 @@ export function createSiteMcpTools(
|
||||
|
||||
const trimmedGoal = goal?.trim() || undefined;
|
||||
|
||||
// The MCP server is stateless, so the `clientInfo` from `initialize` isn't
|
||||
// available here: rely on the HTTP user agent, as the `ask_question` event does.
|
||||
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
|
||||
goal: trimmedGoal,
|
||||
visitorAgent: getAskVisitorAgent(request.headers),
|
||||
});
|
||||
|
||||
trackMcpEvent({
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './lib/tracking';
|
||||
import { AI_CATALOG_PATH, AI_CATALOG_WELL_KNOWN_PATH } from '@/lib/aiCatalog/paths';
|
||||
import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie';
|
||||
import { type AskVisitorAgent, getAskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import { isChatGPTRequest } from '@/lib/chatgpt';
|
||||
import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies';
|
||||
import type { SiteURLData } from '@/lib/context';
|
||||
@@ -470,11 +471,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
events,
|
||||
isAiAgent,
|
||||
isChatGPT,
|
||||
askVisitorAgent,
|
||||
} = encodePathInSiteContent(siteURLData, request);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
// Only set for Markdown and LLM routes, so these request-specific variants are cached separately.
|
||||
stableSiteURLData.isAiAgent = isAiAgent;
|
||||
stableSiteURLData.isChatGPT = isChatGPT;
|
||||
// Splits the cache of `?ask=` answers per user agent, so each asker is classified correctly.
|
||||
stableSiteURLData.askVisitorAgent = askVisitorAgent;
|
||||
|
||||
// Apply a forced theme (`?theme=`/cookie). For the docs embed we thread it through the
|
||||
// route context (`embedTheme`) so those routes stay statically rendered — it becomes part
|
||||
@@ -800,6 +804,8 @@ function encodePathInSiteContent(
|
||||
isAiAgent?: boolean;
|
||||
/** Only set for Markdown and LLM routes, where the output content type depends on ChatGPT. */
|
||||
isChatGPT?: boolean;
|
||||
/** Only set for the ask route, where the API classifies the asker from these headers. */
|
||||
askVisitorAgent?: AskVisitorAgent;
|
||||
} {
|
||||
let pathname = removeLeadingSlash(removeTrailingSlash(siteURLData.pathname));
|
||||
|
||||
@@ -941,6 +947,8 @@ function encodePathInSiteContent(
|
||||
// Left undefined for non-agents to avoid splitting the static cache for them.
|
||||
isAiAgent: isAiAgent || undefined,
|
||||
isChatGPT: isChatGPTRequest(request) || undefined,
|
||||
askVisitorAgent:
|
||||
typeof ask === 'string' ? getAskVisitorAgent(request.headers) : undefined,
|
||||
// TODO: track pageId / spaceId when possible
|
||||
// We don't do it at the moment as we can't easily extract it from the URL.
|
||||
events: ask
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
|
||||
import type { AskVisitorAgent } from '@/lib/ask-visitor';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { linkerWithMarkdownPages } from '@/lib/links';
|
||||
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
|
||||
@@ -14,6 +15,11 @@ export interface ServeAskMarkdownOptions {
|
||||
* passed via the `?goal=` search parameter. Used by the backend to steer the answer.
|
||||
*/
|
||||
goal?: string;
|
||||
|
||||
/**
|
||||
* Headers of the visitor asking the question, forwarded to the backend to classify the asker.
|
||||
*/
|
||||
visitorAgent?: AskVisitorAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +47,10 @@ export async function serveAskMarkdown(
|
||||
return 'You forgot to pass a question in the `?ask=` parameter. Append a question to the URL in the `?ask=<question>` search parameter to get a complete answer and associated sources.';
|
||||
}
|
||||
|
||||
const latestAnswer = await streamSiteAskAnswer(context, question, { goal });
|
||||
const latestAnswer = await streamSiteAskAnswer(context, question, {
|
||||
goal,
|
||||
visitorAgent: options.visitorAgent,
|
||||
});
|
||||
|
||||
if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) {
|
||||
return `We couldn't answer this question.`;
|
||||
|
||||
Reference in New Issue
Block a user