Create Chat component

This commit is contained in:
Zeno Kapitein
2025-05-12 15:23:52 +02:00
parent 34dceb2f97
commit 31bfe77f74
4 changed files with 356 additions and 12 deletions
@@ -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;
}
}
@@ -0,0 +1,67 @@
'use client';
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';
export function SearchChat() {
// const currentPage = usePageContext();
// const language = useLanguage();
const visitedPages = useVisitedPages((state) => state.pages);
const [summary, setSummary] = useState('');
const [responseId, setResponseId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const stream = await streamAISearchSummary({
visitedPages,
});
let generatedSummary = '';
for await (const data of stream) {
if (cancelled) return;
if ('responseId' in data && data.responseId !== undefined) {
setResponseId(data.responseId);
}
if ('summary' in data && data.summary !== undefined) {
generatedSummary = data.summary;
setSummary(generatedSummary);
}
}
})();
return () => {
cancelled = true;
};
}, [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>
{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>
)}
</motion.div>
);
}
@@ -9,8 +9,8 @@ import { tcls } from '@/lib/tailwind';
import { Button } from '../primitives/Button';
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';
@@ -317,7 +317,7 @@ function SearchModalBody(
key="chat"
layout
className={tcls(
'-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 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',
state.mode === 'chat' && 'md:col-start-1'
)}
initial={{ width: 0 }}
@@ -338,7 +338,7 @@ function SearchModalBody(
}}
/>
) : null}
<SearchAskAnswer query={normalizedQuery} />
<SearchChat />
</motion.div>
) : null}
</AnimatePresence>
@@ -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,111 @@ 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,
}: {
question: 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({
answer: z.string().describe('The answer to the question.'),
}),
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.`,
},
{
role: AIMessageRole.User,
content: `Question: ${question}`,
},
].filter(filterOutNullable),
}
);
for await (const value of stream) {
const highlight = value.answer;
if (!highlight) {
continue;
}
yield highlight;
}
}