Skip to content

Commit

Permalink
Implemented autoScroll on generation for React
Browse files Browse the repository at this point in the history
  • Loading branch information
salmenus committed Apr 24, 2024
1 parent a1ce50e commit e74684d
Show file tree
Hide file tree
Showing 14 changed files with 222 additions and 51 deletions.
31 changes: 29 additions & 2 deletions packages/react/core/src/exports/AiChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,24 @@ import {adapterParamToUsableAdapter} from '../utils/adapterParamToUsableAdapter'
import {chatItemsToChatSegment} from '../utils/chatItemsToChatSegment';
import {reactPropsToCoreProps} from '../utils/reactPropsToCoreProps';
import {useAiChatStyle} from './hooks/useAiChatStyle';
import {useAutoScrollHandler} from './hooks/useAutoScrollHandler';
import {useSubmitPromptHandler} from './hooks/useSubmitPromptHandler';
import {AiChatComponentProps} from './props';

const defaultAutoScrollOption = true;

export const AiChat: <AiMsg>(
props: AiChatComponentProps<AiMsg>,
) => ReactElement = function <AiMsg>(
props: AiChatComponentProps<AiMsg>,
): ReactElement {
const conversationRef = useRef<ImperativeConversationCompProps>(null);
const exceptionBoxRef = useRef<HTMLDivElement>(null);
const conversationContainerRef = useRef<HTMLDivElement>(null);
const autoScrollHandler = useAutoScrollHandler(
conversationContainerRef,
props.conversationOptions?.autoScroll,
);

const exceptionBoxController = useMemo(() => {
return exceptionBoxRef.current ? createExceptionsBoxController(exceptionBoxRef.current) : undefined;
Expand All @@ -44,6 +52,7 @@ export const AiChat: <AiMsg>(
adapterToUse ? {aiChatProps: reactPropsToCoreProps<AiMsg>(props, adapterToUse)} : undefined
), [props, adapterToUse]);

const lastActiveSegmentIdRef = useRef<string | undefined>(undefined);
const hasValidInput = useMemo(() => prompt.length > 0, [prompt]);
const handlePromptChange = useCallback((value: string) => setPrompt(value), [setPrompt]);
const handleSubmitPrompt = useSubmitPromptHandler({
Expand All @@ -62,6 +71,21 @@ export const AiChat: <AiMsg>(
props.initialConversation ? chatItemsToChatSegment(props.initialConversation) : undefined,
), [props.initialConversation]);

const handleLastActiveSegmentChange = useCallback((data: {uid: string; div: HTMLDivElement} | undefined) => {
if (!autoScrollHandler) {
return;
}

if (data) {
lastActiveSegmentIdRef.current = data.uid;
autoScrollHandler.handleNewChatSegmentAdded(data.uid, data.div);
} else {
if (lastActiveSegmentIdRef.current) {
autoScrollHandler.handleChatSegmentRemoved(lastActiveSegmentIdRef.current);
}
}
}, [autoScrollHandler]);

const segments = useMemo(() => (
initialSegment ? [initialSegment, ...chatSegments] : chatSegments
), [initialSegment, chatSegments]);
Expand All @@ -72,20 +96,23 @@ export const AiChat: <AiMsg>(
themeId: props.themeId,
}).join(' ');

const ForwardConversationComp = forwardRef(ConversationComp<AiMsg>);
const ForwardConversationComp = useMemo(() => forwardRef(
ConversationComp<AiMsg>,
), []);

return (
<div className={rootClassNames} style={rootStyle}>
<div className={compExceptionsBoxClassName} ref={exceptionBoxRef}/>
<div className="nlux-chtRm-cntr">
<div className="nlux-chtRm-cnv-cntr">
<div className="nlux-chtRm-cnv-cntr" ref={conversationContainerRef}>
<ForwardConversationComp
ref={conversationRef}
segments={segments}
conversationOptions={props.conversationOptions}
personaOptions={props.personaOptions}
customRenderer={props.aiMessageComponent}
syntaxHighlighter={props.syntaxHighlighter}
onLastActiveSegmentChange={handleLastActiveSegmentChange}
/>
</div>
<div className="nlux-chtRm-prmptBox-cntr">
Expand Down
56 changes: 56 additions & 0 deletions packages/react/core/src/exports/hooks/useAutoScrollHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {MutableRefObject, useEffect, useRef, useState} from 'react';
import {createAutoScrollHandler} from '../../../../../shared/src/interactions/autoScroll/autoScrollHandler';
import {AutoScrollHandler} from '../../../../../shared/src/interactions/autoScroll/type';

const defaultAutoScrollOption = true;

export const useAutoScrollHandler = (
conversationContainerRef: MutableRefObject<HTMLDivElement | null>,
autoScroll?: boolean,
) => {
const [autoScrollHandler, setAutoScrollHandler] = useState<AutoScrollHandler>();
const [conversationContainer, setConversationContainer] = useState<HTMLDivElement>();

const autoScrollHandlerRef = useRef(autoScrollHandler);
const autoScrollPropRef = useRef(autoScroll);

useEffect(() => {
const currentConversationContainer = conversationContainerRef.current || undefined;
if (currentConversationContainer !== conversationContainer) {
setConversationContainer(currentConversationContainer);
}
}); // No dependencies - If statement inside the effect will handle the update

useEffect(() => {
if (!conversationContainer) {
if (autoScrollHandlerRef.current) {
autoScrollHandlerRef.current.destroy();
setAutoScrollHandler(undefined);
autoScrollHandlerRef.current = undefined;
}

return;
}

if (autoScrollHandlerRef.current) {
autoScrollHandlerRef.current.updateConversationContainer(conversationContainer);
} else {
autoScrollHandlerRef.current = createAutoScrollHandler(
conversationContainer,
autoScrollPropRef.current ?? defaultAutoScrollOption,
);
setAutoScrollHandler(autoScrollHandlerRef.current);
}
}, [conversationContainer]);

useEffect(() => {
autoScrollPropRef.current = autoScroll;
if (autoScrollHandlerRef.current) {
autoScrollHandlerRef.current.updateProps({
autoScroll: autoScroll ?? defaultAutoScrollOption,
});
}
}, [autoScroll]);

return autoScrollHandler;
};
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai
} = props;

const hasValidInput = useMemo(() => prompt.length > 0, [prompt]);

// React functions and state that can be accessed by non-React DOM update code
const domToReactRef = useRef({
chatSegments,
setChatSegments,
Expand Down
15 changes: 6 additions & 9 deletions packages/react/core/src/logic/ChatSegment/ChatSegmentComp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const ChatSegmentComp: <AiMsg>(
props: ChatSegmentProps<AiMsg>,
ref: Ref<ChatSegmentImperativeProps<AiMsg>>,
): ReactNode {
const {chatSegment} = props;
const {chatSegment, containerRef} = props;
const chatItemsRef = useMemo(
() => new Map<string, RefObject<ChatItemImperativeProps>>(), [],
);
Expand Down Expand Up @@ -52,9 +52,6 @@ export const ChatSegmentComp: <AiMsg>(
const rootClassName = useMemo(() => getChatSegmentClassName(chatSegment.status), [chatSegment.status]);

useImperativeHandle(ref, () => ({
scrollToBottom: () => {
// TODO - Implement scroll to bottom
},
streamChunk: (messageId: string, chunk: string) => {
const messageCompRef = chatItemsRef.get(messageId);
if (messageCompRef?.current) {
Expand All @@ -63,24 +60,24 @@ export const ChatSegmentComp: <AiMsg>(
},
}), []);

const ForwardRefChatItemComp = useMemo(() => forwardRef(
ChatItemComp<AiMsg>,
), []);

const chatItems = chatSegment.items;
if (chatItems.length === 0) {
return null;
}

return (
<div className={rootClassName}>
<div className={rootClassName} ref={containerRef}>
{chatItems.map((chatItem, index) => {
let ref: RefObject<ChatItemImperativeProps> | undefined = chatItemsRef.get(chatItem.uid);
if (!ref) {
ref = createRef<ChatItemImperativeProps>();
chatItemsRef.set(chatItem.uid, ref);
}

const ForwardRefChatItemComp = forwardRef(
ChatItemComp<AiMsg>,
);

if (chatItem.participantRole === 'user') {
//
// User chat item — That should always be in complete state.
Expand Down
3 changes: 2 additions & 1 deletion packages/react/core/src/logic/ChatSegment/props.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {HighlighterExtension} from '@nlux/core';
import {FunctionComponent, ReactElement} from 'react';
import {FunctionComponent, ReactElement, RefObject} from 'react';
import {ChatSegment} from '../../../../../shared/src/types/chatSegment/chatSegment';
import {PersonaOptions} from '../../exports/personaOptions';

Expand All @@ -9,6 +9,7 @@ export type ChatSegmentProps<AiMsg> = {
loader?: ReactElement;
personaOptions?: PersonaOptions;
syntaxHighlighter?: HighlighterExtension;
containerRef?: RefObject<HTMLDivElement>;
};

export type ChatSegmentImperativeProps<AiMsg> = {
Expand Down
60 changes: 49 additions & 11 deletions packages/react/core/src/logic/Conversation/ConversationComp.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {createRef, forwardRef, ReactNode, Ref, RefObject, useEffect, useImperativeHandle, useMemo} from 'react';
import {createRef, forwardRef, ReactNode, Ref, RefObject, useEffect, useImperativeHandle, useMemo, useRef} from 'react';
import {WelcomeMessageComp} from '../../ui/WelcomeMessage/WelcomeMessageComp';
import {ChatSegmentComp} from '../ChatSegment/ChatSegmentComp';
import {ChatSegmentImperativeProps} from '../ChatSegment/props';
Expand All @@ -13,11 +13,19 @@ export const ConversationComp: ConversationCompType = function <AiMsg>(
props: ConversationCompProps<AiMsg>,
ref: Ref<ImperativeConversationCompProps>,
): ReactNode {
const {segments, personaOptions} = props;
const {
segments,
personaOptions,
onLastActiveSegmentChange,
} = props;

const hasMessages = useMemo(() => segments.some((segment) => segment.items.length > 0), [segments]);
const hasAiPersona = personaOptions?.bot?.name && personaOptions.bot.picture;
const showWelcomeMessage = hasAiPersona && !hasMessages;

const lastSegmentContainerRef = createRef<HTMLDivElement>();
const lastActiveSegmentDataReportedRef = useRef<{uid: string; div: HTMLDivElement} | undefined>(undefined);

const chatSegmentsRef = useMemo(
() => new Map<string, RefObject<ChatSegmentImperativeProps<any>>>(), [],
);
Expand All @@ -37,10 +45,42 @@ export const ConversationComp: ConversationCompType = function <AiMsg>(
}
}, [props.segments]);

const lastActiveSegmentId = useMemo(() => {
const lastSegment = segments.length > 0 ? segments[segments.length - 1] : undefined;
return lastSegment?.status === 'active' ? lastSegment.uid : undefined;
}, [segments]);

const ForwardRefChatSegmentComp = useMemo(() => forwardRef(
ChatSegmentComp<AiMsg>,
), []);

// Whenever the last active segment div+id changes, call the onLastActiveSegmentChange callback
useEffect(() => {
if (!onLastActiveSegmentChange) {
return;
}

const lastReportedData = lastActiveSegmentDataReportedRef.current;
if (lastActiveSegmentId === lastReportedData?.uid
&& lastSegmentContainerRef.current === lastReportedData?.div) {
return;
}

const data = (lastActiveSegmentId && lastSegmentContainerRef.current) ? {
uid: lastActiveSegmentId,
div: lastSegmentContainerRef.current,
} : undefined;

if (!data && !lastActiveSegmentDataReportedRef.current) {
return;
}

onLastActiveSegmentChange(data);
lastActiveSegmentDataReportedRef.current = data;
}); // No dependencies on purpose — we want to run this effect on every render cycle
// 'if' statements inside the effect will prevent unnecessary calls to the callback

useImperativeHandle(ref, () => ({
scrollToBottom: () => {
// TODO - Implement scroll to bottom
},
streamChunk: (segmentId: string, messageId: string, chunk: string) => {
const chatSegmentRef = chatSegmentsRef.get(segmentId);
if (chatSegmentRef?.current) {
Expand All @@ -59,26 +99,24 @@ export const ConversationComp: ConversationCompType = function <AiMsg>(
/>
)}
<div className="nlux-chtRm-cnv-sgmts-cntr">
{segments.map((segment) => {
{segments.map((segment, index) => {
const isLastSegment = index === segments.length - 1;
let ref: RefObject<ChatSegmentImperativeProps<any>> | undefined = chatSegmentsRef.get(segment.uid);
if (!ref) {
ref = createRef();
chatSegmentsRef.set(segment.uid, ref);
}

const ForwardRefChatItemComp = forwardRef(
ChatSegmentComp<AiMsg>,
);

return (
<ForwardRefChatItemComp
<ForwardRefChatSegmentComp
ref={ref}
key={segment.uid}
chatSegment={segment}
personaOptions={personaOptions}
loader={props.loader}
customRenderer={props.customRenderer}
syntaxHighlighter={props.syntaxHighlighter}
containerRef={isLastSegment ? lastSegmentContainerRef : undefined}
/>
);
})}
Expand Down
5 changes: 4 additions & 1 deletion packages/react/core/src/logic/Conversation/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ export type ConversationCompProps<AiMsg> = {
customRenderer?: FunctionComponent<{message: AiMsg}>;
syntaxHighlighter?: HighlighterExtension;
loader?: ReactElement;
onLastActiveSegmentChange?: (data: {
uid: string;
div: HTMLDivElement;
} | undefined) => void;
};

export type ImperativeConversationCompProps = {
scrollToBottom: () => void;
streamChunk: (segmentId: string, messageId: string, chunk: string) => void;
};
4 changes: 2 additions & 2 deletions packages/react/core/src/ui/ChatItem/ChatItemComp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ export const ChatItemComp: <AiMsg>(
return () => <>{props.message !== undefined ? props.message : ''}</>;
}, [props.customRenderer, props.message]);

const ForwardRefStreamContainerComp = forwardRef(
const ForwardRefStreamContainerComp = useMemo(() => forwardRef(
StreamContainerComp,
);
), []);

return (
<div className={className}>
Expand Down
Loading

0 comments on commit e74684d

Please sign in to comment.