mirror of
https://github.com/coaidev/coai.git
synced 2025-05-20 05:20:15 +09:00
feat: optimize scroll area and message acitons
This commit is contained in:
parent
39ea5f2051
commit
27dd2c80f6
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "chatnio",
|
||||
"version": "3.10.2"
|
||||
"version": "3.10.3"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
@ -135,6 +135,7 @@
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(var(--border-hover));
|
||||
transition: 0.25s linear;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--border-active));
|
||||
|
@ -644,20 +644,14 @@
|
||||
overflow-y: auto;
|
||||
touch-action: pan-y;
|
||||
padding: 18px;
|
||||
scrollbar-width: thin;
|
||||
|
||||
// using margin instead of gap to avoid browser compatibility issues
|
||||
& > * {
|
||||
margin-bottom: 8px;
|
||||
.message {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
@ -702,7 +696,7 @@
|
||||
.text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
transform: translateY(-1px);
|
||||
transform: translateY(-0.5px);
|
||||
opacity: 0;
|
||||
transition: 0.3s;
|
||||
transition-delay: 0.3s;
|
||||
|
@ -345,3 +345,17 @@ input[type="number"] {
|
||||
.border-input:focus {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
|
||||
.animate-fade-in {
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
animation: fadeIn 0.5s forwards;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getRoleIcon, Message } from "@/api/types.tsx";
|
||||
import { Message } from "@/api/types.tsx";
|
||||
import Markdown from "@/components/Markdown.tsx";
|
||||
import {
|
||||
CalendarCheck2,
|
||||
@ -8,7 +8,6 @@ import {
|
||||
Copy,
|
||||
File,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
MousePointerSquare,
|
||||
PencilLine,
|
||||
Power,
|
||||
@ -16,9 +15,14 @@ import {
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import { filterMessage } from "@/utils/processor.ts";
|
||||
import { copyClipboard, saveAsFile, useInputValue } from "@/utils/dom.ts";
|
||||
import {
|
||||
copyClipboard,
|
||||
isContainDom,
|
||||
saveAsFile,
|
||||
useInputValue,
|
||||
} from "@/utils/dom.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ref, useMemo, useRef, useState } from "react";
|
||||
import React, { Ref, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -32,8 +36,6 @@ import Avatar from "@/components/Avatar.tsx";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectUsername } from "@/store/auth.ts";
|
||||
import { appLogo } from "@/conf/env.ts";
|
||||
import Icon from "@/components/utils/Icon.tsx";
|
||||
import { useMobile } from "@/utils/device.ts";
|
||||
|
||||
type MessageProps = {
|
||||
index: number;
|
||||
@ -42,17 +44,34 @@ type MessageProps = {
|
||||
onEvent?: (event: string, index?: number, message?: string) => void;
|
||||
ref?: Ref<HTMLElement>;
|
||||
sharing?: boolean;
|
||||
|
||||
selected?: boolean;
|
||||
onFocus?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
onFocusLeave?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
};
|
||||
|
||||
function MessageSegment(props: MessageProps) {
|
||||
const ref = useRef(null);
|
||||
const mobile = useMobile();
|
||||
const { message } = props;
|
||||
|
||||
return (
|
||||
<div className={`message ${message.role}`} ref={ref}>
|
||||
<div
|
||||
className={`message ${message.role}`}
|
||||
ref={ref}
|
||||
onClick={props.onFocus}
|
||||
onMouseEnter={props.onFocus}
|
||||
onMouseLeave={(event) => {
|
||||
try {
|
||||
if (isContainDom(ref.current, event.relatedTarget as HTMLElement))
|
||||
return;
|
||||
props.onFocusLeave && props.onFocusLeave(event);
|
||||
} catch (e) {
|
||||
console.debug(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MessageContent {...props} />
|
||||
{!mobile && <MessageQuota message={message} />}
|
||||
<MessageQuota message={message} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -96,71 +115,44 @@ function MessageQuota({ message }: MessageQuotaProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent({ message, end, index, onEvent }: MessageProps) {
|
||||
type MessageMenuProps = {
|
||||
children?: React.ReactNode;
|
||||
message: Message;
|
||||
end?: boolean;
|
||||
index: number;
|
||||
onEvent?: (event: string, index?: number, message?: string) => void;
|
||||
editedMessage?: string;
|
||||
setEditedMessage: (message: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
align?: "start" | "end";
|
||||
};
|
||||
|
||||
function MessageMenu({
|
||||
children,
|
||||
align,
|
||||
message,
|
||||
end,
|
||||
index,
|
||||
onEvent,
|
||||
editedMessage,
|
||||
setEditedMessage,
|
||||
setOpen,
|
||||
}: MessageMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const mobile = useMobile();
|
||||
const isAssistant = message.role === "assistant";
|
||||
const isUser = message.role === "user";
|
||||
|
||||
const username = useSelector(selectUsername);
|
||||
const icon = getRoleIcon(message.role);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropdown, setDropdown] = useState(false);
|
||||
const [editedMessage, setEditedMessage] = useState<string | undefined>("");
|
||||
|
||||
return (
|
||||
<div className={"content-wrapper"}>
|
||||
<EditorProvider
|
||||
submittable={true}
|
||||
onSubmit={(value) => onEvent && onEvent("edit", index, value)}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
value={editedMessage ?? ""}
|
||||
onChange={setEditedMessage}
|
||||
/>
|
||||
<div className={`message-avatar-wrapper`}>
|
||||
<Tips
|
||||
classNamePopup={`flex flex-row items-center`}
|
||||
trigger={
|
||||
isUser ? (
|
||||
<Avatar className={`message-avatar`} username={username} />
|
||||
) : (
|
||||
<img src={appLogo} alt={``} className={`message-avatar`} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon icon={icon} className={`h-4 w-4 mr-1`} />
|
||||
{message.role}
|
||||
</Tips>
|
||||
</div>
|
||||
<div className={`message-content`}>
|
||||
{message.content.length ? (
|
||||
<Markdown children={message.content} />
|
||||
) : message.end === true ? (
|
||||
<CircleSlash className={`h-5 w-5 m-1`} />
|
||||
) : (
|
||||
<Loader2 className={`h-5 w-5 m-1 animate-spin`} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(`message-toolbar`, mobile && "w-full")}>
|
||||
<DropdownMenu open={dropdown} onOpenChange={setDropdown}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(`flex flex-row outline-none`, mobile && "my-1.5")}
|
||||
>
|
||||
{mobile && <MessageQuota message={message} />}
|
||||
{!mobile ? (
|
||||
<MoreVertical className={`h-4 w-4 m-0.5`} />
|
||||
) : (
|
||||
<PencilLine className={cn(`h-6 w-6 p-1`, "ml-auto")} />
|
||||
)}
|
||||
<DropdownMenuTrigger className={cn(`flex flex-row outline-none`)}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={`end`}>
|
||||
<DropdownMenuContent align={align}>
|
||||
{isAssistant && end && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onEvent &&
|
||||
onEvent(message.end !== false ? "restart" : "stop");
|
||||
onEvent && onEvent(message.end !== false ? "restart" : "stop");
|
||||
setDropdown(false);
|
||||
}}
|
||||
>
|
||||
@ -184,26 +176,21 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
|
||||
{t("message.copy")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
useInputValue("input", filterMessage(message.content))
|
||||
}
|
||||
onClick={() => useInputValue("input", filterMessage(message.content))}
|
||||
>
|
||||
<MousePointerSquare className={`h-4 w-4 mr-1.5`} />
|
||||
{t("message.use")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
editedMessage?.length === 0 &&
|
||||
setEditedMessage(message.content);
|
||||
editedMessage?.length === 0 && setEditedMessage(message.content);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<PencilLine className={`h-4 w-4 mr-1.5`} />
|
||||
{t("message.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onEvent && onEvent("remove", index)}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => onEvent && onEvent("remove", index)}>
|
||||
<Trash className={`h-4 w-4 mr-1.5`} />
|
||||
{t("message.remove")}
|
||||
</DropdownMenuItem>
|
||||
@ -220,6 +207,74 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent({
|
||||
message,
|
||||
end,
|
||||
index,
|
||||
onEvent,
|
||||
selected,
|
||||
}: MessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
const username = useSelector(selectUsername);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editedMessage, setEditedMessage] = useState<string | undefined>("");
|
||||
|
||||
return (
|
||||
<div className={"content-wrapper"}>
|
||||
<EditorProvider
|
||||
submittable={true}
|
||||
onSubmit={(value) => onEvent && onEvent("edit", index, value)}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
value={editedMessage ?? ""}
|
||||
onChange={setEditedMessage}
|
||||
/>
|
||||
<div className={`message-avatar-wrapper`}>
|
||||
{!selected ? (
|
||||
isUser ? (
|
||||
<Avatar
|
||||
className={`message-avatar animate-fade-in`}
|
||||
username={username}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={appLogo}
|
||||
alt={``}
|
||||
className={`message-avatar animate-fade-in`}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<MessageMenu
|
||||
message={message}
|
||||
end={end}
|
||||
index={index}
|
||||
onEvent={onEvent}
|
||||
editedMessage={editedMessage}
|
||||
setEditedMessage={setEditedMessage}
|
||||
setOpen={setOpen}
|
||||
align={isUser ? "end" : "start"}
|
||||
>
|
||||
<div
|
||||
className={`message-avatar flex flex-row items-center justify-center cursor-pointer select-none opacity-0 animate-fade-in`}
|
||||
>
|
||||
<PencilLine className={`h-4 w-4`} />
|
||||
</div>
|
||||
</MessageMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className={`message-content`}>
|
||||
{message.content.length ? (
|
||||
<Markdown children={message.content} />
|
||||
) : message.end === true ? (
|
||||
<CircleSlash className={`h-5 w-5 m-1`} />
|
||||
) : (
|
||||
<Loader2 className={`h-5 w-5 m-1 animate-spin`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,56 +7,35 @@ import {
|
||||
useMessages,
|
||||
} from "@/store/chat.ts";
|
||||
import MessageSegment from "@/components/Message.tsx";
|
||||
import { chatEvent } from "@/events/chat.ts";
|
||||
import { addEventListeners } from "@/utils/dom.ts";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
|
||||
type ChatInterfaceProps = {
|
||||
scrollable: boolean;
|
||||
setTarget: (target: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
function ChatInterface({ setTarget }: ChatInterfaceProps) {
|
||||
function ChatInterface({ scrollable, setTarget }: ChatInterfaceProps) {
|
||||
const ref = React.useRef(null);
|
||||
const messages: Message[] = useMessages();
|
||||
const process = listenMessageEvent();
|
||||
const current: number = useSelector(selectCurrent);
|
||||
const [scrollable, setScrollable] = React.useState(true);
|
||||
const [position, setPosition] = React.useState(0);
|
||||
const [selected, setSelected] = React.useState(-1);
|
||||
|
||||
useEffect(
|
||||
function () {
|
||||
if (!ref.current || !scrollable) return;
|
||||
const el = ref.current as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
chatEvent.emit();
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const el = ref.current as HTMLDivElement;
|
||||
|
||||
const event = () => {
|
||||
const offset = el.scrollTop - position;
|
||||
setPosition(el.scrollTop);
|
||||
if (offset < 0) setScrollable(false);
|
||||
else
|
||||
setScrollable(el.scrollTop + el.clientHeight + 20 >= el.scrollHeight);
|
||||
};
|
||||
return addEventListeners(
|
||||
el,
|
||||
["scroll", "scrollend", "resize", "touchend"],
|
||||
event,
|
||||
);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
setTarget(ref.current);
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`chat-content`} ref={ref}>
|
||||
<ScrollArea className={`chat-content`} ref={ref}>
|
||||
{messages.map((message, i) => (
|
||||
<MessageSegment
|
||||
message={message}
|
||||
@ -66,10 +45,12 @@ function ChatInterface({ setTarget }: ChatInterfaceProps) {
|
||||
}}
|
||||
key={i}
|
||||
index={i}
|
||||
selected={selected === i}
|
||||
onFocus={() => setSelected(i)}
|
||||
onFocusLeave={() => setSelected(-1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -38,6 +38,7 @@ import { getModelFromId } from "@/conf/model.ts";
|
||||
import { posterEvent } from "@/events/poster.ts";
|
||||
|
||||
type InterfaceProps = {
|
||||
scrollable: boolean;
|
||||
setTarget: (instance: HTMLElement | null) => void;
|
||||
};
|
||||
|
||||
@ -91,6 +92,8 @@ function ChatWrapper() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (working) return false;
|
||||
|
||||
const message: string = formatMessage(files, data);
|
||||
if (message.length > 0 && data.trim().length > 0) {
|
||||
if (await sendAction(message)) {
|
||||
@ -155,7 +158,7 @@ function ChatWrapper() {
|
||||
return (
|
||||
<div className={`chat-container`}>
|
||||
<div className={`chat-wrapper`}>
|
||||
<Interface setTarget={setInstance} />
|
||||
<Interface setTarget={setInstance} scrollable={!visible} />
|
||||
<div className={`chat-input`}>
|
||||
<div className={`input-action`}>
|
||||
<ScrollAction
|
||||
|
@ -28,7 +28,7 @@ export const ChatAction = React.forwardRef<HTMLDivElement, ChatActionProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`action chat-action ${className}`}
|
||||
className={cn("action chat-action", className)}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
style={{ "--width": `${labelWidth}px` } as React.CSSProperties}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { chatEvent } from "@/events/chat.ts";
|
||||
import { addEventListeners, scrollDown } from "@/utils/dom.ts";
|
||||
import { ChatAction } from "@/components/home/assemblies/ChatAction.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -13,27 +12,35 @@ type ScrollActionProps = {
|
||||
target: HTMLElement | null;
|
||||
};
|
||||
|
||||
function ScrollAction({ visible, target, setVisibility }: ScrollActionProps) {
|
||||
function ScrollAction(
|
||||
this: any,
|
||||
{ target, visible, setVisibility }: ScrollActionProps,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const messages: Message[] = useMessages();
|
||||
|
||||
const scrollableHandler = () => {
|
||||
if (!target) return;
|
||||
|
||||
const position = target.scrollTop + target.clientHeight;
|
||||
const height = target.scrollHeight;
|
||||
const diff = Math.abs(position - height);
|
||||
setVisibility(diff > 20);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
return addEventListeners(
|
||||
target,
|
||||
["scroll", "touchmove"],
|
||||
scrollableHandler,
|
||||
);
|
||||
}, [target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return setVisibility(false);
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return setVisibility(false);
|
||||
addEventListeners(target, ["scroll", "resize"], listenScrollingAction);
|
||||
}, [target]);
|
||||
|
||||
function listenScrollingAction() {
|
||||
if (!target) return;
|
||||
const offset = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
setVisibility(offset > 100);
|
||||
}
|
||||
|
||||
chatEvent.addEventListener(listenScrollingAction);
|
||||
|
||||
return (
|
||||
visible && (
|
||||
<ChatAction text={t("scroll-down")} onClick={() => scrollDown(target)}>
|
||||
|
@ -8,11 +8,13 @@ const ScrollArea = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className="h-full w-full rounded-[inherit]"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
import { syncSiteInfo } from "@/admin/api/info.ts";
|
||||
import { setAxiosConfig } from "@/conf/api.ts";
|
||||
|
||||
export const version = "3.10.2"; // version of the current build
|
||||
export const version = "3.10.3"; // version of the current build
|
||||
export const dev: boolean = getDev(); // is in development mode (for debugging, in localhost origin)
|
||||
export const deploy: boolean = true; // is production environment (for api endpoint)
|
||||
export const tokenField = getTokenField(deploy); // token field name for storing token
|
||||
|
@ -1,5 +0,0 @@
|
||||
import { EventCommitter } from "@/events/struct.ts";
|
||||
|
||||
export const chatEvent = new EventCommitter<void>({
|
||||
name: "chat",
|
||||
});
|
@ -304,3 +304,39 @@ export function getQuerySelector(query: string): HTMLElement | null {
|
||||
|
||||
return document.body.querySelector(query);
|
||||
}
|
||||
|
||||
export function isContainDom(
|
||||
el: HTMLElement | undefined | null,
|
||||
target: HTMLElement | undefined | null,
|
||||
notIncludeSelf = false,
|
||||
) {
|
||||
/**
|
||||
* Test if element contains target
|
||||
* @param el Element
|
||||
* @param target Target
|
||||
* @example
|
||||
* const el = document.getElementById("el");
|
||||
* const target = document.getElementById("target");
|
||||
* console.log(isContain(el, target));
|
||||
*/
|
||||
|
||||
if (!el || !target) return false;
|
||||
return el.contains(target) && (!notIncludeSelf || el !== target);
|
||||
}
|
||||
|
||||
export function isContainEventTarget(
|
||||
el: HTMLElement | undefined | null,
|
||||
e: Event,
|
||||
) {
|
||||
/**
|
||||
* Test if element contains event target
|
||||
* @param el Element
|
||||
* @param e Event
|
||||
* @example
|
||||
* const el = document.getElementById("el");
|
||||
* const handler = (e: Event) => console.log(isContainEventTarget(el, e));
|
||||
* el.addEventListener("click", handler);
|
||||
*/
|
||||
|
||||
return isContainDom(el, e.target as HTMLElement);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user