Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/embed/src/standalone/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}

@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}

* {
box-sizing: border-box;
}
Expand Down Expand Up @@ -120,7 +130,8 @@
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-solid);
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
border-radius: var(--gitbook-widget-radius);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers());

return (
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
<CustomizationRootLayout
className="site-background"
forcedTheme={forcedTheme}
context={context}
>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';

export const dynamic = 'force-static';

type PageProps = {
params: Promise<RouteLayoutParams>;
};

export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);

return <EmbeddableAssistantPage context={context} />;
export default async function Page() {
return <EmbeddableAssistantPage />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents();

return (
<CustomizationRootLayout context={context}>
<CustomizationRootLayout className="site-background" context={context}>
<SiteLayout
context={context}
withTracking={withTracking}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';

export const dynamic = 'force-static';

type PageProps = {
params: Promise<RouteLayoutParams>;
};

export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);

return <EmbeddableAssistantPage context={context} />;
export default async function Page() {
return <EmbeddableAssistantPage />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ export function AIMessageView(
) {
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;

return (
return message.steps.length > 0 ? (
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
return (
<div
key={index}
className={tcls(
'flex animate-fade-in-slow flex-col gap-2',
'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
Expand All @@ -35,7 +35,7 @@ export function AIMessageView(
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style="mt-2 space-y-4 empty:hidden"
style="mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
/>

{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
Expand All @@ -45,5 +45,5 @@ export function AIMessageView(
);
})}
</div>
);
) : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props;

return (
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
<div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon
icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
Expand Down Expand Up @@ -160,7 +160,7 @@ async function DescriptionForSearchToolCall(props: {
const hasResults = toolCall.results.length > 0;

return (
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<details className={tcls('-ml-5 group flex w-full flex-col')}>
<summary
className={tcls(
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
Expand All @@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
) : null}
</summary>
{hasResults ? (
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
Expand Down
162 changes: 74 additions & 88 deletions packages/gitbook/src/components/AIChat/AIChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
useAI,
useAIChatController,
useAIChatState,
} from '../AI';
Expand All @@ -24,15 +25,15 @@ import {
import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';

export function AIChat(props: { trademark: boolean }) {
const { trademark } = props;

export function AIChat() {
const { config } = useAI();
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
Expand Down Expand Up @@ -70,18 +71,18 @@ export function AIChat(props: { trademark: boolean }) {
<div
data-testid="ai-chat"
className={tcls(
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 depth-flat:lg:p-0 lg:pr-4 lg:pl-0 xl:w-96',
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96',
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
)}
>
<EmbeddableFrame className="relative shrink-0 circular-corners:rounded-3xl rounded-corners:rounded-md border border-tint-subtle depth-subtle:shadow-lg shadow-tint transition-all duration-300 lg:w-76 depth-flat:lg:rounded-none depth-flat:lg:border-y-0 depth-flat:lg:border-r-0 xl:w-92">
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} />
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, trademark)}
{getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
Expand All @@ -98,7 +99,7 @@ export function AIChat(props: { trademark: boolean }) {
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
<AIChatBody chatController={chatController} chat={chat} />
</EmbeddableFrameBody>
</EmbeddableFrame>
</div>
Expand Down Expand Up @@ -145,10 +146,33 @@ export function AIChatSubtitle(props: {
const language = useLanguage();

return (
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
{chat.messages[chat.messages.length - 1]?.content
? tString(language, 'ai_chat_working')
: tString(language, 'ai_chat_thinking')}
<EmbeddableFrameSubtitle
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-slow'
: 'hidden'
: 'animate-blur-out-slow'
)}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-slow'
: 'animate-blur-in-slow'
: 'hidden'
)}
>
{t(language, 'ai_chat_thinking')}
</span>
</EmbeddableFrameSubtitle>
);
}
Expand All @@ -159,20 +183,13 @@ export function AIChatSubtitle(props: {
export function AIChatBody(props: {
chatController: AIChatController;
chat: AIChatState;
trademark: boolean;
welcomeMessage?: string;
suggestions?: string[];
}) {
const { chatController, chat, trademark, suggestions } = props;
const { chatController, chat, suggestions } = props;
const { trademark } = useAI().config;

const [input, setInput] = React.useState('');

const scrollContainerRef = React.useRef<HTMLDivElement>(null);
// Ref for the last user message element
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLDivElement>(null);

const [inputHeight, setInputHeight] = React.useState(0);
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting

Expand All @@ -186,67 +203,42 @@ export function AIChatBody(props: {
return tString(language, 'ai_chat_assistant_greeting_evening');
}, [now, language]);

// Auto-scroll to the latest user message when messages change
React.useEffect(() => {
if (chat.messages.length > 0 && lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [chat.messages.length]);

React.useEffect(() => {
const timeout = setTimeout(() => {
if (lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, 100);

// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
setInputHeight(entry.contentRect.height + 32);
});
});
if (inputRef.current) {
observer.observe(inputRef.current);
}
return () => {
observer.disconnect();
clearTimeout(timeout);
};
}, []);

return (
<>
<div
ref={scrollContainerRef}
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
style={{
paddingBottom: `${inputHeight}px`,
}}
<ScrollContainer
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
fadeEdges={['leading']}
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
>
{isEmpty ? (
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
<div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
<AIChatIcon
state="intro"
trademark={trademark}
className="size-16 animate-[present_500ms_200ms_both]"
/>
</div>
<div className="animate-[fadeIn_500ms_400ms_both]">
<h5 className=" text-center font-bold text-lg text-tint-strong">
{timeGreeting}
</h5>
<p className="text-center text-tint">
{t(language, 'ai_chat_assistant_description')}
</p>
<div className="flex grow flex-col">
<div className="my-auto flex flex-row items-center gap-4 pb-6 [@container(min-height:400px)]:flex-col">
<div
className="flex size-16 shrink-0 animate-scale-in items-center justify-center rounded-full bg-primary-solid/1 [@container(min-height:400px)]:size-32"
style={{ animationDelay: '.3s' }}
>
<AIChatIcon
state="intro"
trademark={trademark}
className="size-8 text-primary [@container(min-height:400px)]:size-16"
/>
</div>
<div className="flex flex-col items-start [@container(min-height:400px)]:items-center">
<h5
className="animate-blur-in-slow font-bold text-lg text-tint-strong [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.5s' }}
>
{timeGreeting}
</h5>
<p
className="animate-blur-in-slow text-tint [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.6s' }}
>
{t(language, 'ai_chat_assistant_description')}
</p>
</div>
</div>
{!chat.error ? (
<AIChatSuggestedQuestions
Expand All @@ -256,17 +248,11 @@ export function AIChatBody(props: {
) : null}
</div>
) : (
<AIChatMessages
chat={chat}
chatController={chatController}
lastUserMessageRef={lastUserMessageRef}
/>
<AIChatMessages chat={chat} chatController={chatController} />
)}
</div>
<div
ref={inputRef}
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
>
</ScrollContainer>

<div className="flex flex-col gap-2 px-4 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}

Expand Down
2 changes: 1 addition & 1 deletion packages/gitbook/src/components/AIChat/AIChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function AIChatInput(props: {
);

return (
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex animate-blur-in-slow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
<textarea
ref={inputRef}
disabled={disabled || loading}
Expand Down
Loading
Loading