This commit is contained in:
Zeno Kapitein
2025-05-07 15:05:31 +02:00
parent c38d403f77
commit 51f59063ac
8 changed files with 250 additions and 11 deletions
+1
View File
@@ -1395,6 +1395,7 @@ async function* streamAIResponse(
output: params.output,
model: params.model,
tools: params.tools,
previousResponseId: params.previousResponseId,
});
for await (const event of res) {
@@ -190,5 +190,6 @@ export interface GitBookDataFetcher {
output: api.AIOutputFormat;
model: api.AIModel;
tools?: api.AIToolCapabilities;
previousResponseId?: string;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
@@ -2,9 +2,18 @@
import { useEffect, useRef, useState } from 'react';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Button } from '../primitives/Button';
import { useAdaptiveContext } from './AdaptiveContext';
import { streamPageQuestion } from './server-actions/streamPageQuestion';
import { streamPageSummary } from './server-actions/streamPageSummary';
interface ChatMessage {
type: 'question' | 'answer';
content: string;
}
type StreamData = { answer: string } | { newResponseId: string } | { toolUsage: boolean };
export function AIPageSummary() {
const { toggle, setLoading, setToggle } = useAdaptiveContext();
@@ -17,8 +26,66 @@ export function AIPageSummary() {
bigPicture?: string;
}>({});
const [question, setQuestion] = useState('');
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [isAsking, setIsAsking] = useState(false);
const [responseId, setResponseId] = useState<string | null>(null);
const [showTypingIndicator, setShowTypingIndicator] = useState(false);
const handleSubmit = async () => {
if (!question.trim() || isAsking) return;
const currentQuestion = question;
setQuestion('');
setIsAsking(true);
setShowTypingIndicator(true);
// Add question to chat history
setChatHistory((prev) => [...prev, { type: 'question', content: currentQuestion }]);
try {
const stream = await streamPageQuestion(currentQuestion, responseId ?? '');
let currentAnswer = '';
for await (const data of stream as AsyncIterableIterator<StreamData>) {
if ('answer' in data && data.answer) {
currentAnswer = data.answer;
setShowTypingIndicator(false);
// Update the last message in chat history with the streaming answer
setChatHistory((prev) => {
const newHistory = [...prev];
const lastMessage = newHistory[newHistory.length - 1];
if (lastMessage?.type === 'answer') {
lastMessage.content = currentAnswer;
} else {
newHistory.push({ type: 'answer', content: currentAnswer });
}
return newHistory;
});
} else if ('newResponseId' in data && data.newResponseId) {
setResponseId(data.newResponseId);
} else if ('toolUsage' in data) {
// Show typing indicator when tools are being used
setShowTypingIndicator(true);
}
}
} finally {
setIsAsking(false);
setShowTypingIndicator(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit();
}
};
useEffect(() => {
if (!summary.keyFacts) setLoading(true);
}, [summary.keyFacts, setLoading]);
useEffect(() => {
if (!visitedPages?.length) return;
// Skip if the visited pages haven't changed
@@ -41,10 +108,17 @@ export function AIPageSummary() {
visitedPages: visitedPages,
});
for await (const summary of stream) {
for await (const data of stream) {
if (canceled) return;
setSummary(summary);
if ('responseId' in data && data.responseId !== undefined) {
setResponseId(data.responseId);
}
setSummary((prev) => ({
keyFacts: data.keyFacts ?? prev.keyFacts,
bigPicture: data.bigPicture ?? prev.bigPicture,
}));
}
})().finally(() => {
setLoading(false);
@@ -53,7 +127,7 @@ export function AIPageSummary() {
return () => {
canceled = true;
};
}, [currentPage, visitedPages, toggle, setLoading, setToggle]);
}, [currentPage, visitedPages, setLoading]);
const shimmerBlocks = [20, 35, 25, 10, 45, 30, 30, 35, 25, 10, 40, 30]; // Widths in percentages
@@ -91,6 +165,66 @@ export function AIPageSummary() {
{summary?.bigPicture}
</div>
) : null}
{chatHistory.length > 0 && (
<div className="flex flex-col gap-3">
{chatHistory.map((message) => (
<div
key={message.content.slice(0, 10)}
className={`flex ${message.type === 'question' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
message.type === 'question'
? 'bg-primary-solid text-contrast-primary-solid'
: 'bg-tint-active'
}`}
>
{message.content}
</div>
</div>
))}
{showTypingIndicator && (
<div className="flex justify-start">
<div className="flex max-w-[80%] items-center gap-2 rounded-lg bg-tint-active px-4 py-2">
<span className="animate-pulse"></span>
<span
className="animate-pulse"
style={{ animationDelay: '0.3s' }}
>
</span>
<span
className="animate-pulse"
style={{ animationDelay: '0.6s' }}
>
</span>
</div>
</div>
)}
</div>
)}
<div className="flex gap-2">
<input
type="text"
className={`w-full rounded-md border border-tint-subtle px-3 py-2 transition-all duration-300 ${!responseId && 'scale-95'}`}
placeholder="Ask about this page"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isAsking || !responseId}
/>
<Button
iconOnly
icon="send"
variant="blank"
disabled={!responseId}
onClick={handleSubmit}
/>
</div>
</div>
)
);
@@ -10,7 +10,7 @@ export function AdaptivePane() {
return (
<div
className={tcls(
'flex shrink-0 flex-col gap-4 overflow-x-hidden rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300',
'flex shrink-0 flex-col gap-4 overflow-hidden rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300',
toggle.open ? 'max-h px-4 py-4 xl:w-72' : 'px-4 py-3 xl:w-56'
)}
>
@@ -51,6 +51,7 @@ export async function streamGenerateObject<T>(
{
schema,
messages,
previousResponseId,
model = AIModel.Fast,
tools = {},
}: {
@@ -64,6 +65,7 @@ export async function streamGenerateObject<T>(
const rawStream = context.dataFetcher.streamAIResponse({
organizationId,
siteId,
previousResponseId,
input: messages,
output: {
type: 'object',
@@ -0,0 +1,77 @@
'use server';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, 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* streamPageQuestion(question: string, responseId: string) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream, response }] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
answer: z.string().describe('The answer to the question'),
}),
previousResponseId: responseId,
tools: {
search: true,
getPageContent: true,
getPages: true,
},
messages: [
{
role: AIMessageRole.Developer,
content:
'The user is asking a question about the page. Use your knowledge of the page and the context to answer the question. Be succinct in your answers, do not repeat information already in the key facts or big picture.',
},
{
role: AIMessageRole.Developer,
content:
'Use the tools available to you to find the answers (read page content, etc).',
},
{
role: AIMessageRole.User,
content: question,
},
],
}
),
fetchServerActionSiteContext(baseContext),
]);
// Get the responseId asynchronously in the background
let newResponseId: string | null = null;
const responseIdPromise = response
.then((r) => {
newResponseId = r.responseId;
})
.catch((error) => {
console.error('Error getting responseId:', error);
});
// Start processing the stream immediately
for await (const value of stream) {
if (!value.answer) continue;
yield {
answer: value.answer,
};
}
// Wait for the responseId to be available and yield one final time
await responseIdPromise;
yield { newResponseId };
}
@@ -21,7 +21,6 @@ export async function* streamPageSummary({
};
currentSpace: {
id: string;
// title: string;
};
visitedPages: {
pageId: string;
@@ -31,7 +30,7 @@ export async function* streamPageSummary({
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }] = await Promise.all([
const [{ stream, response }] = await Promise.all([
streamGenerateObject(
baseContext,
{
@@ -54,10 +53,6 @@ export async function* streamPageSummary({
)
: z.undefined(),
}),
tools: {
// getPages: true,
// getPageContent: true,
},
messages: [
{
role: AIMessageRole.Developer,
@@ -200,6 +195,17 @@ export async function* streamPageSummary({
fetchServerActionSiteContext(baseContext),
]);
// 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);
});
// Start processing the stream immediately
for await (const value of stream) {
const keyFacts = value.keyFacts;
const bigPicture = value.bigPicture;
@@ -211,4 +217,8 @@ export async function* streamPageSummary({
bigPicture,
};
}
// Wait for the responseId to be available and yield one final time
await responseIdPromise;
yield { responseId };
}
@@ -14,6 +14,7 @@ type ButtonProps = {
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
disabled?: boolean;
label?: string;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
@@ -33,10 +34,12 @@ const variantClasses = {
'ring-0',
'shadow-none',
'hover:bg-primary-hover',
'disabled:hover:bg-transparent',
'hover:text-primary',
'hover:scale-1',
'hover:shadow-none',
'contrast-more:bg-tint-subtle',
'disabled:hover:shadow-none',
],
secondary: [
'bg-tint',
@@ -57,6 +60,7 @@ export function Button({
label,
icon,
iconOnly = false,
disabled = false,
...rest
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
@@ -94,6 +98,10 @@ export function Button({
'active:scale-100',
'transition-all',
'disabled:opacity-5',
'disabled:cursor-not-allowed',
'disabled:hover:shadow-none',
'grow-0',
'shrink-0',
'truncate',
@@ -119,7 +127,13 @@ export function Button({
}
return (
<button type="button" className={domClassName} aria-label={label} {...rest}>
<button
type="button"
className={domClassName}
aria-label={label}
disabled={disabled}
{...rest}
>
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</button>