mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-13 22:29:22 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d678a9162d | |||
| af7cadc8e9 | |||
| e2b2468475 | |||
| 11f4241709 | |||
| 5a410feda0 | |||
| 31bfe77f74 | |||
| 34dceb2f97 | |||
| cced4f0d78 |
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
|
||||
input: api.AIMessageInput[];
|
||||
output: api.AIOutputFormat;
|
||||
model: api.AIModel;
|
||||
tools?: api.AIToolCapabilities;
|
||||
previousResponseId?: string;
|
||||
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
'use server';
|
||||
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
|
||||
import {
|
||||
type AIMessageInput,
|
||||
AIModel,
|
||||
type AIStreamResponse,
|
||||
type AIToolCapabilities,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookBaseContext } from '@v2/lib/context';
|
||||
import { EventIterator } from 'event-iterator';
|
||||
import type { MaybePromise } from 'p-map';
|
||||
@@ -51,6 +56,7 @@ export async function streamGenerateObject<T>(
|
||||
schema: z.ZodSchema<T>;
|
||||
messages: AIMessageInput[];
|
||||
model?: AIModel;
|
||||
tools?: AIToolCapabilities;
|
||||
previousResponseId?: string;
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'use server';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import { isV2 } from '@/lib/v2';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
import { z } from 'zod';
|
||||
import { streamGenerateObject } from './api';
|
||||
|
||||
/**
|
||||
* Get a summary of a page, in the context of another page
|
||||
*/
|
||||
export async function* streamLinkPageSummary({
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
visitedPages,
|
||||
}: {
|
||||
currentSpaceId: string;
|
||||
currentPageId: string;
|
||||
currentPageTitle: string;
|
||||
targetSpaceId: string;
|
||||
targetPageId: string;
|
||||
linkPreview?: string;
|
||||
linkTitle?: string;
|
||||
visitedPages?: Array<{ spaceId: string; pageId: string }>;
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const { stream } = await streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
highlight: z
|
||||
.string()
|
||||
.describe('The reason why the user should read the target page.'),
|
||||
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||
}),
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 1. Role
|
||||
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
|
||||
|
||||
# 2. Task
|
||||
Extract a contextually-relevant fact that:
|
||||
- Directly answers the specific need or question implied by the link's placement
|
||||
- States a capability, limitation, or specification from the target page
|
||||
- Connects precisely to the user's current paragraph or sentence
|
||||
- Completes the user's understanding based on what they're currently reading
|
||||
|
||||
# 3. Instructions
|
||||
1. First, identify the exact need, question, or gap in the current paragraph where the link appears
|
||||
2. Find the specific fact in the target page that addresses this exact contextual need
|
||||
3. Ensure the fact relates directly to the context of the paragraph containing the link
|
||||
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
|
||||
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 4. Current page
|
||||
The content of the current page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: currentSpaceId,
|
||||
pageId: currentPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
...(visitedPages
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: '# 5. Previous pages',
|
||||
},
|
||||
...visitedPages.map(({ spaceId, pageId }) => ({
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Page ${pageId}`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId,
|
||||
pageId,
|
||||
},
|
||||
],
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 6. Target page
|
||||
The content of the target page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: targetSpaceId,
|
||||
pageId: targetPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 7. Link preview
|
||||
The content of the link preview is:
|
||||
> ${linkPreview}
|
||||
> Page ID: ${targetPageId}`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 8. Guidelines & Examples
|
||||
ALWAYS:
|
||||
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
|
||||
- ALWAYS connect target page information specifically to the current paragraph context
|
||||
- ALWAYS focus on the gap in knowledge that the link is meant to fill
|
||||
- ALWAYS consider user's navigation history to ensure contextual continuity
|
||||
- ALWAYS use action verbs like "click", "select", "use", "create", "enable"
|
||||
|
||||
NEVER:
|
||||
- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly.
|
||||
- NEVER select general facts unrelated to the specific link context
|
||||
- NEVER ignore the specific context where the link appears
|
||||
- NEVER repeat the same fact in different words
|
||||
|
||||
## Examples
|
||||
Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once."
|
||||
Preview: "Multiple Select: Select multiple content blocks at once."
|
||||
✓ "Shift selects content between two points, useful for reorganizing your current heading structure."
|
||||
✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks."
|
||||
|
||||
Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]."
|
||||
Preview: "Change Requests: Collaborative content editing workflow."
|
||||
✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions."
|
||||
✗ "Each reviewer receives an email notification and can approve or request changes."
|
||||
|
||||
Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]."
|
||||
Preview: "Live Edit: Real-time collaborative editing."
|
||||
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
|
||||
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
|
||||
},
|
||||
].filter(filterOutNullable),
|
||||
}
|
||||
);
|
||||
|
||||
for await (const value of stream) {
|
||||
const highlight = value.highlight;
|
||||
if (!highlight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield highlight;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
/* Light mode */
|
||||
::-webkit-scrollbar {
|
||||
@apply bg-tint-subtle;
|
||||
@apply bg-tint-subtle z-50;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ export function HighlightQuery(props: {
|
||||
'text-bold',
|
||||
'bg-primary',
|
||||
'text-contrast-primary',
|
||||
'px-0.5',
|
||||
'-mx-0.5',
|
||||
'px-1',
|
||||
'py-0.5',
|
||||
'rounded',
|
||||
'straight-corners:rounded-sm',
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Link } from '../primitives';
|
||||
import { useSearchAskContext } from './SearchAskContext';
|
||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
import { useSearch, useSearchLink } from './useSearch';
|
||||
|
||||
export type SearchAskState =
|
||||
| {
|
||||
type: 'answer';
|
||||
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
}, [setAskState]);
|
||||
|
||||
const loading = (
|
||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
||||
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
|
||||
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
|
||||
{[...Array(9)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
|
||||
width: `${((index % 5) + 1) * 15}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
|
||||
<motion.div className={tcls('mx-auto w-full max-w-prose')} layout="position">
|
||||
{askState?.type === 'answer' ? (
|
||||
<React.Suspense fallback={loading}>
|
||||
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
|
||||
@@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
||||
) : null}
|
||||
{askState?.type === 'loading' ? loading : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="search-ask-answer"
|
||||
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
|
||||
>
|
||||
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
|
||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||
{answer.followupQuestions.length > 0 ? (
|
||||
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
||||
@@ -182,7 +186,6 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
|
||||
)}
|
||||
{...getSearchLinkProps({
|
||||
query: question,
|
||||
ask: true,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
|
||||
const onClick = () => {
|
||||
setSearchState({
|
||||
ask: false,
|
||||
mode: 'both',
|
||||
global: false,
|
||||
query: '',
|
||||
});
|
||||
@@ -99,7 +99,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut() {
|
||||
export function Shortcut() {
|
||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVisitedPages } from '../Insights/useVisitedPages';
|
||||
import { Button } from '../primitives';
|
||||
import { Shortcut } from './SearchButton';
|
||||
import { isQuestion } from './isQuestion';
|
||||
import { streamAISearchAnswer, streamAISearchSummary } from './server-actions';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
// Types
|
||||
type Message = {
|
||||
role: 'assistant' | 'user';
|
||||
content?: string;
|
||||
context?: string;
|
||||
fetching?: boolean;
|
||||
};
|
||||
|
||||
// Loading animation component
|
||||
function LoadingAnimation() {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{[...Array(9)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
|
||||
width: `${((index % 5) + 1) * 15}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Followup questions component
|
||||
function FollowupQuestions({
|
||||
questions,
|
||||
onQuestionClick,
|
||||
}: {
|
||||
questions: string[];
|
||||
onQuestionClick: (question: string) => void;
|
||||
}) {
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-prose flex-col">
|
||||
{questions.map((question) => (
|
||||
<button
|
||||
type="button"
|
||||
key={question}
|
||||
className="-mx-4 flex items-center gap-4 rounded straight-corners:rounded-none px-4 py-2 text-tint hover:bg-tint-hover"
|
||||
onClick={() => onQuestionClick(question)}
|
||||
>
|
||||
<Icon icon="search" className="size-4" /> {question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual chat message component
|
||||
function ChatMessage({
|
||||
message,
|
||||
}: {
|
||||
message: Message;
|
||||
}) {
|
||||
const language = useLanguage();
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={tcls('flex-col gap-1', isUser && 'items-end gap-1 self-end')}>
|
||||
<h5 className="flex items-center gap-1 font-semibold text-tint-subtle text-xs">
|
||||
{isUser ? (
|
||||
(message.context ??
|
||||
`You asked ${isQuestion(message.content ?? '') ? '' : 'about'}`)
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="sparkle" className="mt-0.5 size-3" />
|
||||
{message.context ?? 'AI Answer'}
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
|
||||
{message.fetching ? (
|
||||
<LoadingAnimation />
|
||||
) : !message.content ? (
|
||||
<div className="text-tint-subtle italic">{t(language, 'search_ask_no_answer')}</div>
|
||||
) : (
|
||||
<div className={tcls(isUser && 'rounded-lg bg-tint-active px-4 py-2')}>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chat input component
|
||||
function ChatInput({
|
||||
onSendMessage,
|
||||
disabled,
|
||||
inputRef,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void;
|
||||
disabled: boolean;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return;
|
||||
onSendMessage(inputValue);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex grow">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Ask a follow-up question"
|
||||
className="grow rounded px-4 py-1 ring-1 ring-tint-subtle"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!disabled && (
|
||||
<div className="-translate-y-1/2 absolute top-1/2 right-2.5">
|
||||
<Shortcut />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
label="Send"
|
||||
iconOnly
|
||||
icon="arrow-up"
|
||||
size="medium"
|
||||
className="shrink-0"
|
||||
onClick={handleSend}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom hook for AI streaming
|
||||
function useAIStream({
|
||||
question,
|
||||
previousResponseId,
|
||||
}: {
|
||||
question?: string;
|
||||
previousResponseId?: string;
|
||||
}) {
|
||||
const [response, setResponse] = useState<{
|
||||
content?: string;
|
||||
responseId?: string;
|
||||
followupQuestions?: string[];
|
||||
fetching: boolean;
|
||||
}>({
|
||||
fetching: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!question) return;
|
||||
|
||||
let cancelled = false;
|
||||
setResponse({ fetching: true });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await streamAISearchAnswer({
|
||||
question,
|
||||
previousResponseId,
|
||||
});
|
||||
|
||||
for await (const rawData of stream) {
|
||||
if (cancelled) break;
|
||||
if (!rawData) continue;
|
||||
|
||||
// Use type assertion to handle the data
|
||||
const data = rawData as any;
|
||||
|
||||
setResponse((prev) => {
|
||||
const updated = { ...prev, fetching: false };
|
||||
|
||||
if (data.responseId) {
|
||||
updated.responseId = String(data.responseId);
|
||||
}
|
||||
|
||||
if (data.answer) {
|
||||
updated.content = String(data.answer);
|
||||
}
|
||||
|
||||
if (data.followupQuestions) {
|
||||
updated.followupQuestions =
|
||||
data.followupQuestions.filter(filterOutNullable);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in AI stream:', error);
|
||||
setResponse((prev) => ({ ...prev, fetching: false }));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [question, previousResponseId]);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Summary hook
|
||||
function useSummary(visitedPages: any[]) {
|
||||
const [summary, setSummary] = useState('');
|
||||
const [summaryResponseId, setSummaryResponseId] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await streamAISearchSummary({ visitedPages });
|
||||
|
||||
for await (const rawData of stream) {
|
||||
if (cancelled) break;
|
||||
if (!rawData) continue;
|
||||
|
||||
// Use type assertion
|
||||
const data = rawData as any;
|
||||
|
||||
if (data.responseId) {
|
||||
setSummaryResponseId(String(data.responseId));
|
||||
}
|
||||
|
||||
if (data.summary) {
|
||||
setSummary(String(data.summary));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in summary stream:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visitedPages]);
|
||||
|
||||
return { summary, summaryResponseId };
|
||||
}
|
||||
|
||||
// Main component
|
||||
export function SearchChat(props: {
|
||||
query: string;
|
||||
chatInputRef?: React.RefObject<HTMLInputElement>;
|
||||
}) {
|
||||
const { query, chatInputRef } = props;
|
||||
const visitedPages = useVisitedPages((state) => state.pages);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [followupQuestions, setFollowupQuestions] = useState<string[]>([]);
|
||||
const [conversationResponseId, setConversationResponseId] = useState<string | undefined>();
|
||||
const [searchState, setSearchState] = useSearch();
|
||||
const latestMessageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isExpanded = searchState?.mode === 'chat';
|
||||
|
||||
// Get summary of visited pages
|
||||
const { summary, summaryResponseId } = useSummary(visitedPages);
|
||||
|
||||
// Handle initial query
|
||||
const initialResponse = useAIStream({
|
||||
question: query,
|
||||
previousResponseId: summaryResponseId,
|
||||
});
|
||||
|
||||
// Set up initial query effect
|
||||
useEffect(() => {
|
||||
if (!query) return;
|
||||
|
||||
// Add initial assistant message
|
||||
setMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`,
|
||||
fetching: true,
|
||||
},
|
||||
]);
|
||||
|
||||
setFollowupQuestions([]);
|
||||
setConversationResponseId(undefined);
|
||||
}, [query]);
|
||||
|
||||
// Update message when initial response changes
|
||||
useEffect(() => {
|
||||
if (!query || !initialResponse) return;
|
||||
|
||||
if (initialResponse.content !== undefined) {
|
||||
setMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`,
|
||||
content: initialResponse.content,
|
||||
fetching: initialResponse.fetching,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (initialResponse.followupQuestions) {
|
||||
setFollowupQuestions(initialResponse.followupQuestions);
|
||||
}
|
||||
|
||||
if (initialResponse.responseId) {
|
||||
setConversationResponseId(initialResponse.responseId);
|
||||
}
|
||||
}, [initialResponse, query]);
|
||||
|
||||
// Handle follow-up messages
|
||||
const handleSendMessage = (message: string) => {
|
||||
// Add user message
|
||||
const newMessages: Message[] = [
|
||||
...messages,
|
||||
{ role: 'user', content: message, fetching: false },
|
||||
{ role: 'assistant', fetching: true },
|
||||
];
|
||||
|
||||
setMessages(newMessages);
|
||||
setFollowupQuestions([]);
|
||||
if (!searchState?.manual) {
|
||||
setSearchState((state) => (state ? { ...state, mode: 'chat' } : null));
|
||||
}
|
||||
|
||||
// Get AI response
|
||||
const cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await streamAISearchAnswer({
|
||||
question: message,
|
||||
previousResponseId: conversationResponseId,
|
||||
});
|
||||
|
||||
for await (const rawData of stream) {
|
||||
if (cancelled) break;
|
||||
if (!rawData) continue;
|
||||
|
||||
// Use type assertion
|
||||
const data = rawData as any;
|
||||
|
||||
if (data.responseId) {
|
||||
setConversationResponseId(String(data.responseId));
|
||||
}
|
||||
|
||||
if (data.answer !== undefined) {
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ role: 'assistant', content: data.answer, fetching: false },
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.followupQuestions && Array.isArray(data.followupQuestions)) {
|
||||
setFollowupQuestions(data.followupQuestions.filter(filterOutNullable));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in follow-up stream:', error);
|
||||
// Update the message to show an error state
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ role: 'assistant', fetching: false },
|
||||
]);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// Handle followup question click
|
||||
const handleFollowupClick = (question: string) => {
|
||||
handleSendMessage(question);
|
||||
};
|
||||
|
||||
// Auto-scroll to latest message
|
||||
useEffect(() => {
|
||||
if (latestMessageRef.current) {
|
||||
latestMessageRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Toggle button for showing search results */}
|
||||
{searchState?.mode === 'chat' && (
|
||||
<div
|
||||
className="absolute top-2 animate-fadeIn max-md:right-4 md:top-4 md:left-4"
|
||||
style={{ animationDelay: '500ms' }}
|
||||
>
|
||||
<Button
|
||||
label="Show search results"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
iconOnly
|
||||
icon="arrow-down-from-line"
|
||||
className="md:hidden"
|
||||
onClick={() => {
|
||||
setSearchState((state) =>
|
||||
state ? { ...state, mode: 'both', manual: true } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Show search results"
|
||||
iconOnly
|
||||
variant="blank"
|
||||
size="default"
|
||||
icon="sidebar"
|
||||
className="hidden px-2 md:block"
|
||||
onClick={() => {
|
||||
setSearchState((state) =>
|
||||
state ? { ...state, mode: 'both', manual: true } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main chat area */}
|
||||
<div
|
||||
className={tcls(
|
||||
'mx-auto flex w-full grow scroll-pt-8 flex-col gap-4 overflow-y-auto p-8 transition-all delay-200 duration-500',
|
||||
isExpanded && 'md:px-16'
|
||||
)}
|
||||
ref={latestMessageRef}
|
||||
>
|
||||
{/* Summary section */}
|
||||
<div className="mx-auto w-full max-w-prose">
|
||||
<h5 className="mb-1 flex items-center gap-1 font-semibold text-tint-subtle text-xs">
|
||||
<Icon icon="glasses-round" className="mt-0.5 size-3" /> Summary of what
|
||||
you've read
|
||||
</h5>
|
||||
{summary ? summary : <LoadingAnimation />}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{messages.map((message, index) => {
|
||||
const isLast = index === messages.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isLast ? latestMessageRef : undefined}
|
||||
className={tcls(
|
||||
'mx-auto flex flex w-full max-w-prose flex-col gap-4',
|
||||
isLast && 'min-h-[calc(100%-2rem)]'
|
||||
)}
|
||||
>
|
||||
<ChatMessage message={message} />
|
||||
{isLast && followupQuestions && followupQuestions.length > 0 && (
|
||||
<FollowupQuestions
|
||||
questions={followupQuestions}
|
||||
onQuestionClick={handleFollowupClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
{query && (
|
||||
<div
|
||||
className={tcls(
|
||||
'border-tint-subtle border-t bg-tint-subtle px-8 py-4 transition-all delay-200 duration-500',
|
||||
isExpanded && 'md:px-16'
|
||||
)}
|
||||
>
|
||||
<div className={tcls('mx-auto flex w-full max-w-prose flex-col gap-2')}>
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={!conversationResponseId}
|
||||
inputRef={chatInputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
@@ -8,10 +6,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { LoadingPane } from '../primitives/LoadingPane';
|
||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
import { SearchChat } from './SearchChat';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { type SearchState, type UpdateSearchState, useSearch } from './useSearch';
|
||||
@@ -30,14 +27,21 @@ export function SearchModal(props: SearchModalProps) {
|
||||
const searchAsk = useSearchAskState();
|
||||
const [askState] = searchAsk;
|
||||
const router = useRouter();
|
||||
const chatInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
useHotkeys(
|
||||
'mod+k',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setSearchState({ ask: false, query: '', global: false });
|
||||
if (state !== null) {
|
||||
// If search is already open, focus the chat input
|
||||
chatInputRef.current?.focus();
|
||||
} else {
|
||||
// Otherwise open the search modal
|
||||
setSearchState({ mode: 'both', query: '', global: false });
|
||||
}
|
||||
},
|
||||
[]
|
||||
[state]
|
||||
);
|
||||
|
||||
// Add a global class on the body when the search modal is open
|
||||
@@ -125,6 +129,7 @@ export function SearchModal(props: SearchModalProps) {
|
||||
state={state}
|
||||
setSearchState={setSearchState}
|
||||
onClose={onClose}
|
||||
chatInputRef={chatInputRef}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -139,9 +144,11 @@ function SearchModalBody(
|
||||
state: SearchState;
|
||||
setSearchState: UpdateSearchState;
|
||||
onClose: (to?: string) => void;
|
||||
chatInputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
) {
|
||||
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose } = props;
|
||||
const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose, chatInputRef } =
|
||||
props;
|
||||
|
||||
const language = useLanguage();
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
@@ -165,6 +172,12 @@ function SearchModalBody(
|
||||
}, [onClose]);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Handle second Cmd+K
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
chatInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.moveUp();
|
||||
@@ -179,7 +192,7 @@ function SearchModalBody(
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchState({
|
||||
ask: false, // When typing, we go back to the default search mode
|
||||
mode: 'both', // When typing, we go back to the default search mode
|
||||
query: event.target.value,
|
||||
global: state.global,
|
||||
});
|
||||
@@ -219,9 +232,10 @@ function SearchModalBody(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'bg-tint-base',
|
||||
'max-w-prose',
|
||||
'max-w-screen-lg',
|
||||
'mx-auto',
|
||||
'max-h-[70dvh]',
|
||||
// 'min-h-[50dvh]',
|
||||
'h-[70dvh]',
|
||||
'w-full',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
@@ -242,12 +256,10 @@ function SearchModalBody(
|
||||
'flex-row',
|
||||
'items-start',
|
||||
state.query !== null ? 'border-b' : null,
|
||||
'border-tint-subtle'
|
||||
'border-tint-subtle',
|
||||
'col-span-full'
|
||||
)}
|
||||
>
|
||||
<div className={tcls('p-2', 'pl-4', 'pt-4')}>
|
||||
<Icon icon="magnifying-glass" className={tcls('size-4', 'text-tint-subtle')} />
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full',
|
||||
@@ -270,8 +282,8 @@ function SearchModalBody(
|
||||
'flex',
|
||||
'resize-none',
|
||||
'flex-1',
|
||||
'h-12',
|
||||
'p-2',
|
||||
'py-4',
|
||||
'px-8',
|
||||
'focus:outline-none',
|
||||
'bg-transparent',
|
||||
'whitespace-pre-line'
|
||||
@@ -287,18 +299,38 @@ function SearchModalBody(
|
||||
{isMultiVariants ? <SearchScopeToggle spaceTitle={spaceTitle} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{!state.ask || !withAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
) : null}
|
||||
{normalizedQuery && state.ask && withAsk ? (
|
||||
<SearchAskAnswer query={normalizedQuery} />
|
||||
) : null}
|
||||
<div className={tcls('flex grow flex-col overflow-hidden md:flex-row')}>
|
||||
<div
|
||||
key="results"
|
||||
className={tcls(
|
||||
'h-full w-full flex-1 overflow-y-auto transition-all duration-500 ease-[cubic-bezier(0.85,0,0.15,1)] *:transition-opacity *:delay-200 *:duration-300',
|
||||
state.mode === 'chat' && 'flex-[0] delay-200 *:opacity-0 *:delay-0'
|
||||
)}
|
||||
aria-hidden={state.mode === 'chat' ? 'true' : undefined}
|
||||
>
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
key="chat"
|
||||
className={tcls(
|
||||
'relative h-full w-full flex-1 overflow-y-auto overflow-x-hidden bg-tint-subtle transition-colors duration-500 *:transition-opacity *:delay-200 *:duration-300 max-md:border-t md:border-l',
|
||||
state.mode === 'results' && 'flex-[0] *:opacity-0 *:delay-0',
|
||||
state.mode === 'both'
|
||||
? 'border-tint-subtle'
|
||||
: 'border-transparent delay-500'
|
||||
)}
|
||||
aria-hidden={state.mode === 'results' ? 'true' : undefined}
|
||||
>
|
||||
<SearchChat query={normalizedQuery} chatInputRef={chatInputRef} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import type { ComputedPageResult } from './server-actions';
|
||||
|
||||
@@ -14,6 +16,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const language = useLanguage();
|
||||
const { query, item, active } = props;
|
||||
|
||||
const breadcrumbs =
|
||||
@@ -34,16 +37,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'p-4',
|
||||
'border-t',
|
||||
'border-tint-subtle',
|
||||
'first:border-none',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'text-base',
|
||||
'font-medium',
|
||||
'text-tint-strong',
|
||||
'hover:bg-tint-hover',
|
||||
'group',
|
||||
active
|
||||
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
|
||||
: null
|
||||
active && [
|
||||
'is-active',
|
||||
'bg-primary',
|
||||
'text-primary-strong',
|
||||
'hover:bg-primary-hover',
|
||||
]
|
||||
)}
|
||||
insights={{
|
||||
type: 'search_open_result',
|
||||
@@ -56,8 +62,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
>
|
||||
<div className="size-4">
|
||||
<Icon
|
||||
icon="file-lines"
|
||||
className={tcls('size-4', active ? 'text-primary' : 'text-tint-subtle')}
|
||||
icon="file"
|
||||
className={tcls('size-4', active ? 'text-primary-subtle' : 'text-tint-subtle')}
|
||||
/>
|
||||
</div>
|
||||
<div className={tcls('flex', 'flex-col', 'w-full')}>
|
||||
@@ -65,7 +71,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'opacity-6',
|
||||
active ? 'text-primary-subtle' : 'text-neutral-subtle',
|
||||
// 'opacity-6',
|
||||
'contrast-more:opacity-11',
|
||||
'font-normal',
|
||||
'uppercase',
|
||||
@@ -103,19 +110,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
) : null}
|
||||
<HighlightQuery query={query} text={item.title} />
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-none',
|
||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
||||
className={tcls('size-4')}
|
||||
{active ? (
|
||||
<Button
|
||||
icon="arrow-turn-down-left"
|
||||
size="small"
|
||||
label={tString(language, 'view')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { useSearchLink } from './useSearch';
|
||||
|
||||
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
|
||||
@@ -28,19 +28,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
className={tcls(
|
||||
'flex',
|
||||
'px-4',
|
||||
recommended ? ['py-2', 'text-tint'] : 'py-4',
|
||||
'py-2',
|
||||
'text-tint',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'hover:bg-tint-hover',
|
||||
'first:mt-0',
|
||||
'last:pb-3',
|
||||
'gap-4',
|
||||
active && [
|
||||
'is-active',
|
||||
'bg-primary',
|
||||
'text-contrast-primary',
|
||||
'text-primary-strong',
|
||||
'hover:bg-primary-hover',
|
||||
]
|
||||
)}
|
||||
{...getLinkProp({
|
||||
ask: true,
|
||||
query: question,
|
||||
})}
|
||||
>
|
||||
@@ -50,7 +51,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
'size-4',
|
||||
'shrink-0',
|
||||
'mt-1.5',
|
||||
'mr-4',
|
||||
active ? ['text-primary'] : ['text-tint-subtle']
|
||||
)}
|
||||
/>
|
||||
@@ -66,19 +66,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded',
|
||||
'self-center',
|
||||
'straight-corners:rounded-none',
|
||||
active ? ['bg-primary-solid', 'text-contrast-primary-solid'] : ['opacity-6']
|
||||
<div className="self-center">
|
||||
{active ? (
|
||||
<Button
|
||||
icon="arrow-turn-down-left"
|
||||
size="small"
|
||||
label={tString(language, 'search')}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={active ? 'arrow-turn-down-left' : 'chevron-right'}
|
||||
className={tcls('size-4')}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import React from 'react';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Loading } from '../primitives';
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
searchSiteSpaceContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import { type SearchState, useSearch } from './useSearch';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
moveUp(): void;
|
||||
@@ -44,7 +46,6 @@ let cachedRecommendedQuestions: null | ResultType[] = null;
|
||||
*/
|
||||
export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
global: boolean;
|
||||
withAsk: boolean;
|
||||
@@ -52,7 +53,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, query, withAsk, global, onSwitchToAsk } = props;
|
||||
const { query, withAsk, global, onSwitchToAsk } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -62,6 +63,39 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}>({ results: [], fetching: true });
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
const [searchState, setSearchState] = useSearch();
|
||||
const manualStateRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchState?.manual !== undefined) {
|
||||
manualStateRef.current = searchState.manual;
|
||||
}
|
||||
}, [searchState?.manual]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
} else if (!searchState?.manual && !resultsState.fetching && results.length === 0) {
|
||||
setSearchState((prev) => {
|
||||
const newState: SearchState | null = prev
|
||||
? { ...prev, mode: 'chat' as const }
|
||||
: null;
|
||||
return newState;
|
||||
});
|
||||
} else if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setSearchState((prev) => {
|
||||
const newState: SearchState | null = prev
|
||||
? { ...prev, mode: 'both' as const }
|
||||
: null;
|
||||
return newState;
|
||||
});
|
||||
setCursor(0);
|
||||
}
|
||||
}, [results, query, setSearchState, resultsState.fetching, searchState?.manual]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -78,7 +112,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
let cancelled = false;
|
||||
|
||||
// Silently fetch the recommended questions, instead of showing a spinner
|
||||
setResultsState({ results: [], fetching: false });
|
||||
// setResultsState({ results: [], fetching: false });
|
||||
|
||||
// We currently have a bug where the same question can be returned multiple times.
|
||||
// This is a workaround to avoid that.
|
||||
@@ -150,23 +184,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
};
|
||||
}, [query, global, withAsk, trackEvent]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAsk) {
|
||||
return resultsState.results;
|
||||
}
|
||||
return withQuestionResult(resultsState.results, query);
|
||||
}, [resultsState.results, query, withAsk]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
// Reset the cursor when there's no query
|
||||
setCursor(null);
|
||||
} else if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setCursor(0);
|
||||
}
|
||||
}, [results, query]);
|
||||
|
||||
// Scroll to the active result.
|
||||
React.useEffect(() => {
|
||||
if (cursor === null || !refs.current[cursor]) {
|
||||
@@ -214,106 +231,93 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
[moveBy, select]
|
||||
);
|
||||
|
||||
if (resultsState.fetching) {
|
||||
return (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-8')}>
|
||||
<Loading className={tcls('w-6', 'text-primary')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const loading = (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
|
||||
<Loading className={tcls('w-6', 'text-primary-subtle')} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const noResults = (
|
||||
<div className={tcls('text', 'text-tint', 'p-8', 'text-center')}>
|
||||
{t(language, 'search_no_results', query)}
|
||||
<div className={tcls('text', 'text-tint', 'text-center', 'p-8')}>
|
||||
<div className="animate-fadeIn" style={{ animationDelay: '0.5s' }}>
|
||||
{t(language, 'search_no_results', query)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('overflow-auto')}>
|
||||
{children}
|
||||
{results.length === 0 ? (
|
||||
query ? (
|
||||
noResults
|
||||
) : null
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{resultsState.fetching ? (
|
||||
loading
|
||||
) : query && results.length === 0 ? (
|
||||
noResults
|
||||
) : (
|
||||
<>
|
||||
<div data-testid="search-results">
|
||||
{results.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex flex-col gap-2 p-4"
|
||||
data-testid="search-results"
|
||||
>
|
||||
{results.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
return (
|
||||
<SearchPageResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{!results.some((result) => result.type !== 'question') && noResults}
|
||||
</>
|
||||
case 'question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={query}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'recommended-question': {
|
||||
return (
|
||||
<SearchQuestionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
question={item.question}
|
||||
active={index === cursor}
|
||||
onClick={onSwitchToAsk}
|
||||
recommended
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'section': {
|
||||
return (
|
||||
<SearchSectionResultItem
|
||||
ref={(ref) => {
|
||||
refs.current[index] = ref;
|
||||
}}
|
||||
key={item.id}
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(item);
|
||||
}
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a "Ask <question>" item at the top of the results list.
|
||||
*/
|
||||
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
|
||||
const without = results.filter((result) => result.type !== 'question');
|
||||
|
||||
if (query.length === 0) {
|
||||
return without;
|
||||
}
|
||||
|
||||
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import type { ComputedSectionResult } from './server-actions';
|
||||
|
||||
@@ -16,13 +17,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
ref: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
const { query, item, active } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={item.href}
|
||||
className={tcls(
|
||||
'[&:has(+:not(&))]:mb-6',
|
||||
// '[&:has(+:not(&))]:mb-6',
|
||||
'-mt-2',
|
||||
'flex',
|
||||
'items-center',
|
||||
'pl-6',
|
||||
@@ -33,6 +36,8 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
'font-normal',
|
||||
'py-2',
|
||||
'group',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
active && [
|
||||
'is-active',
|
||||
'bg-primary',
|
||||
@@ -72,20 +77,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'p-2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-none',
|
||||
'bg-primary-solid',
|
||||
'text-contrast-primary-solid',
|
||||
'hidden',
|
||||
'sm:block',
|
||||
active ? ['opacity-11', 'block'] : ['opacity-0']
|
||||
)}
|
||||
>
|
||||
<Icon icon="arrow-turn-down-left" className={tcls('size-4')} />
|
||||
</div>
|
||||
{active ? (
|
||||
<Button
|
||||
icon="arrow-turn-down-left"
|
||||
size="small"
|
||||
label={tString(language, 'view')}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="chevron-right" className="size-4 text-tint-subtle/6" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const questionWords = new Set([
|
||||
* Return true if an input query looks like a question.
|
||||
*/
|
||||
export function isQuestion(query: string): boolean {
|
||||
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
|
||||
if ((query.length > 25 && query.includes(' ')) || query.includes('?')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import { resolvePageId } from '@/lib/pages';
|
||||
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import type {
|
||||
RevisionPage,
|
||||
SearchAIAnswer,
|
||||
SearchAIRecommendedQuestionStream,
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
Space,
|
||||
import {
|
||||
AIMessageRole,
|
||||
type RevisionPage,
|
||||
type SearchAIAnswer,
|
||||
type SearchAIRecommendedQuestionStream,
|
||||
type SearchPageResult,
|
||||
type SearchSpaceResult,
|
||||
type SiteSection,
|
||||
type SiteSectionGroup,
|
||||
type Space,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
@@ -24,6 +25,8 @@ import { isV2 } from '@/lib/v2';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { throwIfDataError } from '@v2/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { z } from 'zod';
|
||||
import { streamGenerateObject } from '../Adaptive/server-actions/api';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
|
||||
export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult;
|
||||
@@ -410,3 +413,140 @@ async function transformSitePageResult(
|
||||
|
||||
return [page, ...pageSections];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an AI-generated answer to a search query.
|
||||
*/
|
||||
export async function* streamAISearchSummary({
|
||||
visitedPages,
|
||||
}: {
|
||||
visitedPages: { spaceId: string; pageId: string }[];
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const { stream, response } = await streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
summary: z
|
||||
.string()
|
||||
.describe(
|
||||
'A summary of the most important information the user has learned from the provided context.'
|
||||
),
|
||||
}),
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content:
|
||||
'Summarise the most important information the user has learned from the provided context. Be concise and focus on facts. Do not add commentary, adjectives or other empty descriptors.',
|
||||
attachments: visitedPages.map(({ spaceId, pageId }) => ({
|
||||
type: 'page' as const,
|
||||
spaceId,
|
||||
pageId,
|
||||
})),
|
||||
},
|
||||
].filter(filterOutNullable),
|
||||
}
|
||||
);
|
||||
|
||||
// Get the responseId asynchronously in the background
|
||||
let responseId: string | null = null;
|
||||
const responseIdPromise = response
|
||||
.then((r) => {
|
||||
responseId = r.responseId;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error getting responseId:', error);
|
||||
});
|
||||
|
||||
for await (const value of stream) {
|
||||
const summary = value.summary;
|
||||
if (!summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield { summary };
|
||||
}
|
||||
|
||||
// Wait for the responseId to be available and yield one final time
|
||||
await responseIdPromise;
|
||||
yield { responseId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an AI-generated answer to a search query.
|
||||
*/
|
||||
export async function* streamAISearchAnswer({
|
||||
question,
|
||||
previousResponseId,
|
||||
}: {
|
||||
question: string;
|
||||
previousResponseId?: string;
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const { stream, response } = await streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
answer: z.string().describe('The answer to the question.'),
|
||||
followupQuestions: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Follow-up questions to the question, based on the provided content only. Keep questions very short and use pronouns to refer to known concepts.'
|
||||
)
|
||||
.max(3),
|
||||
}),
|
||||
tools: {
|
||||
search: true,
|
||||
getPageContent: true,
|
||||
},
|
||||
previousResponseId: previousResponseId,
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `Answer the following question by using the provided documentation or by searching. Format the answer in Markdown. If you cannot answer the question using the context provided, provide an empty string. Always list related follow-up questions using the provided context. Check first that you can answer the question given the provided context before listing it as a follow-up question. If you can't answer a question, don't include it in the follow up questions. If there is no provided context, do not list follow-up questions. List the sources used to answer the question in the "sources" field. Only list the sources that were directly used for the content of the answer.`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: question,
|
||||
},
|
||||
].filter(filterOutNullable),
|
||||
}
|
||||
);
|
||||
|
||||
// Get the responseId asynchronously in the background
|
||||
let responseId: string | null = null;
|
||||
const responseIdPromise = response
|
||||
.then((r) => {
|
||||
responseId = r.responseId;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error getting responseId:', error);
|
||||
});
|
||||
|
||||
for await (const value of stream) {
|
||||
const answer = value.answer;
|
||||
const followupQuestions = value.followupQuestions;
|
||||
|
||||
if (answer === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield { answer, followupQuestions };
|
||||
}
|
||||
|
||||
// Wait for the responseId to be available and yield one final time
|
||||
await responseIdPromise;
|
||||
yield { responseId };
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
ask: boolean;
|
||||
global: boolean;
|
||||
mode: 'results' | 'chat' | 'both';
|
||||
manual?: boolean;
|
||||
}
|
||||
|
||||
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
||||
const keyMap = {
|
||||
q: parseAsString,
|
||||
ask: parseAsBoolean,
|
||||
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
|
||||
global: parseAsBoolean,
|
||||
manual: parseAsBoolean,
|
||||
};
|
||||
|
||||
export type UpdateSearchState = (
|
||||
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
|
||||
return {
|
||||
query: rawState.q,
|
||||
mode: rawState.mode,
|
||||
global: !!rawState.global,
|
||||
manual: !!rawState.manual,
|
||||
};
|
||||
}, [rawState]);
|
||||
|
||||
const stateRef = React.useRef(state);
|
||||
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
if (update === null) {
|
||||
return setRawState({
|
||||
q: null,
|
||||
ask: null,
|
||||
mode: null,
|
||||
global: null,
|
||||
manual: null,
|
||||
});
|
||||
}
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask ? true : null,
|
||||
mode: update.mode,
|
||||
global: update.global ? true : null,
|
||||
manual: update.manual ? true : null,
|
||||
});
|
||||
},
|
||||
[setRawState]
|
||||
@@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
(query) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('q', query.query ?? '');
|
||||
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
|
||||
query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode');
|
||||
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
|
||||
searchParams.delete('manual');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
@@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
event.preventDefault();
|
||||
setSearch((prev) => ({
|
||||
query: '',
|
||||
ask: false,
|
||||
mode: 'both',
|
||||
global: false,
|
||||
...(prev ?? {}),
|
||||
...query,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const de = {
|
||||
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
search: 'Suche',
|
||||
view: 'Anzeigen',
|
||||
search_or_ask: 'Fragen oder Suchen',
|
||||
search_input_placeholder: 'Inhalt durchsuchen',
|
||||
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const en = {
|
||||
switch_to_light_theme: 'Switch to light theme',
|
||||
switch_to_system_theme: 'Switch to system theme',
|
||||
search: 'Search',
|
||||
view: 'View',
|
||||
search_or_ask: 'Ask or search',
|
||||
search_input_placeholder: 'Search content',
|
||||
search_ask_input_placeholder: 'Search content or ask a question',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const es: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Cambiar a tema claro',
|
||||
switch_to_system_theme: 'Cambiar a tema del sistema',
|
||||
search: 'Buscar',
|
||||
view: 'Ver',
|
||||
search_or_ask: 'Preguntar o Buscar',
|
||||
search_input_placeholder: 'Buscar contenido',
|
||||
search_ask_input_placeholder: 'Buscar contenido o hacer una pregunta',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const fr: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Passer au thème clair',
|
||||
switch_to_system_theme: 'Passer au thème système',
|
||||
search: 'Rechercher',
|
||||
view: 'Voir',
|
||||
search_or_ask: 'Demander ou rechercher',
|
||||
search_input_placeholder: 'Rechercher le contenu',
|
||||
search_ask_input_placeholder: 'Rechercher du contenu ou poser une question',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ja: TranslationLanguage = {
|
||||
switch_to_light_theme: 'ライトテーマに切り替え',
|
||||
switch_to_system_theme: 'システムのテーマに切り替え',
|
||||
search: '検索',
|
||||
view: '表示',
|
||||
search_or_ask: '質問または検索',
|
||||
search_input_placeholder: 'コンテンツを検索',
|
||||
search_ask_input_placeholder: 'コンテンツを検索するか質問をする',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const nl: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Schakel over naar lichte modus',
|
||||
switch_to_system_theme: 'Schakel over naar systeemmodus',
|
||||
search: 'Zoeken',
|
||||
view: 'Bekijken',
|
||||
search_or_ask: 'Zoek of vraag',
|
||||
search_input_placeholder: 'Zoek inhoud',
|
||||
search_ask_input_placeholder: 'Zoek inhoud of stel een vraag',
|
||||
@@ -17,7 +18,7 @@ export const nl: TranslationLanguage = {
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met AI',
|
||||
search_ask_sources: 'Bronnen',
|
||||
search_ask_sources_no_answer: 'Gerelateerde pagina’s',
|
||||
search_ask_sources_no_answer: "Gerelateerde pagina's",
|
||||
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.',
|
||||
@@ -54,9 +55,9 @@ export const nl: TranslationLanguage = {
|
||||
pdf_print: 'Print of opslaan als PDF',
|
||||
pdf_page_of: '${1} van ${2}',
|
||||
pdf_mode_only_page: 'Alleen deze pagina',
|
||||
pdf_mode_all: 'Alle pagina’s',
|
||||
pdf_mode_all: "Alle pagina's",
|
||||
pdf_limit_reached: "Kon de PDF niet genereren voor ${1} pagina's, generatie gestopt bij ${2}.",
|
||||
pdf_limit_reached_continue: 'Verleng met ${1} extra pagina’s.',
|
||||
pdf_limit_reached_continue: "Verleng met ${1} extra pagina's.",
|
||||
more: 'Meer',
|
||||
link_tooltip_external_link: 'Externe link naar',
|
||||
link_tooltip_page_anchor: 'Spring naar sectie',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const no: TranslationLanguage = {
|
||||
switch_to_light_theme: 'Bytt til lyst tema',
|
||||
switch_to_system_theme: 'Bytt til systemtema',
|
||||
search: 'Søk',
|
||||
view: 'Vis',
|
||||
search_or_ask: 'Spør eller søk',
|
||||
search_input_placeholder: 'Søk i innhold',
|
||||
search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const pt_br = {
|
||||
switch_to_light_theme: 'Mudar para modo claro',
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
search: 'Busca',
|
||||
view: 'Ver',
|
||||
search_or_ask: 'Perguntar ou buscar',
|
||||
search_input_placeholder: 'Buscar conteúdo',
|
||||
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const zh: TranslationLanguage = {
|
||||
switch_to_light_theme: '切换到浅色主题',
|
||||
switch_to_system_theme: '切换到系统主题',
|
||||
search: '搜索',
|
||||
view: '查看',
|
||||
search_or_ask: '询问或搜索',
|
||||
search_input_placeholder: '搜索内容',
|
||||
search_ask_input_placeholder: '搜索内容或提问',
|
||||
|
||||
Reference in New Issue
Block a user