|
@@ -1,12 +1,25 @@
|
|
|
-import { useEffect, useMemo, useState } from 'react';
|
|
|
|
|
|
|
+import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
|
|
import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
|
|
|
import ChatMessage from './ChatMessage';
|
|
import ChatMessage from './ChatMessage';
|
|
|
import { CanvasType, Message, PendingMessage } from '../utils/types';
|
|
import { CanvasType, Message, PendingMessage } from '../utils/types';
|
|
|
-import { classNames, cleanCurrentUrl, throttle } from '../utils/misc';
|
|
|
|
|
|
|
+import { classNames, cleanCurrentUrl } from '../utils/misc';
|
|
|
import CanvasPyInterpreter from './CanvasPyInterpreter';
|
|
import CanvasPyInterpreter from './CanvasPyInterpreter';
|
|
|
import StorageUtils from '../utils/storage';
|
|
import StorageUtils from '../utils/storage';
|
|
|
import { useVSCodeContext } from '../utils/llama-vscode';
|
|
import { useVSCodeContext } from '../utils/llama-vscode';
|
|
|
import { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts';
|
|
import { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts';
|
|
|
|
|
+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';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* A message display is a message node with additional information for rendering.
|
|
* A message display is a message node with additional information for rendering.
|
|
@@ -72,24 +85,6 @@ function getListMessageDisplay(
|
|
|
return res;
|
|
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() {
|
|
export default function ChatScreen() {
|
|
|
const {
|
|
const {
|
|
|
viewingChat,
|
|
viewingChat,
|
|
@@ -102,10 +97,11 @@ export default function ChatScreen() {
|
|
|
} = useAppContext();
|
|
} = useAppContext();
|
|
|
|
|
|
|
|
const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content());
|
|
const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content());
|
|
|
|
|
+ const extraContext = useChatExtraContext();
|
|
|
|
|
+ useVSCodeContext(textarea, extraContext);
|
|
|
|
|
|
|
|
- const { extraContext, clearExtraContext } = useVSCodeContext(textarea);
|
|
|
|
|
- // TODO: improve this when we have "upload file" feature
|
|
|
|
|
- const currExtra: Message['extra'] = extraContext ? [extraContext] : undefined;
|
|
|
|
|
|
|
+ const msgListRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+ useChatScroll(msgListRef);
|
|
|
|
|
|
|
|
// keep track of leaf node for rendering
|
|
// keep track of leaf node for rendering
|
|
|
const [currNodeId, setCurrNodeId] = useState<number>(-1);
|
|
const [currNodeId, setCurrNodeId] = useState<number>(-1);
|
|
@@ -129,13 +125,15 @@ export default function ChatScreen() {
|
|
|
if (currLeafNodeId) {
|
|
if (currLeafNodeId) {
|
|
|
setCurrNodeId(currLeafNodeId);
|
|
setCurrNodeId(currLeafNodeId);
|
|
|
}
|
|
}
|
|
|
- scrollToBottom(true);
|
|
|
|
|
|
|
+ // useChatScroll will handle the auto scroll
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const sendNewMessage = async () => {
|
|
const sendNewMessage = async () => {
|
|
|
const lastInpMsg = textarea.value();
|
|
const lastInpMsg = textarea.value();
|
|
|
- if (lastInpMsg.trim().length === 0 || isGenerating(currConvId ?? ''))
|
|
|
|
|
|
|
+ if (lastInpMsg.trim().length === 0 || isGenerating(currConvId ?? '')) {
|
|
|
|
|
+ toast.error('Please enter a message');
|
|
|
return;
|
|
return;
|
|
|
|
|
+ }
|
|
|
textarea.setValue('');
|
|
textarea.setValue('');
|
|
|
scrollToBottom(false);
|
|
scrollToBottom(false);
|
|
|
setCurrNodeId(-1);
|
|
setCurrNodeId(-1);
|
|
@@ -146,7 +144,7 @@ export default function ChatScreen() {
|
|
|
currConvId,
|
|
currConvId,
|
|
|
lastMsgNodeId,
|
|
lastMsgNodeId,
|
|
|
lastInpMsg,
|
|
lastInpMsg,
|
|
|
- currExtra,
|
|
|
|
|
|
|
+ extraContext.items,
|
|
|
onChunk
|
|
onChunk
|
|
|
))
|
|
))
|
|
|
) {
|
|
) {
|
|
@@ -154,7 +152,7 @@ export default function ChatScreen() {
|
|
|
textarea.setValue(lastInpMsg);
|
|
textarea.setValue(lastInpMsg);
|
|
|
}
|
|
}
|
|
|
// OK
|
|
// OK
|
|
|
- clearExtraContext();
|
|
|
|
|
|
|
+ extraContext.clearItems();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// for vscode context
|
|
// for vscode context
|
|
@@ -234,10 +232,17 @@ export default function ChatScreen() {
|
|
|
})}
|
|
})}
|
|
|
>
|
|
>
|
|
|
{/* chat messages */}
|
|
{/* chat messages */}
|
|
|
- <div id="messages-list" className="grow">
|
|
|
|
|
- <div className="mt-auto flex justify-center">
|
|
|
|
|
|
|
+ <div id="messages-list" className="grow" ref={msgListRef}>
|
|
|
|
|
+ <div className="mt-auto flex flex-col items-center">
|
|
|
{/* placeholder to shift the message to the bottom */}
|
|
{/* placeholder to shift the message to the bottom */}
|
|
|
- {viewingChat ? '' : 'Send a message to start'}
|
|
|
|
|
|
|
+ {viewingChat ? (
|
|
|
|
|
+ ''
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="mb-4">Send a message to start</div>
|
|
|
|
|
+ <ServerInfo />
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
{[...messages, ...pendingMsgDisplay].map((msg) => (
|
|
{[...messages, ...pendingMsgDisplay].map((msg) => (
|
|
|
<ChatMessage
|
|
<ChatMessage
|
|
@@ -248,46 +253,19 @@ export default function ChatScreen() {
|
|
|
onRegenerateMessage={handleRegenerateMessage}
|
|
onRegenerateMessage={handleRegenerateMessage}
|
|
|
onEditMessage={handleEditMessage}
|
|
onEditMessage={handleEditMessage}
|
|
|
onChangeSibling={setCurrNodeId}
|
|
onChangeSibling={setCurrNodeId}
|
|
|
|
|
+ isPending={msg.isPending}
|
|
|
/>
|
|
/>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* chat input */}
|
|
{/* chat input */}
|
|
|
- <div className="flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100">
|
|
|
|
|
- <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="textarea textarea-bordered 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();
|
|
|
|
|
- sendNewMessage();
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
- 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>
|
|
|
|
|
-
|
|
|
|
|
- {isGenerating(currConvId ?? '') ? (
|
|
|
|
|
- <button
|
|
|
|
|
- className="btn btn-neutral ml-2"
|
|
|
|
|
- onClick={() => stopGenerating(currConvId ?? '')}
|
|
|
|
|
- >
|
|
|
|
|
- Stop
|
|
|
|
|
- </button>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <button className="btn btn-primary ml-2" onClick={sendNewMessage}>
|
|
|
|
|
- Send
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <ChatInput
|
|
|
|
|
+ textarea={textarea}
|
|
|
|
|
+ extraContext={extraContext}
|
|
|
|
|
+ onSend={sendNewMessage}
|
|
|
|
|
+ onStop={() => stopGenerating(currConvId ?? '')}
|
|
|
|
|
+ isGenerating={isGenerating(currConvId ?? '')}
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
|
|
<div className="w-full sticky top-[7em] h-[calc(100vh-9em)]">
|
|
|
{canvasData?.type === CanvasType.PY_INTERPRETER && (
|
|
{canvasData?.type === CanvasType.PY_INTERPRETER && (
|
|
@@ -297,3 +275,129 @@ export default function ChatScreen() {
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+function ServerInfo() {
|
|
|
|
|
+ const { serverProps } = useAppContext();
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="card card-sm shadow-sm border-1 border-base-content/20 text-base-content/70 mb-6">
|
|
|
|
|
+ <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;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const [isDrag, setIsDrag] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ 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"
|
|
|
|
|
+ {...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,
|
|
|
|
|
+ })}
|
|
|
|
|
+ >
|
|
|
|
|
+ <PaperClipIcon className="h-5 w-5" />
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ id="file-upload"
|
|
|
|
|
+ type="file"
|
|
|
|
|
+ className="hidden"
|
|
|
|
|
+ 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}
|
|
|
|
|
+ >
|
|
|
|
|
+ <ArrowUpIcon className="h-5 w-5" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Dropzone>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|