mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
ai chat enhancements (#96)
This commit is contained in:
parent
d8ff2cb806
commit
9b3042829c
@ -9,7 +9,8 @@ interface ChatMessageType {
|
|||||||
user: string;
|
user: string;
|
||||||
text: string;
|
text: string;
|
||||||
isAssistant: boolean;
|
isAssistant: boolean;
|
||||||
error?: string;
|
isUpdating?: boolean;
|
||||||
|
isError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultMessage: ChatMessageType = {
|
const defaultMessage: ChatMessageType = {
|
||||||
@ -27,11 +28,11 @@ const addMessageAtom = atom(null, (get, set, message: ChatMessageType) => {
|
|||||||
set(messagesAtom, [...messages, message]);
|
set(messagesAtom, [...messages, message]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateLastMessageAtom = atom(null, (get, set, text: string) => {
|
const updateLastMessageAtom = atom(null, (get, set, text: string, isUpdating: boolean) => {
|
||||||
const messages = get(messagesAtom);
|
const messages = get(messagesAtom);
|
||||||
const lastMessage = messages[messages.length - 1];
|
const lastMessage = messages[messages.length - 1];
|
||||||
if (lastMessage.isAssistant && !lastMessage.error) {
|
if (lastMessage.isAssistant && !lastMessage.isError) {
|
||||||
const updatedMessage = { ...lastMessage, text: lastMessage.text + text };
|
const updatedMessage = { ...lastMessage, text: lastMessage.text + text, isUpdating };
|
||||||
set(messagesAtom, [...messages.slice(0, -1), updatedMessage]);
|
set(messagesAtom, [...messages.slice(0, -1), updatedMessage]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -64,10 +65,11 @@ You can run this script by saving it to a file, for example, \`hello.sh\`, and t
|
|||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (currentPart < parts.length) {
|
if (currentPart < parts.length) {
|
||||||
const part = parts[currentPart] + " ";
|
const part = parts[currentPart] + " ";
|
||||||
set(updateLastMessageAtom, part);
|
set(updateLastMessageAtom, part, true);
|
||||||
currentPart++;
|
currentPart++;
|
||||||
} else {
|
} else {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
set(updateLastMessageAtom, "", false);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
@ -50,12 +50,16 @@
|
|||||||
.chat-msg-assistant {
|
.chat-msg-assistant {
|
||||||
color: var(--app-text-color);
|
color: var(--app-text-color);
|
||||||
|
|
||||||
pre {
|
.markdown {
|
||||||
white-space: pre-wrap;
|
width: 100%;
|
||||||
word-break: break-word;
|
|
||||||
max-width: 100%;
|
pre {
|
||||||
overflow-x: auto;
|
white-space: pre-wrap;
|
||||||
margin-left: 0;
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import { getApi } from "@/app/store/global";
|
|||||||
import { ChatMessageType, useWaveAi } from "@/app/store/waveai";
|
import { ChatMessageType, useWaveAi } from "@/app/store/waveai";
|
||||||
import type { OverlayScrollbars } from "overlayscrollbars";
|
import type { OverlayScrollbars } from "overlayscrollbars";
|
||||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||||
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
import tinycolor from "tinycolor2";
|
import tinycolor from "tinycolor2";
|
||||||
|
|
||||||
import "./waveai.less";
|
import "./waveai.less";
|
||||||
@ -20,7 +20,7 @@ interface ChatItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChatItem = ({ chatItem, itemCount }: ChatItemProps) => {
|
const ChatItem = ({ chatItem, itemCount }: ChatItemProps) => {
|
||||||
const { isAssistant, text, error } = chatItem;
|
const { isAssistant, text, isError } = chatItem;
|
||||||
const senderClassName = isAssistant ? "chat-msg-assistant" : "chat-msg-user";
|
const senderClassName = isAssistant ? "chat-msg-assistant" : "chat-msg-user";
|
||||||
const msgClassName = `chat-msg ${senderClassName}`;
|
const msgClassName = `chat-msg ${senderClassName}`;
|
||||||
const cssVar = getApi().isDev ? "--app-panel-bg-color-dev" : "--app-panel-bg-color";
|
const cssVar = getApi().isDev ? "--app-panel-bg-color-dev" : "--app-panel-bg-color";
|
||||||
@ -33,8 +33,8 @@ const ChatItem = ({ chatItem, itemCount }: ChatItemProps) => {
|
|||||||
|
|
||||||
const renderContent = (): React.JSX.Element => {
|
const renderContent = (): React.JSX.Element => {
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
if (error) {
|
if (isError) {
|
||||||
return renderError(error);
|
return renderError(isError);
|
||||||
}
|
}
|
||||||
return text ? (
|
return text ? (
|
||||||
<>
|
<>
|
||||||
@ -74,60 +74,90 @@ interface ChatWindowProps {
|
|||||||
messages: ChatMessageType[];
|
messages: ChatMessageType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow = forwardRef<OverlayScrollbarsComponentRef, ChatWindowProps>(({ chatWindowRef, messages }, ref) => {
|
const ChatWindow = React.memo(
|
||||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
forwardRef<OverlayScrollbarsComponentRef, ChatWindowProps>(({ chatWindowRef, messages }, ref) => {
|
||||||
|
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef);
|
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||||
|
const prevMessagesRef = useRef<ChatMessageType[]>(messages);
|
||||||
|
|
||||||
useEffect(() => {
|
useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef);
|
||||||
if (osRef.current && osRef.current.osInstance()) {
|
|
||||||
const { viewport } = osRef.current.osInstance().elements();
|
useEffect(() => {
|
||||||
|
const prevMessages = prevMessagesRef.current;
|
||||||
|
if (osRef.current && osRef.current.osInstance()) {
|
||||||
|
const { viewport } = osRef.current.osInstance().elements();
|
||||||
|
|
||||||
|
if (prevMessages.length !== messages.length || !isUserScrolling) {
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
viewport.scrollTo({
|
||||||
|
behavior: "auto",
|
||||||
|
top: chatWindowRef.current?.scrollHeight || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prevMessagesRef.current = messages;
|
||||||
|
}
|
||||||
|
}, [messages, isUserScrolling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (osRef.current && osRef.current.osInstance()) {
|
||||||
|
const { viewport } = osRef.current.osInstance().elements();
|
||||||
|
|
||||||
|
const handleUserScroll = () => {
|
||||||
|
setIsUserScrolling(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
viewport.addEventListener("wheel", handleUserScroll, { passive: true });
|
||||||
|
viewport.addEventListener("touchmove", handleUserScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
viewport.removeEventListener("wheel", handleUserScroll);
|
||||||
|
viewport.removeEventListener("touchmove", handleUserScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (osRef.current && osRef.current.osInstance()) {
|
||||||
|
osRef.current.osInstance().destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScrollbarInitialized = (instance: OverlayScrollbars) => {
|
||||||
|
const { viewport } = instance.elements();
|
||||||
viewport.scrollTo({
|
viewport.scrollTo({
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
top: chatWindowRef.current?.scrollHeight || 0,
|
top: chatWindowRef.current?.scrollHeight || 0,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (osRef.current && osRef.current.osInstance()) {
|
|
||||||
osRef.current.osInstance().destroy();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleScrollbarInitialized = (instance: OverlayScrollbars) => {
|
return (
|
||||||
const { viewport } = instance.elements();
|
<OverlayScrollbarsComponent
|
||||||
viewport.scrollTo({
|
ref={osRef}
|
||||||
behavior: "auto",
|
className="scrollable"
|
||||||
top: chatWindowRef.current?.scrollHeight || 0,
|
options={{ scrollbars: { autoHide: "leave" } }}
|
||||||
});
|
events={{ initialized: handleScrollbarInitialized }}
|
||||||
};
|
>
|
||||||
|
<div ref={chatWindowRef} className="chat-window">
|
||||||
return (
|
<div className="filler"></div>
|
||||||
<OverlayScrollbarsComponent
|
{messages.map((chitem, idx) => (
|
||||||
ref={osRef}
|
<ChatItem key={idx} chatItem={chitem} itemCount={idx + 1} />
|
||||||
className="scrollable"
|
))}
|
||||||
options={{ scrollbars: { autoHide: "leave" } }}
|
</div>
|
||||||
events={{ initialized: handleScrollbarInitialized }}
|
</OverlayScrollbarsComponent>
|
||||||
>
|
);
|
||||||
<div ref={chatWindowRef} className="chat-window">
|
})
|
||||||
<div className="filler"></div>
|
);
|
||||||
{messages.map((chitem, idx) => (
|
|
||||||
<ChatItem key={idx} chatItem={chitem} itemCount={idx + 1} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</OverlayScrollbarsComponent>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
termFontSize: number;
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
|
onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
|
||||||
termFontSize: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
||||||
@ -189,10 +219,12 @@ const WaveAi = React.memo(({ parentRef }: WaveAiProps) => {
|
|||||||
const chatWindowRef = useRef<HTMLDivElement>(null);
|
const chatWindowRef = useRef<HTMLDivElement>(null);
|
||||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const submitTimeoutRef = useRef<NodeJS.Timeout>(null);
|
||||||
|
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [waveAiHeight, setWaveAiHeight] = useState(0);
|
const [waveAiHeight, setWaveAiHeight] = useState(0);
|
||||||
const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(null);
|
const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const termFontSize: number = 14;
|
const termFontSize: number = 14;
|
||||||
|
|
||||||
@ -218,12 +250,26 @@ const WaveAi = React.memo(({ parentRef }: WaveAiProps) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
if (submitTimeoutRef.current) {
|
||||||
|
clearTimeout(submitTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submit = (messageStr: string) => {
|
const submit = useCallback(
|
||||||
sendMessage(messageStr);
|
(messageStr: string) => {
|
||||||
};
|
if (!isSubmitting) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
sendMessage(messageStr);
|
||||||
|
|
||||||
|
clearTimeout(submitTimeoutRef.current);
|
||||||
|
submitTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isSubmitting, sendMessage, setValue]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
@ -260,11 +306,14 @@ const WaveAi = React.memo(({ parentRef }: WaveAiProps) => {
|
|||||||
setSelectedBlockIdx(null);
|
setSelectedBlockIdx(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnterKeyPressed = () => {
|
const handleEnterKeyPressed = useCallback(() => {
|
||||||
|
const isCurrentlyUpdating = messages.some((message) => message.isUpdating);
|
||||||
|
if (isCurrentlyUpdating || value === "") return;
|
||||||
|
|
||||||
submit(value);
|
submit(value);
|
||||||
setValue("");
|
setValue("");
|
||||||
setSelectedBlockIdx(null);
|
setSelectedBlockIdx(null);
|
||||||
};
|
}, [messages, value]);
|
||||||
|
|
||||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
|
Loading…
Reference in New Issue
Block a user