| import { useEffect, useMemo, useRef, useState } from 'react'; |
| import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context'; |
| import ChatMessage from './ChatMessage'; |
| import { CanvasType, Message, PendingMessage } from '../utils/types'; |
| import { classNames, cleanCurrentUrl, throttle } from '../utils/misc'; |
| import CanvasPyInterpreter from './CanvasPyInterpreter'; |
| import StorageUtils from '../utils/storage'; |
| import { useVSCodeContext } from '../utils/llama-vscode'; |
|
|
| |
| |
| |
| |
| export interface MessageDisplay { |
| msg: Message | PendingMessage; |
| siblingLeafNodeIds: Message['id'][]; |
| siblingCurrIdx: number; |
| isPending?: boolean; |
| } |
|
|
| |
| |
| |
| |
| const prefilledMsg = { |
| content() { |
| const url = new URL(window.location.href); |
| return url.searchParams.get('m') ?? url.searchParams.get('q') ?? ''; |
| }, |
| shouldSend() { |
| const url = new URL(window.location.href); |
| return url.searchParams.has('q'); |
| }, |
| clear() { |
| cleanCurrentUrl(['m', 'q']); |
| }, |
| }; |
|
|
| function getListMessageDisplay( |
| msgs: Readonly<Message[]>, |
| leafNodeId: Message['id'] |
| ): MessageDisplay[] { |
| const currNodes = StorageUtils.filterByLeafNodeId(msgs, leafNodeId, true); |
| const res: MessageDisplay[] = []; |
| const nodeMap = new Map<Message['id'], Message>(); |
| for (const msg of msgs) { |
| nodeMap.set(msg.id, msg); |
| } |
| |
| const findLeafNode = (msgId: Message['id']): Message['id'] => { |
| let currNode: Message | undefined = nodeMap.get(msgId); |
| while (currNode) { |
| if (currNode.children.length === 0) break; |
| currNode = nodeMap.get(currNode.children.at(-1) ?? -1); |
| } |
| return currNode?.id ?? -1; |
| }; |
| |
| for (const msg of currNodes) { |
| const parentNode = nodeMap.get(msg.parent ?? -1); |
| if (!parentNode) continue; |
| const siblings = parentNode.children; |
| if (msg.type !== 'root') { |
| res.push({ |
| msg, |
| siblingLeafNodeIds: siblings.map(findLeafNode), |
| siblingCurrIdx: siblings.indexOf(msg.id), |
| }); |
| } |
| } |
| return res; |
| } |
|
|
| const scrollToBottom = throttle( |
| (requiresNearBottom: boolean, delay: number = 80) => { |
| const mainScrollElem = document.getElementById('main-scroll'); |
| if (!mainScrollElem) return; |
| const spaceToBottom = |
| mainScrollElem.scrollHeight - |
| mainScrollElem.scrollTop - |
| mainScrollElem.clientHeight; |
| if (!requiresNearBottom || spaceToBottom < 50) { |
| setTimeout( |
| () => mainScrollElem.scrollTo({ top: mainScrollElem.scrollHeight }), |
| delay |
| ); |
| } |
| }, |
| 80 |
| ); |
|
|
| export default function ChatScreen() { |
| const { |
| viewingChat, |
| sendMessage, |
| isGenerating, |
| stopGenerating, |
| pendingMessages, |
| canvasData, |
| replaceMessageAndGenerate, |
| } = useAppContext(); |
| const [inputMsg, setInputMsg] = useState(prefilledMsg.content()); |
| const inputRef = useRef<HTMLTextAreaElement>(null); |
|
|
| const { extraContext, clearExtraContext } = useVSCodeContext( |
| inputRef, |
| setInputMsg |
| ); |
| |
| const currExtra: Message['extra'] = extraContext ? [extraContext] : undefined; |
|
|
| |
| const [currNodeId, setCurrNodeId] = useState<number>(-1); |
| const messages: MessageDisplay[] = useMemo(() => { |
| if (!viewingChat) return []; |
| else return getListMessageDisplay(viewingChat.messages, currNodeId); |
| }, [currNodeId, viewingChat]); |
|
|
| const currConvId = viewingChat?.conv.id ?? null; |
| const pendingMsg: PendingMessage | undefined = |
| pendingMessages[currConvId ?? '']; |
|
|
| useEffect(() => { |
| |
| setCurrNodeId(-1); |
| |
| scrollToBottom(false, 1); |
| }, [currConvId]); |
|
|
| const onChunk: CallbackGeneratedChunk = (currLeafNodeId?: Message['id']) => { |
| if (currLeafNodeId) { |
| setCurrNodeId(currLeafNodeId); |
| } |
| scrollToBottom(true); |
| }; |
|
|
| const sendNewMessage = async () => { |
| if (inputMsg.trim().length === 0 || isGenerating(currConvId ?? '')) return; |
| const lastInpMsg = inputMsg; |
| setInputMsg(''); |
| scrollToBottom(false); |
| setCurrNodeId(-1); |
| |
| const lastMsgNodeId = messages.at(-1)?.msg.id ?? null; |
| if ( |
| !(await sendMessage( |
| currConvId, |
| lastMsgNodeId, |
| inputMsg, |
| currExtra, |
| onChunk |
| )) |
| ) { |
| |
| setInputMsg(lastInpMsg); |
| } |
| |
| clearExtraContext(); |
| }; |
|
|
| const handleEditMessage = async (msg: Message, content: string) => { |
| if (!viewingChat) return; |
| setCurrNodeId(msg.id); |
| scrollToBottom(false); |
| await replaceMessageAndGenerate( |
| viewingChat.conv.id, |
| msg.parent, |
| content, |
| msg.extra, |
| onChunk |
| ); |
| setCurrNodeId(-1); |
| scrollToBottom(false); |
| }; |
|
|
| const handleRegenerateMessage = async (msg: Message) => { |
| if (!viewingChat) return; |
| setCurrNodeId(msg.parent); |
| scrollToBottom(false); |
| await replaceMessageAndGenerate( |
| viewingChat.conv.id, |
| msg.parent, |
| null, |
| msg.extra, |
| onChunk |
| ); |
| setCurrNodeId(-1); |
| scrollToBottom(false); |
| }; |
|
|
| const hasCanvas = !!canvasData; |
|
|
| useEffect(() => { |
| if (prefilledMsg.shouldSend()) { |
| |
| sendNewMessage(); |
| } else { |
| |
| if (inputRef.current) { |
| inputRef.current.focus(); |
| inputRef.current.selectionStart = inputRef.current.value.length; |
| } |
| } |
| prefilledMsg.clear(); |
| |
| |
| }, [inputRef]); |
|
|
| |
| const pendingMsgDisplay: MessageDisplay[] = |
| pendingMsg && messages.at(-1)?.msg.id !== pendingMsg.id |
| ? [ |
| { |
| msg: pendingMsg, |
| siblingLeafNodeIds: [], |
| siblingCurrIdx: 0, |
| isPending: true, |
| }, |
| ] |
| : []; |
|
|
| return ( |
| <div |
| className={classNames({ |
| 'grid lg:gap-8 grow transition-[300ms]': true, |
| 'grid-cols-[1fr_0fr] lg:grid-cols-[1fr_1fr]': hasCanvas, // adapted for mobile |
| 'grid-cols-[1fr_0fr]': !hasCanvas, |
| })} |
| > |
| <div |
| className={classNames({ |
| 'flex flex-col w-full max-w-[900px] mx-auto': true, |
| 'hidden lg:flex': hasCanvas, // adapted for mobile |
| flex: !hasCanvas, |
| })} |
| > |
| {/* chat messages */} |
| <div id="messages-list" className="grow"> |
| <div className="mt-auto flex justify-center"> |
| {/* placeholder to shift the message to the bottom */} |
| {viewingChat ? '' : 'Send a message to start'} |
| </div> |
| {[...messages, ...pendingMsgDisplay].map((msg) => ( |
| <ChatMessage |
| key={msg.msg.id} |
| msg={msg.msg} |
| siblingLeafNodeIds={msg.siblingLeafNodeIds} |
| siblingCurrIdx={msg.siblingCurrIdx} |
| onRegenerateMessage={handleRegenerateMessage} |
| onEditMessage={handleEditMessage} |
| onChangeSibling={setCurrNodeId} |
| /> |
| ))} |
| </div> |
| |
| {/* chat input */} |
| <div className="flex flex-row items-center pt-8 pb-6 sticky bottom-0 bg-base-100"> |
| <textarea |
| className="textarea textarea-bordered w-full" |
| placeholder="Type a message (Shift+Enter to add a new line)" |
| ref={inputRef} |
| value={inputMsg} |
| onChange={(e) => setInputMsg(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.nativeEvent.isComposing || e.keyCode === 229) return; |
| if (e.key === 'Enter' && e.shiftKey) return; |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| sendNewMessage(); |
| } |
| }} |
| id="msg-input" |
| dir="auto" |
| ></textarea> |
| {isGenerating(currConvId ?? '') ? ( |
| <button |
| className="btn btn-neutral ml-2" |
| onClick={() => stopGenerating(currConvId ?? '')} |
| > |
| Stop |
| </button> |
| ) : ( |
| <button |
| className="btn btn-primary ml-2" |
| onClick={sendNewMessage} |
| disabled={inputMsg.trim().length === 0} |
| > |
| Send |
| </button> |
| )} |
| </div> |
| </div> |
| <div className="w-full sticky top-[7em] h-[calc(100vh-9em)]"> |
| {canvasData?.type === CanvasType.PY_INTERPRETER && ( |
| <CanvasPyInterpreter /> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|