Compare commits

...

8 Commits

Author SHA1 Message Date
Zeno Kapitein d678a9162d Fix typing & translations 2025-05-23 16:54:28 +02:00
Zeno Kapitein af7cadc8e9 Added back and forth chat 2025-05-21 18:08:02 +02:00
Zeno Kapitein e2b2468475 More layout tweaks 2025-05-21 15:13:22 +02:00
Zeno Kapitein 11f4241709 Update layout and transitions 2025-05-21 14:02:48 +02:00
Zeno Kapitein 5a410feda0 Add chat component (non-functional) 2025-05-21 14:02:48 +02:00
Zeno Kapitein 31bfe77f74 Create Chat component 2025-05-21 14:02:48 +02:00
Zeno Kapitein 34dceb2f97 Toggleable layout 2025-05-21 14:02:48 +02:00
Zeno Kapitein cced4f0d78 Initial styling 2025-05-21 14:02:48 +02:00
25 changed files with 1113 additions and 240 deletions
@@ -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: 'コンテンツを検索するか質問をする',
+4 -3
View File
@@ -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 paginas',
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 paginas',
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 paginas.',
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: '搜索内容或提问',