-
Notifications
You must be signed in to change notification settings - Fork 4k
AI Page Journeys #3131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
AI Page Journeys #3131
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"gitbook": minor | ||
--- | ||
|
||
Adds AI sidebar with recommendations based on browsing behaviour |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
'use client'; | ||
import { tcls } from '@/lib/tailwind'; | ||
import { Icon, type IconName } from '@gitbook/icons'; | ||
import { AnimatePresence, motion } from 'framer-motion'; | ||
import Link from 'next/link'; | ||
import { useEffect, useState } from 'react'; | ||
import { useVisitedPages } from '../Insights'; | ||
import { usePageContext } from '../PageContext'; | ||
import { Emoji } from '../primitives'; | ||
import { type SuggestedPage, useAdaptiveContext } from './AdaptiveContext'; | ||
import { streamNextPageSuggestions } from './server-actions/streamNextPageSuggestions'; | ||
|
||
export function AINextPageSuggestions() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add a quick one liner comment? |
||
const { selectedJourney, open } = useAdaptiveContext(); | ||
|
||
const currentPage = usePageContext(); | ||
const visitedPages = useVisitedPages((state) => state.pages); | ||
|
||
const [pages, setPages] = useState<SuggestedPage[]>(selectedJourney?.pages ?? []); | ||
const [suggestedPages, setSuggestedPages] = useState<SuggestedPage[]>([]); | ||
|
||
useEffect(() => { | ||
let canceled = false; | ||
|
||
if (selectedJourney?.pages && selectedJourney.pages.length > 0) { | ||
setPages(selectedJourney.pages); | ||
} else { | ||
setPages(suggestedPages); | ||
} | ||
|
||
if (suggestedPages.length === 0) { | ||
(async () => { | ||
const stream = await streamNextPageSuggestions({ | ||
currentPage: { | ||
id: currentPage.pageId, | ||
title: currentPage.title, | ||
}, | ||
currentSpace: { | ||
id: currentPage.spaceId, | ||
}, | ||
visitedPages: visitedPages, | ||
}); | ||
|
||
for await (const page of stream) { | ||
if (canceled) return; | ||
|
||
setPages((prev) => [...prev, page]); | ||
setSuggestedPages((prev) => [...prev, page]); | ||
} | ||
})(); | ||
} | ||
|
||
return () => { | ||
canceled = true; | ||
}; | ||
}, [ | ||
selectedJourney, | ||
currentPage.pageId, | ||
currentPage.spaceId, | ||
currentPage.title, | ||
visitedPages, | ||
suggestedPages, | ||
]); | ||
|
||
return ( | ||
open && ( | ||
<div className="animate-fadeIn"> | ||
<motion.div className="mb-2 flex flex-row items-start gap-3"> | ||
<AnimatePresence mode="wait"> | ||
{selectedJourney?.icon ? ( | ||
<motion.div | ||
initial={{ opacity: 0, scale: 0.8 }} | ||
animate={{ opacity: 1, scale: 1 }} | ||
exit={{ opacity: 0, scale: 0.8 }} | ||
transition={{ delay: 0.2 }} | ||
key={selectedJourney.icon} | ||
> | ||
<Icon | ||
icon={selectedJourney.icon as IconName} | ||
className="left-0 mt-2 size-6 shrink-0 text-tint-subtle" | ||
/> | ||
</motion.div> | ||
) : null} | ||
</AnimatePresence> | ||
<motion.div className={tcls('flex flex-col')} layout="position"> | ||
<div className="font-semibold text-tint text-xs uppercase tracking-wide"> | ||
Suggested pages | ||
</div> | ||
<AnimatePresence mode="wait"> | ||
{selectedJourney?.label ? ( | ||
<motion.h5 | ||
initial={{ opacity: 0 }} | ||
animate={{ opacity: 1 }} | ||
exit={{ opacity: 0 }} | ||
key={selectedJourney.label} | ||
className="font-semibold text-base" | ||
> | ||
{selectedJourney.label} | ||
</motion.h5> | ||
) : null} | ||
</AnimatePresence> | ||
</motion.div> | ||
</motion.div> | ||
<div className="-mb-1.5 flex flex-col gap-1"> | ||
{Object.assign(Array.from({ length: 5 }), pages).map( | ||
(page: SuggestedPage | undefined, index) => | ||
page ? ( | ||
<Link | ||
key={`${selectedJourney?.label}-${page.id}`} | ||
className="-mx-2 flex animate-fadeIn gap-2 rounded px-2.5 py-1 transition-all hover:bg-tint-hover hover:text-tint-strong" | ||
href={page.href} | ||
style={{ animationDelay: `${0.2 + index * 0.05}s` }} | ||
> | ||
{page.icon ? ( | ||
<Icon | ||
icon={page.icon as IconName} | ||
className="mt-0.5 size-4 shrink-0 text-tint-subtle" | ||
/> | ||
) : null} | ||
{page.emoji ? <Emoji code={page.emoji} /> : null} | ||
{page.title} | ||
</Link> | ||
) : ( | ||
<div | ||
key={index} | ||
className="my-1 h-5 animate-pulse rounded bg-tint-hover" | ||
style={{ | ||
animationDelay: `${index * 0.2}s`, | ||
width: `${((index * 17) % 50) + 50}%`, | ||
}} | ||
/> | ||
) | ||
)} | ||
</div> | ||
</div> | ||
) | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
'use client'; | ||
import { tcls } from '@/lib/tailwind'; | ||
import { Icon, type IconName } from '@gitbook/icons'; | ||
import { JOURNEY_COUNT, type Journey, useAdaptiveContext } from './AdaptiveContext'; | ||
|
||
export function AIPageJourneySuggestions() { | ||
const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext(); | ||
|
||
return ( | ||
open && ( | ||
<div className="animate-fadeIn"> | ||
<div className="mb-2 flex flex-row items-center gap-1 font-semibold text-tint text-xs uppercase tracking-wide"> | ||
More to explore | ||
</div> | ||
<div className="grid grid-cols-2 gap-2"> | ||
{Object.assign(Array.from({ length: JOURNEY_COUNT }), journeys).map( | ||
(journey: Journey | undefined, index) => { | ||
const isSelected = | ||
journey?.label && journey.label === selectedJourney?.label; | ||
const isLoading = !journey || journey?.label === undefined; | ||
return ( | ||
<button | ||
type="button" | ||
key={index} | ||
disabled={journey?.label === undefined} | ||
className={tcls( | ||
'flex flex-col items-center justify-center gap-2 rounded bg-tint px-2 py-4 text-center ring-1 ring-tint-subtle ring-inset transition-all', | ||
isLoading | ||
? 'h-24 scale-90 animate-pulse' | ||
: 'hover:bg-tint-hover hover:text-tint-strong hover:ring-tint active:scale-95', | ||
isSelected && | ||
'bg-primary-active text-primary-strong ring-2 ring-primary hover:bg-primary-active hover:ring-primary' | ||
)} | ||
style={{ | ||
animationDelay: `${index * 0.2}s`, | ||
}} | ||
onClick={() => | ||
setSelectedJourney(isSelected ? undefined : journey) | ||
} | ||
> | ||
{journey?.icon ? ( | ||
<Icon | ||
icon={journey.icon as IconName} | ||
className="size-4 animate-fadeIn text-tint-subtle [animation-delay:300ms]" | ||
/> | ||
) : null} | ||
{journey?.label ? ( | ||
<span className="animate-fadeIn leading-tight [animation-delay:400ms]"> | ||
{journey.label} | ||
</span> | ||
) : null} | ||
</button> | ||
); | ||
} | ||
)} | ||
</div> | ||
</div> | ||
) | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
'use client'; | ||
|
||
import React, { useEffect } from 'react'; | ||
import { useVisitedPages } from '../Insights'; | ||
import { usePageContext } from '../PageContext'; | ||
import { streamPageJourneySuggestions } from './server-actions'; | ||
|
||
export type SuggestedPage = { | ||
id: string; | ||
title: string; | ||
href: string; | ||
icon?: string; | ||
emoji?: string; | ||
}; | ||
|
||
export type Journey = { | ||
label: string; | ||
icon?: string; | ||
pages?: Array<SuggestedPage>; | ||
}; | ||
|
||
type AdaptiveContextType = { | ||
journeys: Journey[]; | ||
selectedJourney: Journey | undefined; | ||
setSelectedJourney: (journey: Journey | undefined) => void; | ||
loading: boolean; | ||
open: boolean; | ||
setOpen: (open: boolean) => void; | ||
}; | ||
|
||
export const AdaptiveContext = React.createContext<AdaptiveContextType | null>(null); | ||
|
||
export const JOURNEY_COUNT = 4; | ||
|
||
/** | ||
* Client side context provider to pass information about the current page. | ||
*/ | ||
export function JourneyContextProvider({ | ||
children, | ||
spaces, | ||
}: { children: React.ReactNode; spaces: { id: string; title: string }[] }) { | ||
const [journeys, setJourneys] = React.useState<Journey[]>([]); | ||
const [selectedJourney, setSelectedJourney] = React.useState<Journey | undefined>(undefined); | ||
const [loading, setLoading] = React.useState(true); | ||
const [open, setOpen] = React.useState(true); | ||
|
||
const currentPage = usePageContext(); | ||
const visitedPages = useVisitedPages((state) => state.pages); | ||
|
||
useEffect(() => { | ||
let canceled = false; | ||
|
||
setJourneys([]); | ||
|
||
(async () => { | ||
const stream = await streamPageJourneySuggestions({ | ||
count: JOURNEY_COUNT, | ||
currentPage: { | ||
id: currentPage.pageId, | ||
title: currentPage.title, | ||
}, | ||
currentSpace: { | ||
id: currentPage.spaceId, | ||
}, | ||
allSpaces: spaces, | ||
visitedPages, | ||
}); | ||
|
||
for await (const journey of stream) { | ||
if (canceled) return; | ||
|
||
setJourneys((prev) => [...prev, journey]); | ||
} | ||
|
||
setLoading(false); | ||
})(); | ||
|
||
return () => { | ||
canceled = true; | ||
}; | ||
}, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]); | ||
Comment on lines
+55
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we using this Provider anywhere else apart from |
||
|
||
return ( | ||
<AdaptiveContext.Provider | ||
value={{ journeys, selectedJourney, setSelectedJourney, loading, open, setOpen }} | ||
Comment on lines
+84
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's pull out the value in an |
||
> | ||
{children} | ||
</AdaptiveContext.Provider> | ||
); | ||
} | ||
|
||
/** | ||
* Hook to use the adaptive context. | ||
*/ | ||
export function useAdaptiveContext() { | ||
const context = React.useContext(AdaptiveContext); | ||
if (!context) { | ||
throw new Error('useAdaptiveContext must be used within a AdaptiveContextProvider'); | ||
} | ||
return context; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
'use client'; | ||
|
||
import { tcls } from '@/lib/tailwind'; | ||
import { AINextPageSuggestions } from './AINextPageSuggestions'; | ||
import { AIPageJourneySuggestions } from './AIPageJourneySuggestions'; | ||
import { useAdaptiveContext } from './AdaptiveContext'; | ||
import { AdaptivePaneHeader } from './AdaptivePaneHeader'; | ||
export function AdaptivePane() { | ||
const { open } = useAdaptiveContext(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. okay we are. let's call this hook |
||
|
||
return ( | ||
<div | ||
className={tcls( | ||
'flex flex-col gap-4 rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300', | ||
open ? 'w-72 px-4 py-4' : 'w-56 px-4 py-3' | ||
)} | ||
> | ||
<AdaptivePaneHeader /> | ||
<AIPageJourneySuggestions /> | ||
<AINextPageSuggestions /> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
'use client'; | ||
|
||
import { tcls } from '@/lib/tailwind'; | ||
import { AnimatePresence, motion } from 'framer-motion'; | ||
import { Button, Loading } from '../primitives'; | ||
import { useAdaptiveContext } from './AdaptiveContext'; | ||
|
||
export function AdaptivePaneHeader() { | ||
const { loading, open, setOpen } = useAdaptiveContext(); | ||
|
||
return ( | ||
<div className="flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500"> | ||
<div className="flex grow flex-col"> | ||
<h4 className="flex items-center gap-1.5 font-semibold "> | ||
<Loading className="size-4 text-tint-subtle" busy={loading} /> | ||
For you | ||
</h4> | ||
<AnimatePresence initial={false} mode="wait"> | ||
<motion.h5 | ||
key={loading ? 'loading' : 'loaded'} | ||
initial={{ opacity: 0 }} | ||
animate={{ opacity: 1 }} | ||
exit={{ opacity: 0 }} | ||
className="text-tint-subtle text-xs" | ||
> | ||
{loading ? 'Basing on your context...' : 'Based on your context'} | ||
</motion.h5> | ||
</AnimatePresence> | ||
</div> | ||
<Button | ||
variant="blank" | ||
className={tcls('px-2 *:transition-transform', !open && '*:-rotate-45')} | ||
iconOnly | ||
label="Close" | ||
icon="close" | ||
onClick={() => setOpen(!open)} | ||
/> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
export * from './AIPageLinkSummary'; | ||
export * from './AdaptiveContext'; | ||
export * from './AdaptivePane'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lets export it from
server-actions
so we don't have to do nested imports like this