2025-05-14 10:07:31 +02:00
import { ClipboardEvent , useEffect , useMemo , useRef , useState } from 'react' ;
2025-02-10 21:23:17 +01:00
import { CallbackGeneratedChunk , useAppContext } from '../utils/app.context' ;
2025-02-06 17:32:29 +01:00
import ChatMessage from './ChatMessage' ;
2025-02-10 21:23:17 +01:00
import { CanvasType , Message , PendingMessage } from '../utils/types' ;
2025-05-08 15:37:29 +02:00
import { classNames , cleanCurrentUrl } from '../utils/misc' ;
2025-02-08 21:54:50 +01:00
import CanvasPyInterpreter from './CanvasPyInterpreter' ;
2025-02-10 21:23:17 +01:00
import StorageUtils from '../utils/storage' ;
2025-02-19 00:01:44 +02:00
import { useVSCodeContext } from '../utils/llama-vscode' ;
2025-04-08 14:14:59 +05:00
import { useChatTextarea , ChatTextareaApi } from './useChatTextarea.ts' ;
2025-05-08 15:37:29 +02:00
import {
ArrowUpIcon ,
StopIcon ,
PaperClipIcon ,
} from '@heroicons/react/24/solid' ;
import {
ChatExtraContextApi ,
useChatExtraContext ,
} from './useChatExtraContext.tsx' ;
import Dropzone from 'react-dropzone' ;
import toast from 'react-hot-toast' ;
import ChatInputExtraContextItem from './ChatInputExtraContextItem.tsx' ;
import { scrollToBottom , useChatScroll } from './useChatScroll.tsx' ;
2025-02-06 17:32:29 +01:00
2025-02-10 21:23:17 +01:00
/ * *
* A message display is a message node with additional information for rendering .
* For example , siblings of the message node are stored as their last node ( aka leaf node ) .
* /
export interface MessageDisplay {
msg : Message | PendingMessage ;
siblingLeafNodeIds : Message [ 'id' ] [ ] ;
siblingCurrIdx : number ;
isPending? : boolean ;
}
2025-02-06 17:32:29 +01:00
2025-03-03 11:42:45 +01:00
/ * *
* If the current URL contains "?m=..." , prefill the message input with the value .
* If the current URL contains "?q=..." , prefill and SEND the message .
* /
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' ] ) ;
} ,
} ;
2025-02-10 21:23:17 +01:00
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 ) ;
}
// find leaf node from a message node
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 ;
} ;
// traverse the current nodes
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 ;
}
2025-02-06 17:32:29 +01:00
2025-02-10 21:23:17 +01:00
export default function ChatScreen() {
const {
viewingChat ,
sendMessage ,
isGenerating ,
stopGenerating ,
pendingMessages ,
canvasData ,
replaceMessageAndGenerate ,
} = useAppContext ( ) ;
2025-04-08 14:14:59 +05:00
const textarea : ChatTextareaApi = useChatTextarea ( prefilledMsg . content ( ) ) ;
2025-05-08 15:37:29 +02:00
const extraContext = useChatExtraContext ( ) ;
useVSCodeContext ( textarea , extraContext ) ;
2025-02-19 00:01:44 +02:00
2025-05-08 15:37:29 +02:00
const msgListRef = useRef < HTMLDivElement > ( null ) ;
useChatScroll ( msgListRef ) ;
2025-02-10 21:23:17 +01:00
// keep track of leaf node for rendering
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 ? ? '' ] ;
2025-02-06 17:32:29 +01:00
useEffect ( ( ) = > {
2025-02-10 21:23:17 +01:00
// reset to latest node when conversation changes
setCurrNodeId ( - 1 ) ;
// scroll to bottom when conversation changes
scrollToBottom ( false , 1 ) ;
} , [ currConvId ] ) ;
const onChunk : CallbackGeneratedChunk = ( currLeafNodeId? : Message [ 'id' ] ) = > {
if ( currLeafNodeId ) {
setCurrNodeId ( currLeafNodeId ) ;
}
2025-05-08 15:37:29 +02:00
// useChatScroll will handle the auto scroll
2025-02-10 21:23:17 +01:00
} ;
2025-02-06 17:32:29 +01:00
const sendNewMessage = async ( ) = > {
2025-03-20 14:57:43 +00:00
const lastInpMsg = textarea . value ( ) ;
2025-05-08 15:37:29 +02:00
if ( lastInpMsg . trim ( ) . length === 0 || isGenerating ( currConvId ? ? '' ) ) {
toast . error ( 'Please enter a message' ) ;
2025-03-20 14:57:43 +00:00
return ;
2025-05-08 15:37:29 +02:00
}
2025-03-20 14:57:43 +00:00
textarea . setValue ( '' ) ;
2025-02-06 17:32:29 +01:00
scrollToBottom ( false ) ;
2025-02-10 21:23:17 +01:00
setCurrNodeId ( - 1 ) ;
// get the last message node
const lastMsgNodeId = messages . at ( - 1 ) ? . msg . id ? ? null ;
2025-02-19 00:01:44 +02:00
if (
! ( await sendMessage (
currConvId ,
lastMsgNodeId ,
2025-03-20 14:57:43 +00:00
lastInpMsg ,
2025-05-08 15:37:29 +02:00
extraContext . items ,
2025-02-19 00:01:44 +02:00
onChunk
) )
) {
2025-02-06 17:32:29 +01:00
// restore the input message if failed
2025-03-20 14:57:43 +00:00
textarea . setValue ( lastInpMsg ) ;
2025-02-06 17:32:29 +01:00
}
2025-02-19 00:01:44 +02:00
// OK
2025-05-08 15:37:29 +02:00
extraContext . clearItems ( ) ;
2025-02-06 17:32:29 +01:00
} ;
2025-05-05 17:03:31 +03:00
// for vscode context
textarea . refOnSubmit . current = sendNewMessage ;
2025-02-10 21:23:17 +01:00
const handleEditMessage = async ( msg : Message , content : string ) = > {
if ( ! viewingChat ) return ;
setCurrNodeId ( msg . id ) ;
scrollToBottom ( false ) ;
await replaceMessageAndGenerate (
viewingChat . conv . id ,
msg . parent ,
content ,
2025-02-19 00:01:44 +02:00
msg . extra ,
2025-02-10 21:23:17 +01:00
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 ,
2025-02-19 00:01:44 +02:00
msg . extra ,
2025-02-10 21:23:17 +01:00
onChunk
) ;
setCurrNodeId ( - 1 ) ;
scrollToBottom ( false ) ;
} ;
2025-02-08 21:54:50 +01:00
const hasCanvas = ! ! canvasData ;
2025-03-03 11:42:45 +01:00
useEffect ( ( ) = > {
if ( prefilledMsg . shouldSend ( ) ) {
// send the prefilled message if needed
sendNewMessage ( ) ;
} else {
2025-03-20 14:57:43 +00:00
// otherwise, focus on the input
textarea . focus ( ) ;
2025-03-03 11:42:45 +01:00
}
prefilledMsg . clear ( ) ;
// no need to keep track of sendNewMessage
// eslint-disable-next-line react-hooks/exhaustive-deps
2025-03-20 14:57:43 +00:00
} , [ textarea . ref ] ) ;
2025-03-03 11:42:45 +01:00
2025-02-10 21:23:17 +01:00
// due to some timing issues of StorageUtils.appendMsg(), we need to make sure the pendingMsg is not duplicated upon rendering (i.e. appears once in the saved conversation and once in the pendingMsg)
const pendingMsgDisplay : MessageDisplay [ ] =
pendingMsg && messages . at ( - 1 ) ? . msg . id !== pendingMsg . id
? [
{
msg : pendingMsg ,
siblingLeafNodeIds : [ ] ,
siblingCurrIdx : 0 ,
isPending : true ,
} ,
]
: [ ] ;
2025-02-06 17:32:29 +01:00
return (
2025-02-08 21:54:50 +01:00
< 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 ,
} ) }
>
2025-02-06 17:32:29 +01:00
< div
2025-02-08 21:54:50 +01:00
className = { classNames ( {
'flex flex-col w-full max-w-[900px] mx-auto' : true ,
'hidden lg:flex' : hasCanvas , // adapted for mobile
flex : ! hasCanvas ,
} ) }
2025-02-06 17:32:29 +01:00
>
2025-02-08 21:54:50 +01:00
{ /* chat messages */ }
2025-05-08 15:37:29 +02:00
< div id = "messages-list" className = "grow" ref = { msgListRef } >
< div className = "mt-auto flex flex-col items-center" >
2025-02-08 21:54:50 +01:00
{ /* placeholder to shift the message to the bottom */ }
2025-05-08 15:37:29 +02:00
{ viewingChat ? (
''
) : (
< >
< div className = "mb-4" > Send a message to start < / div >
< ServerInfo / >
< / >
) }
2025-02-08 21:54:50 +01:00
< / div >
2025-02-10 21:23:17 +01:00
{ [ . . . messages , . . . pendingMsgDisplay ] . map ( ( msg ) = > (
2025-02-08 21:54:50 +01:00
< ChatMessage
2025-02-10 21:23:17 +01:00
key = { msg . msg . id }
msg = { msg . msg }
siblingLeafNodeIds = { msg . siblingLeafNodeIds }
siblingCurrIdx = { msg . siblingCurrIdx }
onRegenerateMessage = { handleRegenerateMessage }
onEditMessage = { handleEditMessage }
onChangeSibling = { setCurrNodeId }
2025-05-08 15:37:29 +02:00
isPending = { msg . isPending }
2025-02-08 21:54:50 +01:00
/ >
) ) }
2025-02-06 17:32:29 +01:00
< / div >
2025-02-08 21:54:50 +01:00
{ /* chat input */ }
2025-05-08 15:37:29 +02:00
< ChatInput
textarea = { textarea }
extraContext = { extraContext }
onSend = { sendNewMessage }
onStop = { ( ) = > stopGenerating ( currConvId ? ? '' ) }
isGenerating = { isGenerating ( currConvId ? ? '' ) }
/ >
2025-02-06 17:32:29 +01:00
< / div >
2025-02-08 21:54:50 +01:00
< div className = "w-full sticky top-[7em] h-[calc(100vh-9em)]" >
{ canvasData ? . type === CanvasType . PY_INTERPRETER && (
< CanvasPyInterpreter / >
2025-02-06 17:32:29 +01:00
) }
< / div >
2025-02-08 21:54:50 +01:00
< / div >
2025-02-06 17:32:29 +01:00
) ;
}
2025-05-08 15:37:29 +02:00
function ServerInfo() {
const { serverProps } = useAppContext ( ) ;
return (
2025-05-16 21:49:01 +02:00
< div
className = "card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6"
tabIndex = { 0 }
aria - description = "Server information"
>
2025-05-08 15:37:29 +02:00
< div className = "card-body" >
< b > Server Info < / b >
< p >
< b > Model < / b > : { serverProps ? . model_path ? . split ( /(\\|\/)/ ) . pop ( ) }
< br / >
< b > Build < / b > : { serverProps ? . build_info }
< br / >
< / p >
< / div >
< / div >
) ;
}
function ChatInput ( {
textarea ,
extraContext ,
onSend ,
onStop ,
isGenerating ,
} : {
textarea : ChatTextareaApi ;
extraContext : ChatExtraContextApi ;
onSend : ( ) = > void ;
onStop : ( ) = > void ;
isGenerating : boolean ;
} ) {
2025-05-15 14:24:50 +02:00
const { config } = useAppContext ( ) ;
2025-05-08 15:37:29 +02:00
const [ isDrag , setIsDrag ] = useState ( false ) ;
return (
< div
2025-05-16 21:49:01 +02:00
role = "group"
aria - label = "Chat input"
2025-05-08 15:37:29 +02:00
className = { classNames ( {
'flex items-end pt-8 pb-6 sticky bottom-0 bg-base-100' : true ,
'opacity-50' : isDrag , // simply visual feedback to inform user that the file will be accepted
} ) }
>
< Dropzone
noClick
onDrop = { ( files : File [ ] ) = > {
setIsDrag ( false ) ;
extraContext . onFileAdded ( files ) ;
} }
onDragEnter = { ( ) = > setIsDrag ( true ) }
onDragLeave = { ( ) = > setIsDrag ( false ) }
multiple = { true }
>
{ ( { getRootProps , getInputProps } ) = > (
< div
className = "flex flex-col rounded-xl border-1 border-base-content/30 p-3 w-full"
2025-05-15 14:24:50 +02:00
// when a file is pasted to the input, we handle it here
// if a text is pasted, and if it is long text, we will convert it to a file
2025-05-14 10:07:31 +02:00
onPasteCapture = { ( e : ClipboardEvent < HTMLInputElement > ) = > {
2025-05-15 14:24:50 +02:00
const text = e . clipboardData . getData ( 'text/plain' ) ;
if (
text . length > 0 &&
config . pasteLongTextToFileLen > 0 &&
text . length > config . pasteLongTextToFileLen
) {
// if the text is too long, we will convert it to a file
extraContext . addItems ( [
{
type : 'context' ,
name : 'Pasted Content' ,
content : text ,
} ,
] ) ;
e . preventDefault ( ) ;
return ;
}
// if a file is pasted, we will handle it here
2025-05-14 10:07:31 +02:00
const files = Array . from ( e . clipboardData . items )
. filter ( ( item ) = > item . kind === 'file' )
. map ( ( item ) = > item . getAsFile ( ) )
. filter ( ( file ) = > file !== null ) ;
if ( files . length > 0 ) {
e . preventDefault ( ) ;
extraContext . onFileAdded ( files ) ;
}
} }
2025-05-08 15:37:29 +02:00
{ . . . getRootProps ( ) }
>
{ ! isGenerating && (
< ChatInputExtraContextItem
items = { extraContext . items }
removeItem = { extraContext . removeItem }
/ >
) }
< div className = "flex flex-row w-full" >
< textarea
// Default (mobile): Enable vertical resize, overflow auto for scrolling if needed
// Large screens (lg:): Disable manual resize, apply max-height for autosize limit
className = "text-md outline-none border-none w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60)
placeholder = "Type a message (Shift+Enter to add a new line)"
ref = { textarea . ref }
onInput = { textarea . onInput } // Hook's input handler (will only resize height on lg+ screens)
onKeyDown = { ( e ) = > {
if ( e . nativeEvent . isComposing || e . keyCode === 229 ) return ;
if ( e . key === 'Enter' && ! e . shiftKey ) {
e . preventDefault ( ) ;
onSend ( ) ;
}
} }
id = "msg-input"
dir = "auto"
// Set a base height of 2 rows for mobile views
// On lg+ screens, the hook will calculate and set the initial height anyway
rows = { 2 }
> < / textarea >
{ /* buttons area */ }
< div className = "flex flex-row gap-2 ml-2" >
< label
htmlFor = "file-upload"
className = { classNames ( {
'btn w-8 h-8 p-0 rounded-full' : true ,
'btn-disabled' : isGenerating ,
} ) }
2025-05-16 21:49:01 +02:00
aria - label = "Upload file"
tabIndex = { 0 }
role = "button"
2025-05-08 15:37:29 +02:00
>
< PaperClipIcon className = "h-5 w-5" / >
< / label >
< input
id = "file-upload"
type = "file"
disabled = { isGenerating }
{ . . . getInputProps ( ) }
hidden
/ >
{ isGenerating ? (
< button
className = "btn btn-neutral w-8 h-8 p-0 rounded-full"
onClick = { onStop }
>
< StopIcon className = "h-5 w-5" / >
< / button >
) : (
< button
className = "btn btn-primary w-8 h-8 p-0 rounded-full"
onClick = { onSend }
2025-05-16 21:49:01 +02:00
aria - label = "Send message"
2025-05-08 15:37:29 +02:00
>
< ArrowUpIcon className = "h-5 w-5" / >
< / button >
) }
< / div >
< / div >
< / div >
) }
< / Dropzone >
< / div >
) ;
}