mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Add chat component (non-functional)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useVisitedPages } from '../Insights/useVisitedPages';
|
||||
import { streamAISearchSummary } from './server-actions';
|
||||
import { Button } from '../primitives';
|
||||
import { isQuestion } from './isQuestion';
|
||||
import { streamAISearchAnswer, streamAISearchSummary } from './server-actions';
|
||||
|
||||
export function SearchChat() {
|
||||
export function SearchChat(props: { query: string }) {
|
||||
// const currentPage = usePageContext();
|
||||
// const language = useLanguage();
|
||||
|
||||
const { query } = props;
|
||||
|
||||
const visitedPages = useVisitedPages((state) => state.pages);
|
||||
const [summary, setSummary] = useState('');
|
||||
const [messages, setMessages] = useState<
|
||||
{ role: string; content?: string; fetching?: boolean }[]
|
||||
>([]);
|
||||
const [followupQuestions, setFollowupQuestions] = useState<string[]>();
|
||||
|
||||
const [responseId, setResponseId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,7 +32,6 @@ export function SearchChat() {
|
||||
visitedPages,
|
||||
});
|
||||
|
||||
let generatedSummary = '';
|
||||
for await (const data of stream) {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -29,8 +40,7 @@ export function SearchChat() {
|
||||
}
|
||||
|
||||
if ('summary' in data && data.summary !== undefined) {
|
||||
generatedSummary = data.summary;
|
||||
setSummary(generatedSummary);
|
||||
setSummary(data.summary);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -40,28 +50,155 @@ export function SearchChat() {
|
||||
};
|
||||
}, [visitedPages]);
|
||||
|
||||
return (
|
||||
<motion.div layout="position" className="w-full">
|
||||
<h5 className="mb-1 flex items-center gap-1 font-semibold text-sm text-tint-subtle">
|
||||
<Icon icon="glasses-round" className="mt-0.5 size-4" /> Summary of what you've read
|
||||
</h5>
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
{summary ? (
|
||||
summary
|
||||
) : (
|
||||
<div key="loading" 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}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
if (query) {
|
||||
setMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: query,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
fetching: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(async () => {
|
||||
const stream = await streamAISearchAnswer({
|
||||
question: query,
|
||||
previousResponseId: responseId ?? undefined,
|
||||
});
|
||||
|
||||
for await (const data of stream) {
|
||||
if (cancelled) return;
|
||||
|
||||
if ('responseId' in data && data.responseId !== undefined) {
|
||||
setResponseId(data.responseId);
|
||||
}
|
||||
|
||||
if ('answer' in data && data.answer !== undefined) {
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ role: 'assistant', content: data.answer, fetching: false },
|
||||
]);
|
||||
}
|
||||
|
||||
if ('followupQuestions' in data && data.followupQuestions !== undefined) {
|
||||
setFollowupQuestions(data.followupQuestions.filter(filterOutNullable));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [query, responseId]);
|
||||
|
||||
return (
|
||||
<motion.div layout="position" className="relative mx-auto h-full p-8">
|
||||
<div className="mx-auto flex w-full max-w-prose flex-col gap-4">
|
||||
<div>
|
||||
<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
|
||||
) : (
|
||||
<div key="loading" 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.content}
|
||||
className={tcls(
|
||||
'flex flex-col gap-1',
|
||||
message.role === 'user' && 'items-end gap-1 self-end'
|
||||
)}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<h5 className="flex items-center gap-1 font-semibold text-tint-subtle text-xs">
|
||||
You asked {isQuestion(query) ? '' : 'about'}
|
||||
</h5>
|
||||
) : (
|
||||
<h5 className="flex items-center gap-1 font-semibold text-tint-subtle text-xs">
|
||||
<Icon icon="sparkle" className="mt-0.5 size-3" /> AI Answer
|
||||
</h5>
|
||||
)}
|
||||
{message.fetching ? (
|
||||
<div key="loading" 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>
|
||||
) : (
|
||||
<div
|
||||
className={tcls(
|
||||
message.role === 'user' && 'rounded-lg bg-tint-active px-4 py-2'
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{query ? (
|
||||
<div className="absolute inset-x-0 bottom-0 border-tint-subtle border-t bg-tint-subtle px-8 py-4">
|
||||
<div className="mx-auto flex w-full max-w-prose flex-col gap-2">
|
||||
{followupQuestions && followupQuestions.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{followupQuestions?.map((question) => (
|
||||
<div
|
||||
className="whitespace-nowrap rounded straight-corners:rounded-sm border border-tint-subtle bg-tint-base px-2 py-1 text-sm"
|
||||
key={question}
|
||||
>
|
||||
{question}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask a follow-up question"
|
||||
className="grow rounded px-4 py-1 ring-1 ring-tint-subtle"
|
||||
/>
|
||||
<Button
|
||||
label="Send"
|
||||
iconOnly
|
||||
icon="arrow-up"
|
||||
size="medium"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Button } from '../primitives/Button';
|
||||
import { LoadingPane } from '../primitives/LoadingPane';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
import { SearchChat } from './SearchChat';
|
||||
@@ -220,8 +218,8 @@ function SearchModalBody(
|
||||
'bg-tint-base',
|
||||
'max-w-screen-lg',
|
||||
'mx-auto',
|
||||
'min-h-[30dvh]',
|
||||
'max-h-[70dvh]',
|
||||
// 'min-h-[50dvh]',
|
||||
'h-[70dvh]',
|
||||
'w-full',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
@@ -317,28 +315,14 @@ function SearchModalBody(
|
||||
key="chat"
|
||||
layout
|
||||
className={tcls(
|
||||
'md:-col-end-1 flex items-start gap-4 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle p-8 max-md:border-t md:row-start-2 md:border-l',
|
||||
'md:-col-end-1 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle max-md:border-t md:row-start-2 md:border-l',
|
||||
state.mode === 'chat' && 'md:col-start-1'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: '100%' }}
|
||||
exit={{ width: 0 }}
|
||||
>
|
||||
{state.mode === 'chat' ? (
|
||||
<Button
|
||||
icon="right-from-line"
|
||||
iconOnly
|
||||
label="Show results"
|
||||
variant="blank"
|
||||
className="px-2"
|
||||
onClick={() => {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, mode: 'both', manual: true } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SearchChat />
|
||||
<SearchChat query={normalizedQuery} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -42,7 +42,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
|
||||
]
|
||||
)}
|
||||
{...getLinkProp({
|
||||
ask: true,
|
||||
query: question,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Loading } from '../primitives';
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
@@ -80,7 +80,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.
|
||||
@@ -214,91 +214,106 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
[moveBy, select]
|
||||
);
|
||||
|
||||
if (resultsState.fetching) {
|
||||
return (
|
||||
<motion.div
|
||||
className={tcls('flex', 'items-center', 'justify-center', 'p-8')}
|
||||
layout="position"
|
||||
>
|
||||
<Loading className={tcls('w-6', 'text-primary-subtle')} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
const loading = (
|
||||
<motion.div
|
||||
className={tcls('flex', 'items-center', 'justify-center', 'p-8')}
|
||||
layout="position"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<Loading className={tcls('w-6', 'text-primary-subtle')} />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const noResults = (
|
||||
<div className={tcls('text', 'text-tint', 'text-center', 'p-8')}>
|
||||
<motion.div
|
||||
layout="position"
|
||||
className={tcls('text', 'text-tint', 'text-center', 'p-8')}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{t(language, 'search_no_results', query)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return results.length === 0 ? (
|
||||
query ? (
|
||||
noResults
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div 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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{resultsState.fetching ? (
|
||||
loading
|
||||
) : query && results.length === 0 ? (
|
||||
noResults
|
||||
) : (
|
||||
<motion.div
|
||||
layout="position"
|
||||
className="flex flex-col gap-2 p-4"
|
||||
data-testid="search-results"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{!results.some((result) => result.type !== 'question') && noResults}
|
||||
</>
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -483,13 +483,15 @@ export async function* streamAISearchSummary({
|
||||
*/
|
||||
export async function* streamAISearchAnswer({
|
||||
question,
|
||||
previousResponseId,
|
||||
}: {
|
||||
question: string;
|
||||
previousResponseId?: string;
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const { stream } = await streamGenerateObject(
|
||||
const { stream, response } = await streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
@@ -498,26 +500,53 @@ export async function* streamAISearchAnswer({
|
||||
{
|
||||
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 using only the provided context below. 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.`,
|
||||
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: ${question}`,
|
||||
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 highlight = value.answer;
|
||||
if (!highlight) {
|
||||
const answer = value.answer;
|
||||
const followupQuestions = value.followupQuestions;
|
||||
|
||||
if (!answer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield highlight;
|
||||
yield { answer, followupQuestions };
|
||||
}
|
||||
|
||||
// Wait for the responseId to be available and yield one final time
|
||||
await responseIdPromise;
|
||||
yield { responseId };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user