Add chat component (non-functional)

This commit is contained in:
Zeno Kapitein
2025-05-13 10:50:10 +02:00
parent 31bfe77f74
commit 5a410feda0
7 changed files with 307 additions and 135 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;
}
) {
@@ -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 };
}