|
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> |
|
); |
|
} |
|
|