Compare commits
35 Commits
dependabot
...
refactor/n
Author | SHA1 | Date | |
---|---|---|---|
|
0c53579996 | ||
|
00b1a9781d | ||
|
240d330001 | ||
|
4e4431339f | ||
|
fa2f8c66d1 | ||
|
32f62d70af | ||
|
68f0fa917f | ||
|
8a14cb19a9 | ||
|
3d99965a8f | ||
|
4d5a9476b6 | ||
|
15d6ed252f | ||
|
ecf6cc27d6 | ||
|
cadd2558fd | ||
|
c3d91bf0cd | ||
|
996537d262 | ||
|
5ea6206319 | ||
|
8c28c408d8 | ||
|
c34b8ab919 | ||
|
9f4813326c | ||
|
9569888b0e | ||
|
1a636b0f50 | ||
|
48e8c0a194 | ||
|
59583e53bd | ||
|
bb7422c526 | ||
|
c99086447e | ||
|
f7074bba8c | ||
|
4400392c0c | ||
|
4a5465f884 | ||
|
37cc87531c | ||
|
1074fffe79 | ||
|
3d0a98d5d2 | ||
|
b3559f99a2 | ||
|
51a1d9f92a | ||
|
3fc9b91bf1 | ||
|
0a8e5d6734 |
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["prettier"]
|
||||
"plugins": [
|
||||
"prettier"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"legacyDecorators": true
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["globals.css"]
|
||||
}
|
||||
|
102
app/(app)/chat/layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
import {
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
} from "@/app/constant";
|
||||
import useDrag from "@/app/hooks/useDrag";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||
import { useRef, useState } from "react";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import React from "react";
|
||||
import { AuthPage } from "@/app/components/auth";
|
||||
import { SideBar } from "@/app/containers/Sidebar";
|
||||
import Screen from "@/app/components/Screen";
|
||||
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
|
||||
import Chat from "@/app/containers/Chat/ChatPanel";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [externalProps, setExternalProps] = useState({});
|
||||
const config = useAppConfig();
|
||||
useSwitchTheme();
|
||||
const isMobileScreen = useMobileScreen();
|
||||
|
||||
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||
// drag side bar
|
||||
const { onDragStart } = useDrag({
|
||||
customToggle: () => {
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
});
|
||||
},
|
||||
customDragMove: (nextWidth: number) => {
|
||||
const { menuWidth } = updateGlobalCSSVars(nextWidth);
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--menu-width",
|
||||
`${menuWidth}px`,
|
||||
);
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = nextWidth;
|
||||
});
|
||||
},
|
||||
customLimit: (x: number) =>
|
||||
Math.max(
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
|
||||
),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
w-[100%] relative bg-center
|
||||
max-md:h-[100%]
|
||||
md:flex md:my-2.5
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex flex-col px-6
|
||||
h-[100%]
|
||||
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
|
||||
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!isMobileScreen && (
|
||||
<div
|
||||
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
|
||||
onPointerDown={(e) => {
|
||||
startDragWidth.current = config.sidebarWidth;
|
||||
onDragStart(e as any);
|
||||
}}
|
||||
>
|
||||
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
md:flex-1 md:h-[100%] md:w-page
|
||||
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
|
||||
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
|
||||
} max-md:z-10
|
||||
`}
|
||||
>
|
||||
{/* <PanelComponent
|
||||
{...props}
|
||||
{...externalProps}
|
||||
setShowPanel={setShowPanel}
|
||||
setExternalProps={setExternalProps}
|
||||
showPanel={showPanel}
|
||||
/> */}
|
||||
{/* {children} */}
|
||||
<Chat></Chat>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
137
app/(app)/chat/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
// import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
import AddIcon from "@/app/icons/addIcon.svg";
|
||||
import NextChatTitle from "@/app/icons/nextchatTitle.svg";
|
||||
|
||||
import Modal from "@/app/components/Modal";
|
||||
import SessionItem from "@/app/containers/Chat/components/SessionItem";
|
||||
|
||||
export default function Page() {
|
||||
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
|
||||
(state) => [
|
||||
state.sessions,
|
||||
state.currentSessionIndex,
|
||||
state.selectSession,
|
||||
state.moveSession,
|
||||
],
|
||||
);
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const pathname = usePathname();
|
||||
const onDragEnd: OnDragEndResponder = (result) => {
|
||||
const { destination, source } = result;
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
moveSession(source.index, destination.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
h-[100%] flex flex-col
|
||||
md:px-0
|
||||
`}
|
||||
>
|
||||
<div data-tauri-drag-region>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between
|
||||
py-6 max-md:box-content max-md:h-0
|
||||
md:py-7
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="">
|
||||
<NextChatTitle />
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer "
|
||||
onClick={() => {
|
||||
// if (config.dontShowMaskSplashScreen) {
|
||||
// chatStore.newSession();
|
||||
// navigate(Path.Chat);
|
||||
// } else {
|
||||
// navigate(Path.NewChat);
|
||||
// }
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pb-3 text-sm sm:text-sm-mobile text-text-chat-header-subtitle`}
|
||||
>
|
||||
Build your own AI assistant.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 overflow-y-auto max-md:pb-chat-panel-mobile `}>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="chat-list">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`w-[100%]`}
|
||||
>
|
||||
{sessions.map((item, i) => (
|
||||
<SessionItem
|
||||
title={item.topic}
|
||||
time={new Date(item.lastUpdate).toLocaleString()}
|
||||
count={item.messages.length}
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
index={i}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
// navigate(Path.Chat);
|
||||
// selectSession(i);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
await Modal.warn({
|
||||
okText: Locale.ChatItem.DeleteOkBtn,
|
||||
cancelText: Locale.ChatItem.DeleteCancelBtn,
|
||||
title: Locale.ChatItem.DeleteTitle,
|
||||
content: Locale.ChatItem.DeleteContent,
|
||||
})
|
||||
) {
|
||||
chatStore.deleteSession(i);
|
||||
}
|
||||
}}
|
||||
mask={item.mask}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
21
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AuthPage } from "@/app/components/auth";
|
||||
import { SideBar } from "@/app/containers/Sidebar";
|
||||
import Screen from "@/app/components/Screen";
|
||||
|
||||
export interface MenuWrapperInspectProps {
|
||||
setExternalProps?: (v: Record<string, any>) => void;
|
||||
setShowPanel?: (v: boolean) => void;
|
||||
showPanel?: boolean;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
|
||||
{children}
|
||||
</Screen>
|
||||
);
|
||||
}
|
4
app/(app)/settings/layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import React from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
3
app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
// import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Locale from "./locales";
|
||||
|
||||
type Command = (param: string) => void;
|
||||
@@ -14,22 +15,23 @@ interface Commands {
|
||||
export function useCommand(commands: Commands = {}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
let shouldUpdate = false;
|
||||
searchParams.forEach((param, name) => {
|
||||
const commandName = name as keyof Commands;
|
||||
if (typeof commands[commandName] === "function") {
|
||||
commands[commandName]!(param);
|
||||
searchParams.delete(name);
|
||||
shouldUpdate = true;
|
||||
}
|
||||
});
|
||||
// fixme: update commands
|
||||
// useEffect(() => {
|
||||
// let shouldUpdate = false;
|
||||
// searchParams.forEach((param, name) => {
|
||||
// const commandName = name as keyof Commands;
|
||||
// if (typeof commands[commandName] === "function") {
|
||||
// commands[commandName]!(param);
|
||||
// searchParams.delete(name);
|
||||
// shouldUpdate = true;
|
||||
// }
|
||||
// });
|
||||
|
||||
if (shouldUpdate) {
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, commands]);
|
||||
// if (shouldUpdate) {
|
||||
// setSearchParams(searchParams);
|
||||
// }
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [searchParams, commands]);
|
||||
}
|
||||
|
||||
interface ChatCommands {
|
||||
|
123
app/components/ActionsBar/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { isValidElement } from "react";
|
||||
|
||||
type IconMap = {
|
||||
active?: JSX.Element;
|
||||
inactive?: JSX.Element;
|
||||
mobileActive?: JSX.Element;
|
||||
mobileInactive?: JSX.Element;
|
||||
};
|
||||
interface Action {
|
||||
id: string;
|
||||
title?: string;
|
||||
icons: JSX.Element | IconMap;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
activeClassName?: string;
|
||||
}
|
||||
|
||||
type Groups = {
|
||||
normal: string[][];
|
||||
mobile: string[][];
|
||||
};
|
||||
|
||||
export interface ActionsBarProps {
|
||||
actionsShema: Action[];
|
||||
onSelect?: (id: string) => void;
|
||||
selected?: string;
|
||||
groups: string[][] | Groups;
|
||||
className?: string;
|
||||
inMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionsBar(props: ActionsBarProps) {
|
||||
const { actionsShema, onSelect, selected, groups, className, inMobile } =
|
||||
props;
|
||||
|
||||
const handlerClick =
|
||||
(action: Action) => (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
if (action.onClick) {
|
||||
action.onClick();
|
||||
}
|
||||
if (selected !== action.id) {
|
||||
onSelect?.(action.id);
|
||||
}
|
||||
};
|
||||
|
||||
const internalGroup = Array.isArray(groups)
|
||||
? groups
|
||||
: inMobile
|
||||
? groups.mobile
|
||||
: groups.normal;
|
||||
|
||||
const content = internalGroup.reduce((res, group, ind, arr) => {
|
||||
res.push(
|
||||
...group.map((i) => {
|
||||
const action = actionsShema.find((a) => a.id === i);
|
||||
if (!action) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { icons } = action;
|
||||
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
|
||||
|
||||
if (isValidElement(icons)) {
|
||||
activeIcon = icons;
|
||||
inactiveIcon = icons;
|
||||
mobileActiveIcon = icons;
|
||||
mobileInactiveIcon = icons;
|
||||
} else {
|
||||
activeIcon = (icons as IconMap).active;
|
||||
inactiveIcon = (icons as IconMap).inactive;
|
||||
mobileActiveIcon = (icons as IconMap).mobileActive;
|
||||
mobileInactiveIcon = (icons as IconMap).mobileInactive;
|
||||
}
|
||||
|
||||
if (inMobile) {
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={` cursor-pointer shrink-1 grow-0 basis-[${
|
||||
(100 - 1) / arr.length
|
||||
}%] flex flex-col items-center justify-around gap-0.5 py-1.5
|
||||
${
|
||||
selected === action.id
|
||||
? "text-text-sidebar-tab-mobile-active"
|
||||
: "text-text-sidebar-tab-mobile-inactive"
|
||||
}
|
||||
`}
|
||||
onClick={handlerClick(action)}
|
||||
>
|
||||
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
|
||||
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
|
||||
{action.title || " "}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={`cursor-pointer p-3 ${
|
||||
selected === action.id
|
||||
? `!bg-actions-bar-btn-default ${action.activeClassName}`
|
||||
: "bg-transparent"
|
||||
} rounded-md items-center ${
|
||||
action.className
|
||||
} transition duration-300 ease-in-out`}
|
||||
onClick={handlerClick(action)}
|
||||
>
|
||||
{selected === action.id ? activeIcon : inactiveIcon}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (ind < arr.length - 1) {
|
||||
res.push(<div key={String(ind)} className=" flex-1"></div>);
|
||||
}
|
||||
return res;
|
||||
}, [] as JSX.Element[]);
|
||||
|
||||
return <div className={`flex items-center ${className} `}>{content}</div>;
|
||||
}
|
78
app/components/Btn/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type ButtonType = "primary" | "danger" | null;
|
||||
|
||||
export interface BtnProps {
|
||||
onClick?: () => void;
|
||||
icon?: JSX.Element;
|
||||
prefixIcon?: JSX.Element;
|
||||
type?: ButtonType;
|
||||
text?: React.ReactNode;
|
||||
bordered?: boolean;
|
||||
shadow?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export default function Btn(props: BtnProps) {
|
||||
const {
|
||||
onClick,
|
||||
icon,
|
||||
type,
|
||||
text,
|
||||
className,
|
||||
title,
|
||||
disabled,
|
||||
tabIndex,
|
||||
autoFocus,
|
||||
prefixIcon,
|
||||
} = props;
|
||||
|
||||
let btnClassName;
|
||||
|
||||
switch (type) {
|
||||
case "primary":
|
||||
btnClassName = `${
|
||||
disabled
|
||||
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
|
||||
: "bg-primary-btn shadow-btn"
|
||||
} text-text-btn-primary `;
|
||||
break;
|
||||
case "danger":
|
||||
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
|
||||
break;
|
||||
default:
|
||||
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
${className ?? ""}
|
||||
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
|
||||
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
|
||||
${btnClassName}
|
||||
follow-parent-svg
|
||||
`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
role="button"
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={autoFocus}
|
||||
>
|
||||
{prefixIcon && (
|
||||
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
|
||||
</button>
|
||||
);
|
||||
}
|
32
app/components/Card/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface CardProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Card(props: CardProps) {
|
||||
const { className, children, title } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div
|
||||
className={`
|
||||
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
|
||||
mb-3
|
||||
|
||||
ml-3
|
||||
md:ml-4
|
||||
`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
18
app/components/GlobalLoading/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import BotIcon from "@/app/icons/bot.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
export default function GloablLoading({
|
||||
noLogo,
|
||||
}: {
|
||||
noLogo?: boolean;
|
||||
useSkeleton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
|
||||
>
|
||||
{!noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
39
app/components/HoverPopover/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
export interface PopoverProps {
|
||||
content?: JSX.Element | string;
|
||||
children?: JSX.Element;
|
||||
arrowClassName?: string;
|
||||
popoverClassName?: string;
|
||||
noArrow?: boolean;
|
||||
align?: ComponentProps<typeof HoverCard.Content>["align"];
|
||||
openDelay?: number;
|
||||
}
|
||||
|
||||
export default function HoverPopover(props: PopoverProps) {
|
||||
const {
|
||||
content,
|
||||
children,
|
||||
arrowClassName,
|
||||
popoverClassName,
|
||||
noArrow = false,
|
||||
align,
|
||||
openDelay = 300,
|
||||
} = props;
|
||||
return (
|
||||
<HoverCard.Root openDelay={openDelay}>
|
||||
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className={`${popoverClassName}`}
|
||||
sideOffset={5}
|
||||
align={align}
|
||||
>
|
||||
{content}
|
||||
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
);
|
||||
}
|
42
app/components/Imgs/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CSSProperties } from "react";
|
||||
import { getMessageImages } from "@/app/utils";
|
||||
import { RequestMessage } from "@/app/client/api";
|
||||
|
||||
interface ImgsProps {
|
||||
message: RequestMessage;
|
||||
}
|
||||
|
||||
export default function Imgs(props: ImgsProps) {
|
||||
const { message } = props;
|
||||
const imgSrcs = getMessageImages(message);
|
||||
|
||||
if (imgSrcs.length < 1) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const imgVars = {
|
||||
"--imgs-width": `calc(var(--max-message-width) - ${
|
||||
imgSrcs.length - 1
|
||||
}*0.25rem)`,
|
||||
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[100%] mt-[0.625rem] flex gap-1`}
|
||||
style={imgVars as CSSProperties}
|
||||
>
|
||||
{imgSrcs.map((image, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
|
||||
style={{
|
||||
backgroundImage: `url(${image})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
88
app/components/Input/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import PasswordVisible from "@/app/icons/passwordVisible.svg";
|
||||
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import List, { ListContext } from "@/app/components/List";
|
||||
|
||||
export interface CommonInputProps
|
||||
extends Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
"onChange" | "type" | "value"
|
||||
> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface NumberInputProps {
|
||||
onChange?: (v: number) => void;
|
||||
type?: "number";
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface TextInputProps {
|
||||
onChange?: (v: string) => void;
|
||||
type?: "text" | "password";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface InputProps {
|
||||
onChange?: ((v: string) => void) | ((v: number) => void);
|
||||
type?: "text" | "password" | "number";
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
export default function Input(
|
||||
props: CommonInputProps & NumberInputProps,
|
||||
): JSX.Element;
|
||||
export default function Input(
|
||||
props: CommonInputProps & TextInputProps,
|
||||
): JSX.Element;
|
||||
export default function Input(props: CommonInputProps & InputProps) {
|
||||
const { value, type = "text", onChange, className, ...rest } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const { inputClassName } = useContext(ListContext);
|
||||
|
||||
const internalType = (show && "text") || type;
|
||||
|
||||
const { update, handleValidate } = useContext(List.ListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
update?.({ type: "input" });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
handleValidate?.(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
|
||||
>
|
||||
<input
|
||||
{...rest}
|
||||
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
|
||||
type={internalType}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (type === "number") {
|
||||
const v = e.currentTarget.valueAsNumber;
|
||||
(onChange as NumberInputProps["onChange"])?.(v);
|
||||
} else {
|
||||
const v = e.currentTarget.value;
|
||||
(onChange as TextInputProps["onChange"])?.(v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{type == "password" && (
|
||||
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
|
||||
{show ? <PasswordVisible /> : <PasswordInvisible />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
157
app/components/List/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface WidgetStyle {
|
||||
selectClassName?: string;
|
||||
inputClassName?: string;
|
||||
rangeClassName?: string;
|
||||
switchClassName?: string;
|
||||
inputNextLine?: boolean;
|
||||
rangeNextLine?: boolean;
|
||||
}
|
||||
|
||||
interface ChildrenMeta {
|
||||
type?: "unknown" | "input" | "range";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
id?: string;
|
||||
isMobileScreen?: boolean;
|
||||
widgetStyle?: WidgetStyle;
|
||||
}
|
||||
|
||||
type Error =
|
||||
| {
|
||||
error: true;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
error: false;
|
||||
};
|
||||
|
||||
export interface ListItemProps {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
nextline?: boolean;
|
||||
validator?: (v: any) => Error | Promise<Error>;
|
||||
}
|
||||
|
||||
export const ListContext = createContext<
|
||||
{
|
||||
isMobileScreen?: boolean;
|
||||
update?: (m: ChildrenMeta) => void;
|
||||
handleValidate?: (v: any) => void;
|
||||
} & WidgetStyle
|
||||
>({ isMobileScreen: false });
|
||||
|
||||
export function ListItem(props: ListItemProps) {
|
||||
const {
|
||||
className = "",
|
||||
onClick,
|
||||
title,
|
||||
subTitle,
|
||||
children,
|
||||
nextline,
|
||||
validator,
|
||||
} = props;
|
||||
|
||||
const context = useContext(ListContext);
|
||||
|
||||
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
|
||||
|
||||
const { inputNextLine, rangeNextLine } = context;
|
||||
|
||||
const { type, error } = childrenMeta;
|
||||
|
||||
let internalNextLine;
|
||||
|
||||
switch (type) {
|
||||
case "input":
|
||||
internalNextLine = !!(nextline || inputNextLine);
|
||||
break;
|
||||
case "range":
|
||||
internalNextLine = !!(nextline || rangeNextLine);
|
||||
break;
|
||||
default:
|
||||
internalNextLine = false;
|
||||
}
|
||||
|
||||
const update = useCallback((m: ChildrenMeta) => {
|
||||
setMeta((pre) => ({ ...pre, ...m }));
|
||||
}, []);
|
||||
|
||||
const handleValidate = useCallback((v: any) => {
|
||||
const insideValidator = validator || (() => {});
|
||||
|
||||
Promise.resolve(insideValidator(v)).then((result) => {
|
||||
if (result && result.error) {
|
||||
return update({
|
||||
error: result.message,
|
||||
});
|
||||
}
|
||||
update({
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-list-item-divider ${
|
||||
internalNextLine ? "" : "flex gap-3"
|
||||
} justify-between items-center px-0 py-2 md:py-3 ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={`flex-1 flex flex-col justify-start gap-1`}>
|
||||
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1 text-text-list-title">
|
||||
{title}
|
||||
</div>
|
||||
{subTitle && (
|
||||
<div className={` text-sm text-text-list-subtitle`}>{subTitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<ListContext.Provider value={{ ...context, update, handleValidate }}>
|
||||
<div
|
||||
className={`${
|
||||
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
|
||||
} flex flex-col items-center justify-center`}
|
||||
>
|
||||
<div>{children}</div>
|
||||
{!!error && (
|
||||
<div className="text-text-btn-danger text-sm-mobile-tab mt-[0.3125rem] flex items-start w-[100%]">
|
||||
<div className="">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function List(props: ListProps) {
|
||||
const { className, children, id, widgetStyle } = props;
|
||||
const { isMobileScreen } = useContext(ListContext);
|
||||
return (
|
||||
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
|
||||
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
</ListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
List.ListItem = ListItem;
|
||||
List.ListContext = ListContext;
|
||||
|
||||
export default List;
|
35
app/components/Loading/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import BotIcon from "@/app/icons/bot.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
import { getCSSVar } from "@/app/utils";
|
||||
|
||||
export default function Loading({
|
||||
noLogo,
|
||||
useSkeleton = true,
|
||||
}: {
|
||||
noLogo?: boolean;
|
||||
useSkeleton?: boolean;
|
||||
}) {
|
||||
let theme;
|
||||
if (typeof window !== "undefined") {
|
||||
theme = getCSSVar("--default-container-bg");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col justify-center items-center w-[100%]
|
||||
h-[100%]
|
||||
md:my-2.5
|
||||
md:ml-1
|
||||
md:mr-2.5
|
||||
md:rounded-md
|
||||
md:h-[calc(100%-1.25rem)]
|
||||
`}
|
||||
style={{ background: useSkeleton ? theme : "" }}
|
||||
>
|
||||
{!noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
115
app/components/MenuLayout/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
} from "@/app/constant";
|
||||
import useDrag from "@/app/hooks/useDrag";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||
import { ComponentType, useRef, useState } from "react";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
|
||||
export interface MenuWrapperInspectProps {
|
||||
setExternalProps?: (v: Record<string, any>) => void;
|
||||
setShowPanel?: (v: boolean) => void;
|
||||
showPanel?: boolean;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export default function MenuLayout<
|
||||
ListComponentProps extends MenuWrapperInspectProps,
|
||||
PanelComponentProps extends MenuWrapperInspectProps,
|
||||
>(
|
||||
ListComponent: ComponentType<ListComponentProps>,
|
||||
PanelComponent: ComponentType<PanelComponentProps>,
|
||||
) {
|
||||
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [externalProps, setExternalProps] = useState({});
|
||||
const config = useAppConfig();
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
|
||||
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||
// drag side bar
|
||||
const { onDragStart } = useDrag({
|
||||
customToggle: () => {
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
});
|
||||
},
|
||||
customDragMove: (nextWidth: number) => {
|
||||
const { menuWidth } = updateGlobalCSSVars(nextWidth);
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--menu-width",
|
||||
`${menuWidth}px`,
|
||||
);
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = nextWidth;
|
||||
});
|
||||
},
|
||||
customLimit: (x: number) =>
|
||||
Math.max(
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
w-[100%] relative bg-center
|
||||
max-md:h-[100%]
|
||||
md:flex md:my-2.5
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex flex-col px-6
|
||||
h-[100%]
|
||||
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
|
||||
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
|
||||
`}
|
||||
>
|
||||
<ListComponent
|
||||
{...props}
|
||||
setShowPanel={setShowPanel}
|
||||
setExternalProps={setExternalProps}
|
||||
showPanel={showPanel}
|
||||
/>
|
||||
</div>
|
||||
{!isMobileScreen && (
|
||||
<div
|
||||
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
|
||||
onPointerDown={(e) => {
|
||||
startDragWidth.current = config.sidebarWidth;
|
||||
onDragStart(e as any);
|
||||
}}
|
||||
>
|
||||
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
md:flex-1 md:h-[100%] md:w-page
|
||||
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
|
||||
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
|
||||
} max-md:z-10
|
||||
`}
|
||||
>
|
||||
<PanelComponent
|
||||
{...props}
|
||||
{...externalProps}
|
||||
setShowPanel={setShowPanel}
|
||||
setExternalProps={setExternalProps}
|
||||
showPanel={showPanel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
359
app/components/Modal/index.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useLayoutEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import * as AlertDialog from "@radix-ui/react-alert-dialog";
|
||||
import Btn, { BtnProps } from "@/app/components/Btn";
|
||||
|
||||
import Warning from "@/app/icons/warning.svg";
|
||||
import Close from "@/app/icons/closeIcon.svg";
|
||||
|
||||
export interface ModalProps {
|
||||
onOk?: () => void;
|
||||
onCancel?: () => void;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
okBtnProps?: BtnProps;
|
||||
cancelBtnProps?: BtnProps;
|
||||
content?:
|
||||
| React.ReactNode
|
||||
| ((handlers: { close: () => void }) => JSX.Element);
|
||||
title?: React.ReactNode;
|
||||
visible?: boolean;
|
||||
noFooter?: boolean;
|
||||
noHeader?: boolean;
|
||||
isMobile?: boolean;
|
||||
closeble?: boolean;
|
||||
type?: "modal" | "bottom-drawer";
|
||||
headerBordered?: boolean;
|
||||
modelClassName?: string;
|
||||
onOpen?: (v: boolean) => void;
|
||||
maskCloseble?: boolean;
|
||||
}
|
||||
|
||||
export interface WarnProps
|
||||
extends Omit<
|
||||
ModalProps,
|
||||
| "closeble"
|
||||
| "isMobile"
|
||||
| "noHeader"
|
||||
| "noFooter"
|
||||
| "onOk"
|
||||
| "okBtnProps"
|
||||
| "cancelBtnProps"
|
||||
| "content"
|
||||
> {
|
||||
onOk?: () => Promise<void> | void;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TriggerProps
|
||||
extends Omit<ModalProps, "visible" | "onOk" | "onCancel"> {
|
||||
children: JSX.Element;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const baseZIndex = 150;
|
||||
|
||||
let div: HTMLDivElement | null = null;
|
||||
|
||||
const Modal = (props: ModalProps) => {
|
||||
const {
|
||||
onOk,
|
||||
onCancel,
|
||||
okText,
|
||||
cancelText,
|
||||
content,
|
||||
title,
|
||||
visible,
|
||||
noFooter,
|
||||
noHeader,
|
||||
closeble = true,
|
||||
okBtnProps,
|
||||
cancelBtnProps,
|
||||
type = "modal",
|
||||
headerBordered,
|
||||
modelClassName,
|
||||
onOpen,
|
||||
maskCloseble = true,
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = useState(!!visible);
|
||||
|
||||
const mergeOpen = visible ?? open;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const div: HTMLDivElement = document.createElement("div");
|
||||
div.id = "confirm-root";
|
||||
div.style.height = "0px";
|
||||
document.body.appendChild(div);
|
||||
}, []);
|
||||
const root = createRoot(div);
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
};
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
setOpen(false);
|
||||
onOk?.();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onOpen?.(mergeOpen);
|
||||
}, [mergeOpen]);
|
||||
|
||||
let layoutClassName = "";
|
||||
let panelClassName = "";
|
||||
let titleClassName = "";
|
||||
let footerClassName = "";
|
||||
|
||||
switch (type) {
|
||||
case "bottom-drawer":
|
||||
layoutClassName = "fixed inset-0 flex flex-col w-[100%] bottom-0";
|
||||
panelClassName =
|
||||
"rounded-t-chat-model-select overflow-y-auto overflow-x-hidden";
|
||||
titleClassName = "px-4 py-3";
|
||||
footerClassName = "absolute w-[100%]";
|
||||
break;
|
||||
case "modal":
|
||||
default:
|
||||
layoutClassName =
|
||||
"fixed inset-0 flex flex-col item-start top-0 left-[50vw] translate-x-[-50%] max-sm:w-modal-modal-type-mobile";
|
||||
panelClassName = "rounded-lg px-6 sm:w-modal-modal-type";
|
||||
titleClassName = "py-6 max-sm:pb-3";
|
||||
footerClassName = "py-6";
|
||||
}
|
||||
const btnCommonClass = "px-4 py-2.5 rounded-md max-sm:flex-1";
|
||||
const { className: okBtnClass } = okBtnProps || {};
|
||||
const { className: cancelBtnClass } = cancelBtnProps || {};
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={mergeOpen} onOpenChange={setOpen}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay
|
||||
className="fixed inset-0 bg-modal-mask animate-mask "
|
||||
style={{ zIndex: baseZIndex - 1 }}
|
||||
onClick={() => {
|
||||
if (maskCloseble) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AlertDialog.Content
|
||||
className={`
|
||||
${layoutClassName}
|
||||
`}
|
||||
style={{ zIndex: baseZIndex - 1 }}
|
||||
>
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (maskCloseble) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col flex-0
|
||||
bg-moda-panel text-modal-panel
|
||||
${modelClassName}
|
||||
${panelClassName}
|
||||
`}
|
||||
>
|
||||
{!noHeader && (
|
||||
<AlertDialog.Title
|
||||
className={`
|
||||
flex items-center justify-between gap-3 font-common
|
||||
md:text-chat-header-title md:font-bold md:leading-5
|
||||
${
|
||||
headerBordered
|
||||
? " border-b border-modal-header-bottom"
|
||||
: ""
|
||||
}
|
||||
${titleClassName}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-start flex-1 gap-3 text-text-modal-title text-chat-header-title">
|
||||
{title}
|
||||
</div>
|
||||
{closeble && (
|
||||
<div
|
||||
className="items-center"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</div>
|
||||
)}
|
||||
</AlertDialog.Title>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
|
||||
{typeof content === "function"
|
||||
? content({
|
||||
close: () => {
|
||||
handleClose();
|
||||
},
|
||||
})
|
||||
: content}
|
||||
</div>
|
||||
{!noFooter && (
|
||||
<div
|
||||
className={`
|
||||
flex gap-3 sm:justify-end max-sm:justify-between
|
||||
${footerClassName}
|
||||
`}
|
||||
>
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Btn
|
||||
{...cancelBtnProps}
|
||||
onClick={() => handleClose()}
|
||||
text={cancelText}
|
||||
className={`${btnCommonClass} ${cancelBtnClass}`}
|
||||
/>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<Btn
|
||||
{...okBtnProps}
|
||||
onClick={handleOk}
|
||||
text={okText}
|
||||
className={`${btnCommonClass} ${okBtnClass}`}
|
||||
/>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{type === "modal" && (
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (maskCloseble) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const Warn = ({
|
||||
title,
|
||||
onOk,
|
||||
visible,
|
||||
content,
|
||||
...props
|
||||
}: WarnProps) => {
|
||||
const [internalVisible, setVisible] = useState(visible);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
title={
|
||||
<>
|
||||
<Warning />
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<AlertDialog.Description
|
||||
className={`
|
||||
font-common font-normal
|
||||
md:text-sm-title md:leading-[158%]
|
||||
`}
|
||||
>
|
||||
{content}
|
||||
</AlertDialog.Description>
|
||||
}
|
||||
closeble={false}
|
||||
onOk={() => {
|
||||
const toDo = onOk?.();
|
||||
if (toDo instanceof Promise) {
|
||||
toDo.then(() => {
|
||||
setVisible(false);
|
||||
});
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}}
|
||||
visible={internalVisible}
|
||||
okBtnProps={{
|
||||
className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
|
||||
}}
|
||||
cancelBtnProps={{
|
||||
className: `bg-delete-chat-cancel-btn border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
|
||||
const root = createRoot(div);
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
root.render(
|
||||
<Warn
|
||||
{...props}
|
||||
visible={true}
|
||||
onCancel={() => {
|
||||
closeModal();
|
||||
resolve(false);
|
||||
}}
|
||||
onOk={() => {
|
||||
closeModal();
|
||||
resolve(true);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const Trigger = (props: TriggerProps) => {
|
||||
const { children, className, content, ...rest } = props;
|
||||
|
||||
const [internalVisible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Modal
|
||||
{...rest}
|
||||
visible={internalVisible}
|
||||
onCancel={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
content={
|
||||
typeof content === "function"
|
||||
? content({
|
||||
close: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
})
|
||||
: content
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Modal.Trigger = Trigger;
|
||||
|
||||
export default Modal;
|
366
app/components/Popover/index.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
import useRelativePosition from "@/app/hooks/useRelativePosition";
|
||||
import {
|
||||
RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const ArrowIcon = ({ sibling }: { sibling: RefObject<HTMLDivElement> }) => {
|
||||
const [color, setColor] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (sibling.current) {
|
||||
const { backgroundColor } = window.getComputedStyle(sibling.current);
|
||||
setColor(backgroundColor);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="6"
|
||||
viewBox="0 0 16 6"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const baseZIndex = 100;
|
||||
const popoverRootName = "popoverRoot";
|
||||
|
||||
export interface PopoverProps {
|
||||
content?: JSX.Element | string;
|
||||
children?: JSX.Element;
|
||||
show?: boolean;
|
||||
onShow?: (v: boolean) => void;
|
||||
className?: string;
|
||||
popoverClassName?: string;
|
||||
trigger?: "hover" | "click";
|
||||
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b" | "l" | "r";
|
||||
noArrow?: boolean;
|
||||
delayClose?: number;
|
||||
useGlobalRoot?: boolean;
|
||||
getPopoverPanelRef?: (ref: RefObject<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
let popoverRoot: HTMLDivElement;
|
||||
|
||||
export default function Popover(props: PopoverProps) {
|
||||
const {
|
||||
content,
|
||||
children,
|
||||
show,
|
||||
onShow,
|
||||
className,
|
||||
popoverClassName,
|
||||
trigger = "hover",
|
||||
placement = "t",
|
||||
noArrow = false,
|
||||
delayClose = 0,
|
||||
useGlobalRoot,
|
||||
getPopoverPanelRef,
|
||||
} = props;
|
||||
|
||||
const [internalShow, setShow] = useState(false);
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
const popoverCommonClass = `absolute p-2 box-border`;
|
||||
|
||||
const mergedShow = show ?? internalShow;
|
||||
|
||||
const { arrowClassName, placementStyle, placementClassName } = useMemo(() => {
|
||||
const arrowCommonClassName = `${
|
||||
noArrow ? "hidden" : ""
|
||||
} absolute z-10 left-[50%] translate-x-[calc(-50%)]`;
|
||||
|
||||
let defaultTopPlacement = true; // when users dont config 't' or 'b'
|
||||
|
||||
const {
|
||||
distanceToBottomBoundary = 0,
|
||||
distanceToLeftBoundary = 0,
|
||||
distanceToRightBoundary = -10000,
|
||||
distanceToTopBoundary = 0,
|
||||
targetH = 0,
|
||||
targetW = 0,
|
||||
} = position?.poi || {};
|
||||
|
||||
if (distanceToBottomBoundary > distanceToTopBoundary) {
|
||||
defaultTopPlacement = false;
|
||||
}
|
||||
|
||||
const placements = {
|
||||
lt: {
|
||||
placementStyle: {
|
||||
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||
placementClassName: "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||
},
|
||||
lb: {
|
||||
placementStyle: {
|
||||
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||
placementClassName: "top-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||
},
|
||||
rt: {
|
||||
placementStyle: {
|
||||
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||
placementClassName: "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||
},
|
||||
rb: {
|
||||
placementStyle: {
|
||||
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||
placementClassName: "top-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||
},
|
||||
t: {
|
||||
placementStyle: {
|
||||
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||
placementClassName:
|
||||
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||
},
|
||||
b: {
|
||||
placementStyle: {
|
||||
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||
placementClassName:
|
||||
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||
},
|
||||
};
|
||||
|
||||
const getStyle = () => {
|
||||
if (["l", "r"].includes(placement)) {
|
||||
return placements[
|
||||
`${placement}${defaultTopPlacement ? "t" : "b"}` as
|
||||
| "lt"
|
||||
| "lb"
|
||||
| "rb"
|
||||
| "rt"
|
||||
];
|
||||
}
|
||||
return placements[placement as Exclude<typeof placement, "l" | "r">];
|
||||
};
|
||||
|
||||
return getStyle();
|
||||
}, [Object.values(position?.poi || {})]);
|
||||
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimer = useRef<number>(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
popoverRoot = document.querySelector(
|
||||
`#${popoverRootName}`,
|
||||
) as HTMLDivElement;
|
||||
if (!popoverRoot) {
|
||||
popoverRoot = document.createElement("div");
|
||||
document.body.appendChild(popoverRoot);
|
||||
popoverRoot.style.height = "0px";
|
||||
popoverRoot.style.width = "100%";
|
||||
popoverRoot.style.position = "fixed";
|
||||
popoverRoot.style.bottom = "0";
|
||||
popoverRoot.style.zIndex = "10000";
|
||||
popoverRoot.id = "popover-root";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
getPopoverPanelRef?.(popoverRef);
|
||||
onShow?.(internalShow);
|
||||
}, [internalShow]);
|
||||
|
||||
if (trigger === "click") {
|
||||
const handleOpen = (e: { currentTarget: any }) => {
|
||||
clearTimeout(closeTimer.current);
|
||||
setShow(true);
|
||||
getRelativePosition(e.currentTarget, "");
|
||||
window.document.documentElement.style.overflow = "hidden";
|
||||
};
|
||||
const handleClose = () => {
|
||||
if (delayClose) {
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
setShow(false);
|
||||
}, delayClose);
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
window.document.documentElement.style.overflow = "auto";
|
||||
};
|
||||
|
||||
if (mergedShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!mergedShow) {
|
||||
handleOpen(e);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{mergedShow && (
|
||||
<>
|
||||
{!noArrow && (
|
||||
<div className={`${arrowClassName}`}>
|
||||
<ArrowIcon sibling={popoverRef} />
|
||||
</div>
|
||||
)}
|
||||
{createPortal(
|
||||
<div
|
||||
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
|
||||
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||
ref={popoverRef}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
popoverRoot,
|
||||
)}
|
||||
{createPortal(
|
||||
<div
|
||||
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
|
||||
style={{ zIndex: baseZIndex }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
|
||||
</div>,
|
||||
popoverRoot,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (useGlobalRoot) {
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onPointerEnter={(e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(closeTimer.current);
|
||||
onShow?.(true);
|
||||
setShow(true);
|
||||
getRelativePosition(e.currentTarget, "");
|
||||
window.document.documentElement.style.overflow = "hidden";
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.preventDefault();
|
||||
if (delayClose) {
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
onShow?.(false);
|
||||
setShow(false);
|
||||
}, delayClose);
|
||||
} else {
|
||||
onShow?.(false);
|
||||
setShow(false);
|
||||
}
|
||||
window.document.documentElement.style.overflow = "auto";
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{mergedShow && (
|
||||
<>
|
||||
<div
|
||||
className={`${
|
||||
noArrow ? "opacity-0" : ""
|
||||
} bg-inherit ${arrowClassName}`}
|
||||
style={{ zIndex: baseZIndex + 1 }}
|
||||
>
|
||||
<ArrowIcon sibling={popoverRef} />
|
||||
</div>
|
||||
{createPortal(
|
||||
<div
|
||||
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
|
||||
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||
ref={popoverRef}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
popoverRoot,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group/popover relative ${className}`}
|
||||
onPointerEnter={(e) => {
|
||||
getRelativePosition(e.currentTarget, "");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className={`
|
||||
hidden group-hover/popover:block
|
||||
${noArrow ? "opacity-0" : ""}
|
||||
bg-inherit
|
||||
${arrowClassName}
|
||||
`}
|
||||
style={{ zIndex: baseZIndex + 1 }}
|
||||
>
|
||||
<ArrowIcon sibling={popoverRef} />
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
hidden group-hover/popover:block whitespace-nowrap
|
||||
${popoverCommonClass}
|
||||
${placementClassName}
|
||||
${popoverClassName}
|
||||
`}
|
||||
ref={popoverRef}
|
||||
style={{ zIndex: baseZIndex + 1 }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
71
app/components/Screen/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMemo, ReactNode } from "react";
|
||||
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
|
||||
import { getLang } from "@/app/locales";
|
||||
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
|
||||
import useListenWinResize from "@/app/hooks/useListenWinResize";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useDeviceInfo } from "@/app/hooks/useDeviceInfo";
|
||||
|
||||
interface ScreenProps {
|
||||
children: ReactNode;
|
||||
noAuth: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
}
|
||||
|
||||
export default function Screen(props: ScreenProps) {
|
||||
const pathname = usePathname();
|
||||
const isAuth = pathname === Path.Auth;
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const { deviceType, systemInfo } = useDeviceInfo();
|
||||
useListenWinResize();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex h-[100%] w-[100%] bg-center
|
||||
max-md:relative max-md:flex-col-reverse max-md:bg-global-mobile
|
||||
md:overflow-hidden md:bg-global
|
||||
`}
|
||||
style={{
|
||||
direction: getLang() === "ar" ? "rtl" : "ltr",
|
||||
}}
|
||||
>
|
||||
{isAuth ? (
|
||||
props.noAuth
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
max-md:absolute max-md:w-[100%] max-md:bottom-0 max-md:z-10
|
||||
md:flex-0 md:overflow-hidden
|
||||
`}
|
||||
id={SIDEBAR_ID}
|
||||
>
|
||||
{props.sidebar}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`
|
||||
h-[100%]
|
||||
max-md:w-[100%]
|
||||
md:flex-1 md:min-w-0 md:overflow-hidden md:flex
|
||||
`}
|
||||
id={SlotID.AppBody}
|
||||
style={{
|
||||
// #3016 disable transition on ios mobile screen
|
||||
transition:
|
||||
systemInfo === "iOS" && deviceType === "mobile"
|
||||
? "none"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
24
app/components/Search/index.module.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
.search {
|
||||
display: flex;
|
||||
max-width: 460px;
|
||||
height: 50px;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--Light-Text-Black, #18182A);
|
||||
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
|
||||
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
flex: 0 0;
|
||||
}
|
||||
.input {
|
||||
height: 18px;
|
||||
flex: 1 1;
|
||||
}
|
||||
}
|
30
app/components/Search/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import styles from "./index.module.scss";
|
||||
import SearchIcon from "@/app/icons/search.svg";
|
||||
|
||||
export interface SearchProps {
|
||||
value?: string;
|
||||
onSearch?: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const Search = (props: SearchProps) => {
|
||||
const { placeholder = "", value, onSearch } = props;
|
||||
return (
|
||||
<div className={styles["search"]}>
|
||||
<div className={styles["icon"]}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
<input
|
||||
className={styles["input"]}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
onSearch?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
118
app/components/Select/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import SelectIcon from "@/app/icons/downArrowIcon.svg";
|
||||
import Popover from "@/app/components/Popover";
|
||||
import React, { useContext, useMemo, useRef } from "react";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import List from "@/app/components/List";
|
||||
|
||||
import Selected from "@/app/icons/selectedIcon.svg";
|
||||
|
||||
export type Option<Value> = {
|
||||
value: Value;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export interface SearchProps<Value> {
|
||||
value?: string;
|
||||
onSelect?: (v: Value) => void;
|
||||
options?: Option<Value>[];
|
||||
inMobile?: boolean;
|
||||
}
|
||||
|
||||
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
|
||||
const { value, onSelect, options = [], inMobile } = props;
|
||||
|
||||
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
|
||||
|
||||
const optionsRef = useRef<Option<Value>[]>([]);
|
||||
optionsRef.current = options;
|
||||
const selectedOption = useMemo(
|
||||
() => optionsRef.current.find((o) => o.value === value),
|
||||
[value],
|
||||
);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
let headerH = 100;
|
||||
let baseH = position?.poi.distanceToBottomBoundary || 0;
|
||||
if (isMobileScreen) {
|
||||
headerH = 60;
|
||||
}
|
||||
if (position?.poi.relativePosition[1] === Orientation.bottom) {
|
||||
baseH = position?.poi.distanceToTopBoundary;
|
||||
}
|
||||
|
||||
const maxHeight = `${baseH - headerH}px`;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{options?.map((o) => (
|
||||
<div
|
||||
key={o.value}
|
||||
className={`
|
||||
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
|
||||
`}
|
||||
onClick={() => {
|
||||
onSelect?.(o.value);
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
|
||||
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
|
||||
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
|
||||
}
|
||||
>
|
||||
<Selected />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
noArrow
|
||||
placement={
|
||||
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
|
||||
}
|
||||
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
|
||||
onShow={(e) => {
|
||||
getRelativePosition(contentRef.current!, "");
|
||||
}}
|
||||
className={selectClassName}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
|
||||
ref={contentRef}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
|
||||
>
|
||||
{!!selectedOption?.icon && (
|
||||
<div className={``}>{selectedOption?.icon}</div>
|
||||
)}
|
||||
<div className={`flex-1`}>{selectedOption?.label}</div>
|
||||
</div>
|
||||
<div className={``}>
|
||||
<SelectIcon />
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
99
app/components/SlideRange/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { ListContext } from "@/app/components/List";
|
||||
import { useResizeObserver } from "usehooks-ts";
|
||||
|
||||
interface SlideRangeProps {
|
||||
className?: string;
|
||||
description?: string;
|
||||
range?: {
|
||||
start?: number;
|
||||
stroke?: number;
|
||||
};
|
||||
onSlide?: (v: number) => void;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const margin = 15;
|
||||
|
||||
export default function SlideRange(props: SlideRangeProps) {
|
||||
const {
|
||||
className = "",
|
||||
description = "",
|
||||
range = {},
|
||||
value,
|
||||
onSlide,
|
||||
step,
|
||||
} = props;
|
||||
const { start = 0, stroke = 1 } = range;
|
||||
|
||||
const { rangeClassName, update } = useContext(ListContext);
|
||||
|
||||
const slideRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useResizeObserver({
|
||||
ref: slideRef,
|
||||
onResize: () => {
|
||||
setProperty(value);
|
||||
},
|
||||
});
|
||||
|
||||
const transformToWidth = (x: number = start) => {
|
||||
const abs = x - start;
|
||||
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
|
||||
const result = (abs / stroke) * maxWidth;
|
||||
return result;
|
||||
};
|
||||
|
||||
const setProperty = (value?: number) => {
|
||||
const initWidth = transformToWidth(value);
|
||||
slideRef.current?.style.setProperty(
|
||||
"--slide-value-size",
|
||||
`${initWidth + margin}px`,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
update?.({ type: "range" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
|
||||
>
|
||||
{!!description && (
|
||||
<div className=" text-common text-sm ">{description}</div>
|
||||
)}
|
||||
<div
|
||||
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
|
||||
ref={slideRef}
|
||||
>
|
||||
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
|
||||
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
|
||||
// onPointerDown={onPointerDown}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
|
||||
value={value}
|
||||
min={start}
|
||||
max={start + stroke}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
setProperty(e.target.valueAsNumber);
|
||||
onSlide?.(e.target.valueAsNumber);
|
||||
}}
|
||||
style={{
|
||||
marginLeft: margin,
|
||||
marginRight: margin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
33
app/components/Switch/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||
import { useContext } from "react";
|
||||
import List from "../List";
|
||||
|
||||
interface SwitchProps {
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const { switchClassName = "" } = useContext(List.ListContext);
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className={`
|
||||
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
|
||||
${switchClassName}
|
||||
${
|
||||
value
|
||||
? "bg-switch-checked justify-end"
|
||||
: "bg-switch-unchecked justify-start"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<RadixSwitch.Thumb
|
||||
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
|
||||
/>
|
||||
</RadixSwitch.Root>
|
||||
);
|
||||
}
|
27
app/components/ThumbnailImg/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
|
||||
|
||||
export interface ThumbnailProps {
|
||||
image: string;
|
||||
deleteImage: () => void;
|
||||
}
|
||||
|
||||
export default function Thumbnail(props: ThumbnailProps) {
|
||||
const { image, deleteImage } = props;
|
||||
return (
|
||||
<div
|
||||
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
|
||||
style={{ backgroundImage: `url("${image}")` }}
|
||||
>
|
||||
<div
|
||||
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
|
||||
>
|
||||
<div
|
||||
className={`cursor-pointer flex items-center justify-center float-right`}
|
||||
onClick={deleteImage}
|
||||
>
|
||||
<ImgDeleteIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -6,6 +6,8 @@
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: var(--white);
|
||||
|
||||
.auth-logo {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
@@ -33,4 +35,18 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
input[type="number"],
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
border: var(--border-in-light);
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
background: var(--white);
|
||||
color: var(--black);
|
||||
padding: 0 10px;
|
||||
max-width: 50%;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
import styles from "./auth.module.scss";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Path } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
@@ -11,11 +12,11 @@ import { useEffect } from "react";
|
||||
import { getClientConfig } from "../config/client";
|
||||
|
||||
export function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const goHome = () => navigate(Path.Home);
|
||||
const goChat = () => navigate(Path.Chat);
|
||||
const goHome = () => router.push(Path.Home);
|
||||
const goChat = () => router.push(Path.Chat);
|
||||
const resetAccessCode = () => {
|
||||
accessStore.update((access) => {
|
||||
access.openaiApiKey = "";
|
||||
|
@@ -12,13 +12,14 @@ import {
|
||||
import { useChatStore } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
// import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import { Mask } from "../store/mask";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { useMobileScreen } from "../utils";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export function ChatItem(props: {
|
||||
onClick?: () => void;
|
||||
@@ -41,14 +42,14 @@ export function ChatItem(props: {
|
||||
}
|
||||
}, [props.selected]);
|
||||
|
||||
const { pathname: currentPath } = useLocation();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={`${styles["chat-item"]} ${
|
||||
props.selected &&
|
||||
(currentPath === Path.Chat || currentPath === Path.Home) &&
|
||||
(pathname === Path.Chat || pathname === Path.Home) &&
|
||||
styles["chat-item-selected"]
|
||||
}`}
|
||||
onClick={props.onClick}
|
||||
@@ -112,8 +113,8 @@ export function ChatList(props: { narrow?: boolean }) {
|
||||
],
|
||||
);
|
||||
const chatStore = useChatStore();
|
||||
const navigate = useNavigate();
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const router = useRouter();
|
||||
|
||||
const onDragEnd: OnDragEndResponder = (result) => {
|
||||
const { destination, source } = result;
|
||||
@@ -150,7 +151,8 @@ export function ChatList(props: { narrow?: boolean }) {
|
||||
index={i}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
navigate(Path.Chat);
|
||||
// navigate(Path.Chat);
|
||||
router.push(Path.Chat);
|
||||
selectSession(i);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
|
@@ -97,6 +97,7 @@ import { ExportMessageModal } from "./exporter";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { useAllModels } from "../utils/hooks";
|
||||
import { MultimodalContent } from "../client/api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
@@ -428,7 +429,7 @@ export function ChatActions(props: {
|
||||
uploading: boolean;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// switch themes
|
||||
@@ -543,7 +544,8 @@ export function ChatActions(props: {
|
||||
|
||||
<ChatAction
|
||||
onClick={() => {
|
||||
navigate(Path.Masks);
|
||||
// navigate(Path.Masks);
|
||||
router.push(Path.Masks);
|
||||
}}
|
||||
text={Locale.Chat.InputActions.Masks}
|
||||
icon={<MaskIcon />}
|
||||
|
@@ -2,6 +2,9 @@
|
||||
&-body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.export-content {
|
||||
|
@@ -177,13 +177,14 @@ export function Markdown(
|
||||
fontSize?: number;
|
||||
parentRef?: RefObject<HTMLDivElement>;
|
||||
defaultShow?: boolean;
|
||||
className?: string;
|
||||
} & React.DOMAttributes<HTMLDivElement>,
|
||||
) {
|
||||
const mdRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-body"
|
||||
className={`markdown-body ${props.className}`}
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
}}
|
||||
|
@@ -4,6 +4,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.mask-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import styles from "./mask.module.scss";
|
||||
|
||||
@@ -56,6 +55,7 @@ import {
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
|
||||
// drag and drop helper function
|
||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||
@@ -398,7 +398,7 @@ export function ContextPrompts(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function MaskPage() {
|
||||
export function MaskPage(props: { className?: string }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const maskStore = useMaskStore();
|
||||
@@ -466,8 +466,13 @@ export function MaskPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
${styles["mask-page"]}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
@@ -645,6 +650,6 @@ export function MaskPage() {
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -8,6 +8,10 @@
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.mask-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
|
||||
import { useCommand } from "../command";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { BUILTIN_MASK_STORE } from "../masks";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
|
||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||
return (
|
||||
@@ -71,7 +72,7 @@ function useMaskGroup(masks: Mask[]) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function NewChat() {
|
||||
export function NewChat(props: { className?: string }) {
|
||||
const chatStore = useChatStore();
|
||||
const maskStore = useMaskStore();
|
||||
|
||||
@@ -110,8 +111,15 @@ export function NewChat() {
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
|
||||
return (
|
||||
<div className={styles["new-chat"]}>
|
||||
<div
|
||||
className={`
|
||||
${styles["new-chat"]}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
<div className={styles["mask-header"]}>
|
||||
<IconButton
|
||||
icon={<LeftIcon />}
|
||||
|
@@ -27,9 +27,9 @@ import {
|
||||
} from "../constant";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { isIOS, useMobileScreen } from "../utils";
|
||||
import dynamic from "next/dynamic";
|
||||
import { showConfirm, showToast } from "./ui-lib";
|
||||
import { useDeviceInfo } from "../hooks/useDeviceInfo";
|
||||
|
||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||
loading: () => null,
|
||||
@@ -130,16 +130,11 @@ function useDragSideBar() {
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const { deviceType, systemInfo } = useDeviceInfo();
|
||||
// drag side bar
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const isIOSMobile = useMemo(
|
||||
() => isIOS() && isMobileScreen,
|
||||
[isMobileScreen],
|
||||
);
|
||||
|
||||
useHotKey();
|
||||
|
||||
@@ -150,7 +145,8 @@ export function SideBar(props: { className?: string }) {
|
||||
}`}
|
||||
style={{
|
||||
// #3016 disable transition on ios mobile screen
|
||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
||||
transition:
|
||||
deviceType === "mobile" && systemInfo === "iOS" ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
||||
|
@@ -101,6 +101,7 @@ interface ModalProps {
|
||||
defaultMax?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
export function Modal(props: ModalProps) {
|
||||
useEffect(() => {
|
||||
@@ -122,14 +123,14 @@ export function Modal(props: ModalProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||||
}
|
||||
className={`${styles["modal-container"]} ${
|
||||
isMax && styles["modal-container-max"]
|
||||
} ${props.className ?? ""}`}
|
||||
>
|
||||
<div className={styles["modal-header"]}>
|
||||
<div className={styles["modal-title"]}>{props.title}</div>
|
||||
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
|
||||
<div className={`${styles["modal-title"]}`}>{props.title}</div>
|
||||
|
||||
<div className={styles["modal-header-actions"]}>
|
||||
<div className={`${styles["modal-header-actions"]}`}>
|
||||
<div
|
||||
className={styles["modal-header-action"]}
|
||||
onClick={() => setMax(!isMax)}
|
||||
@@ -147,11 +148,11 @@ export function Modal(props: ModalProps) {
|
||||
|
||||
<div className={styles["modal-content"]}>{props.children}</div>
|
||||
|
||||
<div className={styles["modal-footer"]}>
|
||||
<div className={`${styles["modal-footer"]} new-footer`}>
|
||||
{props.footer}
|
||||
<div className={styles["modal-actions"]}>
|
||||
{props.actions?.map((action, i) => (
|
||||
<div key={i} className={styles["modal-action"]}>
|
||||
<div key={i} className={`${styles["modal-action"]} new-btn`}>
|
||||
{action}
|
||||
</div>
|
||||
))}
|
||||
|
@@ -3,7 +3,12 @@ import { BuildConfig, getBuildConfig } from "./build";
|
||||
export function getClientConfig() {
|
||||
if (typeof document !== "undefined") {
|
||||
// client side
|
||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
||||
try {
|
||||
const config = JSON.parse(queryMeta("config")) as BuildConfig;
|
||||
return config;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined") {
|
||||
|
@@ -49,11 +49,18 @@ export enum StoreKey {
|
||||
Sync = "sync",
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||
export const MAX_SIDEBAR_WIDTH = 500;
|
||||
export const MIN_SIDEBAR_WIDTH = 230;
|
||||
export const NARROW_SIDEBAR_WIDTH = 100;
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 340;
|
||||
export const MAX_SIDEBAR_WIDTH = 440;
|
||||
export const MIN_SIDEBAR_WIDTH = 230;
|
||||
|
||||
export const WINDOW_WIDTH_SM = 480;
|
||||
export const WINDOW_WIDTH_MD = 768;
|
||||
export const WINDOW_WIDTH_LG = 1120;
|
||||
export const WINDOW_WIDTH_XL = 1440;
|
||||
export const WINDOW_WIDTH_2XL = 1980;
|
||||
|
||||
export const ACCESS_CODE_PREFIX = "nk-";
|
||||
|
||||
export const LAST_INPUT_KEY = "last-input";
|
||||
@@ -218,3 +225,5 @@ export const internalWhiteWebDavEndpoints = [
|
||||
"https://webdav.yandex.com",
|
||||
"https://app.koofr.net/dav/Koofr",
|
||||
];
|
||||
|
||||
export const SIDEBAR_ID = "sidebar";
|
||||
|
301
app/containers/Chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
useChatStore,
|
||||
BOT_HELLO,
|
||||
createMessage,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
ModelType,
|
||||
} from "@/app/store";
|
||||
import Locale from "@/app/locales";
|
||||
import { Selector, showConfirm, showToast } from "@/app/components/ui-lib";
|
||||
import {
|
||||
CHAT_PAGE_SIZE,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
UNFINISHED_INPUT,
|
||||
} from "@/app/constant";
|
||||
import { useCommand } from "@/app/command";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { ExportMessageModal } from "@/app/components/exporter";
|
||||
|
||||
import PromptToast from "./components/PromptToast";
|
||||
import { EditMessageModal } from "./components/EditMessageModal";
|
||||
import ChatHeader from "./components/ChatHeader";
|
||||
import ChatInputPanel, {
|
||||
ChatInputPanelInstance,
|
||||
} from "./components/ChatInputPanel";
|
||||
import ChatMessagePanel, { RenderMessage } from "./components/ChatMessagePanel";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import useRows from "@/app/hooks/useRows";
|
||||
import SessionConfigModel from "./components/SessionConfigModal";
|
||||
import useScrollToBottom from "@/app/hooks/useScrollToBottom";
|
||||
|
||||
function _Chat() {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputPanelRef = useRef<ChatInputPanelInstance | null>(null);
|
||||
|
||||
const [hitBottom, setHitBottom] = useState(true);
|
||||
|
||||
const [attachImages, setAttachImages] = useState<string[]>([]);
|
||||
|
||||
// auto grow input
|
||||
const { measure, inputRows } = useRows({
|
||||
inputRef,
|
||||
});
|
||||
|
||||
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(measure, [userInput]);
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
|
||||
session.messages.forEach((m) => {
|
||||
// check if should stop all stale messages
|
||||
if (m.isError || new Date(m.date).getTime() < stopTiming) {
|
||||
if (m.streaming) {
|
||||
m.streaming = false;
|
||||
}
|
||||
|
||||
if (m.content.length === 0) {
|
||||
m.isError = true;
|
||||
m.content = prettyObject({
|
||||
error: true,
|
||||
message: "empty response",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto sync mask config from global config
|
||||
if (session.mask.syncGlobalConfig) {
|
||||
console.log("[Mask] syncing from global, name = ", session.mask.name);
|
||||
session.mask.modelConfig = { ...config.modelConfig };
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const context: RenderMessage[] = useMemo(() => {
|
||||
return session.mask.hideContext ? [] : session.mask.context.slice();
|
||||
}, [session.mask.context, session.mask.hideContext]);
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
if (
|
||||
context.length === 0 &&
|
||||
session.messages.at(0)?.content !== BOT_HELLO.content
|
||||
) {
|
||||
const copiedHello = Object.assign({}, BOT_HELLO);
|
||||
if (!accessStore.isAuthorized()) {
|
||||
copiedHello.content = Locale.Error.Unauthorized;
|
||||
}
|
||||
context.push(copiedHello);
|
||||
}
|
||||
|
||||
// preview messages
|
||||
const renderMessages = useMemo(() => {
|
||||
return context
|
||||
.concat(session.messages as RenderMessage[])
|
||||
.concat(
|
||||
isLoading
|
||||
? [
|
||||
{
|
||||
...createMessage({
|
||||
role: "assistant",
|
||||
content: "……",
|
||||
}),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
.concat(
|
||||
userInput.length > 0 && config.sendPreviewBubble
|
||||
? [
|
||||
{
|
||||
...createMessage(
|
||||
{
|
||||
role: "user",
|
||||
content: userInput,
|
||||
},
|
||||
{
|
||||
customId: "typing",
|
||||
},
|
||||
),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}, [
|
||||
config.sendPreviewBubble,
|
||||
context,
|
||||
isLoading,
|
||||
session.messages,
|
||||
userInput,
|
||||
]);
|
||||
|
||||
const [msgRenderIndex, _setMsgRenderIndex] = useState(
|
||||
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
|
||||
);
|
||||
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
useCommand({
|
||||
fill: setUserInput,
|
||||
submit: (text) => {
|
||||
chatInputPanelRef.current?.doSubmit(text);
|
||||
},
|
||||
code: (text) => {
|
||||
if (accessStore.disableFastLink) return;
|
||||
console.log("[Command] got code from url: ", text);
|
||||
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
|
||||
if (res) {
|
||||
accessStore.update((access) => (access.accessCode = text));
|
||||
}
|
||||
});
|
||||
},
|
||||
settings: (text) => {
|
||||
if (accessStore.disableFastLink) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as {
|
||||
key?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
console.log("[Command] got settings from url: ", payload);
|
||||
|
||||
if (payload.key || payload.url) {
|
||||
showConfirm(
|
||||
Locale.URLCommand.Settings +
|
||||
`\n${JSON.stringify(payload, null, 4)}`,
|
||||
).then((res) => {
|
||||
if (!res) return;
|
||||
if (payload.key) {
|
||||
accessStore.update(
|
||||
(access) => (access.openaiApiKey = payload.key!),
|
||||
);
|
||||
}
|
||||
if (payload.url) {
|
||||
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("[Command] failed to get settings from url: ", text);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// edit / insert message modal
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||
|
||||
// remember unfinished input
|
||||
useEffect(() => {
|
||||
// try to load from local storage
|
||||
const key = UNFINISHED_INPUT(session.id);
|
||||
const mayBeUnfinishedInput = localStorage.getItem(key);
|
||||
if (mayBeUnfinishedInput && userInput.length === 0) {
|
||||
setUserInput(mayBeUnfinishedInput);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
const dom = inputRef.current;
|
||||
return () => {
|
||||
localStorage.setItem(key, dom?.value ?? "");
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const chatinputPanelProps = {
|
||||
inputRef,
|
||||
isMobileScreen,
|
||||
renderMessages,
|
||||
attachImages,
|
||||
userInput,
|
||||
hitBottom,
|
||||
inputRows,
|
||||
setAttachImages,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
showChatSetting: setShowPromptModal,
|
||||
_setMsgRenderIndex,
|
||||
scrollDomToBottom,
|
||||
setAutoScroll,
|
||||
};
|
||||
|
||||
const chatMessagePanelProps = {
|
||||
scrollRef,
|
||||
inputRef,
|
||||
isMobileScreen,
|
||||
msgRenderIndex,
|
||||
userInput,
|
||||
context,
|
||||
renderMessages,
|
||||
setAutoScroll,
|
||||
setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex,
|
||||
setHitBottom,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-col overflow-hidden bg-chat-panel
|
||||
max-md:absolute max-md:h-[100vh] max-md:w-[100%]
|
||||
md:h-[100%] md:mr-2.5 md:rounded-md
|
||||
`}
|
||||
key={session.id}
|
||||
>
|
||||
<ChatHeader
|
||||
setIsEditingMessage={setIsEditingMessage}
|
||||
setShowExport={setShowExport}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
|
||||
<ChatMessagePanel {...chatMessagePanelProps} />
|
||||
|
||||
<ChatInputPanel ref={chatInputPanelRef} {...chatinputPanelProps} />
|
||||
|
||||
{showExport && (
|
||||
<ExportMessageModal onClose={() => setShowExport(false)} />
|
||||
)}
|
||||
|
||||
{isEditingMessage && (
|
||||
<EditMessageModal
|
||||
onClose={() => {
|
||||
setIsEditingMessage(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PromptToast showToast={!hitBottom} setShowModal={setShowPromptModal} />
|
||||
|
||||
{showPromptModal && (
|
||||
<SessionConfigModel onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const chatStore = useChatStore();
|
||||
const sessionIndex = chatStore.currentSessionIndex;
|
||||
return <_Chat key={sessionIndex}></_Chat>;
|
||||
}
|
276
app/containers/Chat/components/ChatActions.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { ModelType, Theme, useAppConfig } from "@/app/store/config";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { ChatControllerPool } from "@/app/client/controller";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { isVisionModel } from "@/app/utils";
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
|
||||
import BottomIcon from "@/app/icons/bottom.svg";
|
||||
import StopIcon from "@/app/icons/pause.svg";
|
||||
import LoadingButtonIcon from "@/app/icons/loading.svg";
|
||||
import PromptIcon from "@/app/icons/comandIcon.svg";
|
||||
import MaskIcon from "@/app/icons/maskIcon.svg";
|
||||
import BreakIcon from "@/app/icons/eraserIcon.svg";
|
||||
import SettingsIcon from "@/app/icons/configIcon.svg";
|
||||
import ImageIcon from "@/app/icons/uploadImgIcon.svg";
|
||||
import AddCircleIcon from "@/app/icons/addCircle.svg";
|
||||
|
||||
import Popover from "@/app/components/Popover";
|
||||
import ModelSelect from "./ModelSelect";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export interface Action {
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
isShow: boolean;
|
||||
render?: (key: string) => JSX.Element;
|
||||
icon?: JSX.Element;
|
||||
placement: "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatActions(props: {
|
||||
uploadImage: () => void;
|
||||
setAttachImages: (images: string[]) => void;
|
||||
setUploading: (uploading: boolean) => void;
|
||||
showChatSetting: () => void;
|
||||
scrollToBottom: () => void;
|
||||
showPromptHints: () => void;
|
||||
hitBottom: boolean;
|
||||
uploading: boolean;
|
||||
isMobileScreen: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// switch themes
|
||||
const theme = config.theme;
|
||||
function nextTheme() {
|
||||
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
|
||||
const themeIndex = themes.indexOf(theme);
|
||||
const nextIndex = (themeIndex + 1) % themes.length;
|
||||
const nextTheme = themes[nextIndex];
|
||||
config.update((config) => (config.theme = nextTheme));
|
||||
}
|
||||
|
||||
// stop all responses
|
||||
const couldStop = ChatControllerPool.hasPending();
|
||||
const stopAll = () => ChatControllerPool.stopAll();
|
||||
|
||||
// switch model
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
const allModels = useAllModels();
|
||||
const models = useMemo(
|
||||
() => allModels.filter((m) => m.available),
|
||||
[allModels],
|
||||
);
|
||||
const [showUploadImage, setShowUploadImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const show = isVisionModel(currentModel);
|
||||
setShowUploadImage(show);
|
||||
if (!show) {
|
||||
props.setAttachImages([]);
|
||||
props.setUploading(false);
|
||||
}
|
||||
|
||||
// if current model is not available
|
||||
// switch to first available model
|
||||
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
||||
if (isUnavaliableModel && models.length > 0) {
|
||||
const nextModel = models[0].name as ModelType;
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.mask.modelConfig.model = nextModel),
|
||||
);
|
||||
showToast(nextModel);
|
||||
}
|
||||
}, [chatStore, currentModel, models]);
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
onClick: stopAll,
|
||||
text: Locale.Chat.InputActions.Stop,
|
||||
isShow: couldStop,
|
||||
icon: <StopIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
text: currentModel,
|
||||
isShow: !props.isMobileScreen,
|
||||
render: (key: string) => <ModelSelect key={key} />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: props.scrollToBottom,
|
||||
text: Locale.Chat.InputActions.ToBottom,
|
||||
isShow: !props.hitBottom,
|
||||
icon: <BottomIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: props.uploadImage,
|
||||
text: Locale.Chat.InputActions.UploadImage,
|
||||
isShow: showUploadImage,
|
||||
icon: props.uploading ? <LoadingButtonIcon /> : <ImageIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
// {
|
||||
// onClick: nextTheme,
|
||||
// text: Locale.Chat.InputActions.Theme[theme],
|
||||
// isShow: true,
|
||||
// icon: (
|
||||
// <>
|
||||
// {theme === Theme.Auto ? (
|
||||
// <AutoIcon />
|
||||
// ) : theme === Theme.Light ? (
|
||||
// <LightIcon />
|
||||
// ) : theme === Theme.Dark ? (
|
||||
// <DarkIcon />
|
||||
// ) : null}
|
||||
// </>
|
||||
// ),
|
||||
// placement: "left",
|
||||
// },
|
||||
{
|
||||
onClick: props.showPromptHints,
|
||||
text: Locale.Chat.InputActions.Prompt,
|
||||
isShow: true,
|
||||
icon: <PromptIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
router.push(Path.Masks);
|
||||
},
|
||||
text: Locale.Chat.InputActions.Masks,
|
||||
isShow: true,
|
||||
icon: <MaskIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
if (session.clearContextIndex === session.messages.length) {
|
||||
session.clearContextIndex = undefined;
|
||||
} else {
|
||||
session.clearContextIndex = session.messages.length;
|
||||
session.memoryPrompt = ""; // will clear memory
|
||||
}
|
||||
});
|
||||
},
|
||||
text: Locale.Chat.InputActions.Clear,
|
||||
isShow: true,
|
||||
icon: <BreakIcon />,
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
onClick: props.showChatSetting,
|
||||
text: Locale.Chat.InputActions.Settings,
|
||||
isShow: true,
|
||||
icon: <SettingsIcon />,
|
||||
placement: "right",
|
||||
},
|
||||
];
|
||||
|
||||
if (props.isMobileScreen) {
|
||||
const content = (
|
||||
<div className="w-[100%]">
|
||||
{actions
|
||||
.filter((v) => v.isShow && v.icon)
|
||||
.map((act) => {
|
||||
return (
|
||||
<div
|
||||
key={act.text}
|
||||
className={`flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer follow-parent-svg default-icon-color`}
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
<div className="flex-1 font-common text-actions-popover-menu-item">
|
||||
{act.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
placement="rt"
|
||||
noArrow
|
||||
popoverClassName="border border-chat-actions-popover-mobile rounded-md shadow-chat-actions-popover-mobile w-actions-popover bg-chat-actions-popover-panel-mobile "
|
||||
className="cursor-pointer follow-parent-svg default-icon-color"
|
||||
>
|
||||
<AddCircleIcon />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const popoverClassName = `bg-chat-actions-btn-popover px-3 py-2.5 text-text-chat-actions-btn-popover text-sm-title rounded-md`;
|
||||
|
||||
return (
|
||||
<div className={`flex gap-2 item-center ${props.className}`}>
|
||||
{actions
|
||||
.filter((v) => v.placement === "left" && v.isShow)
|
||||
.map((act, ind) => {
|
||||
if (act.render) {
|
||||
return (
|
||||
<div className={`${act.className ?? ""}`} key={act.text}>
|
||||
{act.render(act.text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
key={act.text}
|
||||
content={act.text}
|
||||
popoverClassName={`${popoverClassName}`}
|
||||
placement={ind ? "t" : "lt"}
|
||||
className={`${act.className ?? ""}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer h-[32px] w-[32px] flex items-center justify-center transition duration-300 ease-in-out
|
||||
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
|
||||
follow-parent-svg default-icon-color
|
||||
`}
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1"></div>
|
||||
{actions
|
||||
.filter((v) => v.placement === "right" && v.isShow)
|
||||
.map((act, ind, arr) => {
|
||||
return (
|
||||
<Popover
|
||||
key={act.text}
|
||||
content={act.text}
|
||||
popoverClassName={`${popoverClassName}`}
|
||||
placement={ind === arr.length - 1 ? "rt" : "t"}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer h-[32px] w-[32px] flex items-center transition duration-300 ease-in-out justify-center
|
||||
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
|
||||
follow-parent-svg default-icon-color
|
||||
`}
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
91
app/containers/Chat/components/ChatHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
import { DEFAULT_TOPIC, useChatStore } from "@/app/store/chat";
|
||||
|
||||
import LogIcon from "@/app/icons/logIcon.svg";
|
||||
import GobackIcon from "@/app/icons/goback.svg";
|
||||
import ShareIcon from "@/app/icons/shareIcon.svg";
|
||||
import ModelSelect from "./ModelSelect";
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
isMobileScreen: boolean;
|
||||
setIsEditingMessage: (v: boolean) => void;
|
||||
setShowExport: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ChatHeader(props: ChatHeaderProps) {
|
||||
const { isMobileScreen, setIsEditingMessage, setShowExport } = props;
|
||||
|
||||
// const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
absolute w-[100%] backdrop-blur-[30px] z-20 flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap
|
||||
sm:border-b sm:border-chat-header-bottom
|
||||
max-md:h-menu-title-mobile
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
className={`absolute z-[-1] top-0 left-0 w-[100%] h-[100%] opacity-85 backdrop-blur-[20px] sm:bg-chat-panel-header-mask bg-chat-panel-header-mobile flex flex-0 justify-between items-center gap-chat-header-gap`}
|
||||
>
|
||||
{" "}
|
||||
</div>
|
||||
|
||||
{isMobileScreen ? (
|
||||
<div
|
||||
className="cursor-pointer follow-parent-svg default-icon-color"
|
||||
onClick={() => router.push(Path.Home)}
|
||||
>
|
||||
<GobackIcon />
|
||||
</div>
|
||||
) : (
|
||||
<LogIcon />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex-1
|
||||
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
|
||||
md:mr-4
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
line-clamp-1 cursor-pointer text-text-chat-header-title text-chat-header-title font-common
|
||||
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5
|
||||
`}
|
||||
onClickCapture={() => setIsEditingMessage(true)}
|
||||
>
|
||||
{!session.topic ? DEFAULT_TOPIC : session.topic}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
text-text-chat-header-subtitle text-sm
|
||||
max-md:text-sm-mobile-tab max-md:leading-4
|
||||
`}
|
||||
>
|
||||
{isMobileScreen ? (
|
||||
<ModelSelect />
|
||||
) : (
|
||||
Locale.Chat.SubTitle(session.messages.length)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className=" cursor-pointer hover:bg-hovered-btn p-1.5 rounded-action-btn follow-parent-svg default-icon-color"
|
||||
onClick={() => {
|
||||
setShowExport(true);
|
||||
}}
|
||||
>
|
||||
<ShareIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
323
app/containers/Chat/components/ChatInputPanel.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import useUploadImage from "@/app/hooks/useUploadImage";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import useSubmitHandler from "@/app/hooks/useSubmitHandler";
|
||||
import { CHAT_PAGE_SIZE, LAST_INPUT_KEY, Path } from "@/app/constant";
|
||||
import { ChatCommandPrefix, useChatCommand } from "@/app/command";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { usePromptStore } from "@/app/store/prompt";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import { useRouter } from "next/navigation";
|
||||
import usePaste from "@/app/hooks/usePaste";
|
||||
|
||||
import { ChatActions } from "./ChatActions";
|
||||
import PromptHints, { RenderPompt } from "./PromptHint";
|
||||
|
||||
// import CEIcon from "@/app/icons/command&enterIcon.svg";
|
||||
// import EnterIcon from "@/app/icons/enterIcon.svg";
|
||||
import SendIcon from "@/app/icons/sendIcon.svg";
|
||||
|
||||
import Btn from "@/app/components/Btn";
|
||||
import Thumbnail from "@/app/components/ThumbnailImg";
|
||||
|
||||
export interface ChatInputPanelProps {
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isMobileScreen: boolean;
|
||||
renderMessages: any[];
|
||||
attachImages: string[];
|
||||
userInput: string;
|
||||
hitBottom: boolean;
|
||||
inputRows: number;
|
||||
setAttachImages: (imgs: string[]) => void;
|
||||
setUserInput: (v: string) => void;
|
||||
setIsLoading: (value: boolean) => void;
|
||||
showChatSetting: (value: boolean) => void;
|
||||
_setMsgRenderIndex: (value: number) => void;
|
||||
setAutoScroll: (value: boolean) => void;
|
||||
scrollDomToBottom: () => void;
|
||||
}
|
||||
|
||||
export interface ChatInputPanelInstance {
|
||||
setUploading: (v: boolean) => void;
|
||||
doSubmit: (userInput: string) => void;
|
||||
setMsgRenderIndex: (v: number) => void;
|
||||
}
|
||||
|
||||
// only search prompts when user input is short
|
||||
const SEARCH_TEXT_LIMIT = 30;
|
||||
|
||||
export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
function ChatInputPanel(props, ref) {
|
||||
const {
|
||||
attachImages,
|
||||
inputRef,
|
||||
setAttachImages,
|
||||
userInput,
|
||||
isMobileScreen,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
showChatSetting,
|
||||
renderMessages,
|
||||
_setMsgRenderIndex,
|
||||
hitBottom,
|
||||
inputRows,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
} = props;
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { uploadImage } = useUploadImage(attachImages, {
|
||||
emitImages: setAttachImages,
|
||||
setUploading,
|
||||
});
|
||||
const { submitKey, shouldSubmit } = useSubmitHandler();
|
||||
|
||||
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
|
||||
|
||||
// chat commands shortcuts
|
||||
const chatCommands = useChatCommand({
|
||||
new: () => chatStore.newSession(),
|
||||
newm: () => router.push(Path.NewChat),
|
||||
prev: () => chatStore.nextSession(-1),
|
||||
next: () => chatStore.nextSession(1),
|
||||
clear: () =>
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = session.messages.length),
|
||||
),
|
||||
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
|
||||
});
|
||||
|
||||
// prompt hints
|
||||
const promptStore = usePromptStore();
|
||||
const onSearch = useDebouncedCallback(
|
||||
(text: string) => {
|
||||
const matchedPrompts = promptStore.search(text);
|
||||
setPromptHints(matchedPrompts);
|
||||
},
|
||||
100,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
// check if should send message
|
||||
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// if ArrowUp and no userInput, fill with last input
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
userInput.length <= 0 &&
|
||||
!(e.metaKey || e.altKey || e.ctrlKey)
|
||||
) {
|
||||
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (shouldSubmit(e) && promptHints.length === 0) {
|
||||
doSubmit(userInput);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onPromptSelect = (prompt: RenderPompt) => {
|
||||
setTimeout(() => {
|
||||
setPromptHints([]);
|
||||
|
||||
const matchedChatCommand = chatCommands.match(prompt.content);
|
||||
if (matchedChatCommand.matched) {
|
||||
// if user is selecting a chat command, just trigger it
|
||||
matchedChatCommand.invoke();
|
||||
setUserInput("");
|
||||
} else {
|
||||
// or fill the prompt
|
||||
setUserInput(prompt.content);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const doSubmit = (userInput: string) => {
|
||||
if (userInput.trim() === "") return;
|
||||
const matchCommand = chatCommands.match(userInput);
|
||||
if (matchCommand.matched) {
|
||||
setUserInput("");
|
||||
setPromptHints([]);
|
||||
matchCommand.invoke();
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
chatStore
|
||||
.onUserInput(userInput, attachImages)
|
||||
.then(() => setIsLoading(false));
|
||||
setAttachImages([]);
|
||||
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||
setUserInput("");
|
||||
setPromptHints([]);
|
||||
if (!isMobileScreen) inputRef.current?.focus();
|
||||
setAutoScroll(true);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setUploading,
|
||||
doSubmit,
|
||||
setMsgRenderIndex,
|
||||
}));
|
||||
|
||||
function scrollToBottom() {
|
||||
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
||||
scrollDomToBottom();
|
||||
}
|
||||
|
||||
const onInput = (text: string) => {
|
||||
setUserInput(text);
|
||||
const n = text.trim().length;
|
||||
|
||||
// clear search results
|
||||
if (n === 0) {
|
||||
setPromptHints([]);
|
||||
} else if (text.startsWith(ChatCommandPrefix)) {
|
||||
setPromptHints(chatCommands.search(text));
|
||||
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
||||
// check if need to trigger auto completion
|
||||
if (text.startsWith("/")) {
|
||||
let searchText = text.slice(1);
|
||||
onSearch(searchText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function setMsgRenderIndex(newIndex: number) {
|
||||
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
|
||||
newIndex = Math.max(0, newIndex);
|
||||
_setMsgRenderIndex(newIndex);
|
||||
}
|
||||
|
||||
const { handlePaste } = usePaste(attachImages, {
|
||||
emitImages: setAttachImages,
|
||||
setUploading,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative w-[100%] box-border
|
||||
max-md:rounded-tl-md max-md:rounded-tr-md
|
||||
md:border-t md:border-chat-input-top
|
||||
`}
|
||||
>
|
||||
<PromptHints
|
||||
prompts={promptHints}
|
||||
onPromptSelect={onPromptSelect}
|
||||
className=" border-chat-input-top"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex
|
||||
max-md:flex-row-reverse max-md:items-center max-md:gap-2 max-md:p-3
|
||||
md:flex-col md:px-5 md:pb-5
|
||||
`}
|
||||
>
|
||||
<ChatActions
|
||||
uploadImage={uploadImage}
|
||||
setAttachImages={setAttachImages}
|
||||
setUploading={setUploading}
|
||||
showChatSetting={() => showChatSetting(true)}
|
||||
scrollToBottom={scrollToBottom}
|
||||
hitBottom={hitBottom}
|
||||
uploading={uploading}
|
||||
showPromptHints={() => {
|
||||
// Click again to close
|
||||
if (promptHints.length > 0) {
|
||||
setPromptHints([]);
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
setUserInput("/");
|
||||
onSearch("");
|
||||
}}
|
||||
className={`
|
||||
md:py-2.5
|
||||
`}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
<label
|
||||
className={`
|
||||
cursor-text flex flex-col bg-chat-panel-input-hood border border-chat-input-hood
|
||||
focus-within:border-chat-input-hood-focus sm:focus-within:shadow-chat-input-hood-focus-shadow
|
||||
rounded-chat-input p-3 gap-3 max-md:flex-1
|
||||
md:rounded-md md:p-4 md:gap-4
|
||||
`}
|
||||
htmlFor="chat-input"
|
||||
>
|
||||
{attachImages.length != 0 && (
|
||||
<div className={`flex gap-2`}>
|
||||
{attachImages.map((image, index) => {
|
||||
return (
|
||||
<Thumbnail
|
||||
key={index}
|
||||
deleteImage={() => {
|
||||
setAttachImages(
|
||||
attachImages.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
image={image}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
id="chat-input"
|
||||
ref={inputRef}
|
||||
className={`
|
||||
leading-[19px] flex-1 focus:outline-none focus:shadow-none focus:border-none resize-none bg-inherit text-text-input
|
||||
max-md:h-chat-input-mobile
|
||||
md:min-h-chat-input
|
||||
`}
|
||||
placeholder={
|
||||
isMobileScreen
|
||||
? Locale.Chat.Input(submitKey, isMobileScreen)
|
||||
: undefined
|
||||
}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
value={userInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={scrollToBottom}
|
||||
onClick={scrollToBottom}
|
||||
onPaste={handlePaste}
|
||||
rows={inputRows}
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
fontSize: config.fontSize,
|
||||
}}
|
||||
/>
|
||||
{!isMobileScreen && (
|
||||
<div className="flex items-center justify-center gap-3 text-sm">
|
||||
<div className="flex-1"> </div>
|
||||
<div className="text-text-chat-input-placeholder font-common line-clamp-1">
|
||||
{Locale.Chat.Input(submitKey)}
|
||||
</div>
|
||||
<Btn
|
||||
className="min-w-[77px]"
|
||||
icon={<SendIcon />}
|
||||
text={Locale.Chat.Send}
|
||||
disabled={!userInput.length}
|
||||
type="primary"
|
||||
onClick={() => doSubmit(userInput)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
248
app/containers/Chat/components/ChatMessagePanel.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Fragment, useEffect, useMemo } from "react";
|
||||
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||
import { CHAT_PAGE_SIZE } from "@/app/constant";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import { getMessageTextContent, selectOrCopy } from "@/app/utils";
|
||||
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
import { Avatar } from "@/app/components/emoji";
|
||||
import { MaskAvatar } from "@/app/components/mask";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import ClearContextDivider from "./ClearContextDivider";
|
||||
import dynamic from "next/dynamic";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import MessageActions, { RenderMessage } from "./MessageActions";
|
||||
import Imgs from "@/app/components/Imgs";
|
||||
|
||||
export type { RenderMessage };
|
||||
|
||||
export interface ChatMessagePanelProps {
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isMobileScreen: boolean;
|
||||
msgRenderIndex: number;
|
||||
userInput: string;
|
||||
context: any[];
|
||||
renderMessages: RenderMessage[];
|
||||
scrollDomToBottom: () => void;
|
||||
setAutoScroll?: (value: boolean) => void;
|
||||
setMsgRenderIndex?: (newIndex: number) => void;
|
||||
setHitBottom?: (value: boolean) => void;
|
||||
setUserInput?: (v: string) => void;
|
||||
setIsLoading?: (value: boolean) => void;
|
||||
setShowPromptModal?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
let MarkdownLoadedCallback: () => void;
|
||||
|
||||
const Markdown = dynamic(
|
||||
async () => {
|
||||
const bundle = await import("@/app/components/markdown");
|
||||
|
||||
if (MarkdownLoadedCallback) {
|
||||
MarkdownLoadedCallback();
|
||||
}
|
||||
return bundle.Markdown;
|
||||
},
|
||||
{
|
||||
loading: () => <LoadingIcon />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
const {
|
||||
scrollRef,
|
||||
inputRef,
|
||||
setAutoScroll,
|
||||
setMsgRenderIndex,
|
||||
isMobileScreen,
|
||||
msgRenderIndex,
|
||||
setHitBottom,
|
||||
setUserInput,
|
||||
userInput,
|
||||
context,
|
||||
renderMessages,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
scrollDomToBottom,
|
||||
} = props;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
const fontSize = config.fontSize;
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
containerRef: scrollRef,
|
||||
delay: 0,
|
||||
offsetDistance: 20,
|
||||
});
|
||||
|
||||
// clear context index = context length + index in messages
|
||||
const clearContextIndex =
|
||||
(session.clearContextIndex ?? -1) >= 0
|
||||
? session.clearContextIndex! + context.length - msgRenderIndex
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!MarkdownLoadedCallback) {
|
||||
MarkdownLoadedCallback = () => {
|
||||
window.setTimeout(scrollDomToBottom, 100);
|
||||
};
|
||||
}
|
||||
}, [scrollDomToBottom]);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const endRenderIndex = Math.min(
|
||||
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
||||
renderMessages.length,
|
||||
);
|
||||
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
||||
}, [msgRenderIndex, renderMessages]);
|
||||
|
||||
const onChatBodyScroll = (e: HTMLElement) => {
|
||||
const bottomHeight = e.scrollTop + e.clientHeight;
|
||||
const edgeThreshold = e.clientHeight;
|
||||
|
||||
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
|
||||
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
|
||||
const isHitBottom =
|
||||
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
|
||||
|
||||
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
|
||||
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
|
||||
|
||||
if (isTouchTopEdge && !isTouchBottomEdge) {
|
||||
setMsgRenderIndex?.(prevPageMsgIndex);
|
||||
} else if (isTouchBottomEdge) {
|
||||
setMsgRenderIndex?.(nextPageMsgIndex);
|
||||
}
|
||||
|
||||
setHitBottom?.(isHitBottom);
|
||||
setAutoScroll?.(isHitBottom);
|
||||
};
|
||||
|
||||
const onRightClick = (e: any, message: ChatMessage) => {
|
||||
// copy to clipboard
|
||||
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
|
||||
if (userInput.length === 0) {
|
||||
setUserInput?.(getMessageTextContent(message));
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pt-[80px] relative flex-1 overscroll-y-none overflow-y-auto overflow-x-hidden px-3 pb-6 md:bg-chat-panel-message bg-chat-panel-message-mobile`}
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
onMouseDown={() => inputRef.current?.blur()}
|
||||
onTouchStart={() => {
|
||||
inputRef.current?.blur();
|
||||
setAutoScroll?.(false);
|
||||
}}
|
||||
>
|
||||
{messages.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
const isContext = i < context.length;
|
||||
|
||||
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
||||
|
||||
const actionsBarPosition =
|
||||
position?.id === message.id &&
|
||||
position?.poi.overlapPositions[Orientation.bottom]
|
||||
? "bottom-[calc(100%-0.25rem)]"
|
||||
: "top-[calc(100%-0.25rem)]";
|
||||
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<div
|
||||
className={`flex mt-6 gap-2 ${isUser ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className={`relative flex-0`}>
|
||||
{isUser ? (
|
||||
<Avatar avatar={config.avatar} />
|
||||
) : (
|
||||
<>
|
||||
{["system"].includes(message.role) ? (
|
||||
<Avatar avatar="2699-fe0f" />
|
||||
) : (
|
||||
<MaskAvatar
|
||||
avatar={session.mask.avatar}
|
||||
model={message.model || session.mask.modelConfig.model}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`group relative flex ${
|
||||
isUser ? "flex-row-reverse" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={` pointer-events-none text-text-chat-message-date text-right font-common whitespace-nowrap transition-all duration-500 text-sm absolute z-1 ${
|
||||
isUser ? "right-0" : "left-0"
|
||||
} bottom-[100%] hidden group-hover:block`}
|
||||
>
|
||||
{isContext
|
||||
? Locale.Chat.IsContext
|
||||
: message.date.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 select-text break-words font-common text-sm-title ${
|
||||
isUser
|
||||
? "rounded-user-message bg-chat-panel-message-user"
|
||||
: "rounded-bot-message bg-chat-panel-message-bot"
|
||||
} box-border peer py-2 px-3`}
|
||||
onPointerMoveCapture={(e) =>
|
||||
getRelativePosition(e.currentTarget, message.id)
|
||||
}
|
||||
>
|
||||
<Markdown
|
||||
content={getMessageTextContent(message)}
|
||||
loading={
|
||||
(message.preview || message.streaming) &&
|
||||
message.content.length === 0 &&
|
||||
!isUser
|
||||
}
|
||||
onContextMenu={(e) => onRightClick(e, message)}
|
||||
onDoubleClickCapture={() => {
|
||||
if (!isMobileScreen) return;
|
||||
setUserInput?.(getMessageTextContent(message));
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
parentRef={scrollRef}
|
||||
defaultShow={i >= messages.length - 6}
|
||||
className={`leading-6 max-w-message-width ${
|
||||
isUser
|
||||
? " text-text-chat-message-markdown-user"
|
||||
: "text-text-chat-message-markdown-bot"
|
||||
}`}
|
||||
/>
|
||||
<Imgs message={message} />
|
||||
</div>
|
||||
<MessageActions
|
||||
className={actionsBarPosition}
|
||||
message={message}
|
||||
inputRef={inputRef}
|
||||
isUser={isUser}
|
||||
isContext={isContext}
|
||||
setIsLoading={setIsLoading}
|
||||
setShowPromptModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
46
app/containers/Chat/components/ClearContextDivider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
|
||||
export default function ClearContextDivider() {
|
||||
const chatStore = useChatStore();
|
||||
const { isMobileScreen } = useAppConfig();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-6 mb-8 flex items-center justify-center gap-2.5 max-md:cursor-pointer`}
|
||||
onClick={() => {
|
||||
if (!isMobileScreen) {
|
||||
return;
|
||||
}
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = undefined),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
|
||||
<div className="flex items-center justify-between gap-1 text-sm">
|
||||
<div className={`text-text-chat-panel-message-clear`}>
|
||||
{Locale.Context.Clear}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
text-text-chat-panel-message-clear-revert underline font-common
|
||||
md:cursor-pointer
|
||||
`}
|
||||
onClick={() => {
|
||||
if (isMobileScreen) {
|
||||
return;
|
||||
}
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = undefined),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Locale.Context.Revert}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
|
||||
</div>
|
||||
);
|
||||
}
|
75
app/containers/Chat/components/EditMessageModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { List, ListItem, Modal } from "@/app/components/ui-lib";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { ContextPrompts } from "@/app/components/mask";
|
||||
|
||||
import CancelIcon from "@/app/icons/cancel.svg";
|
||||
import ConfirmIcon from "@/app/icons/confirm.svg";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export function EditMessageModal(props: { onClose: () => void }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const [messages, setMessages] = useState(session.messages.slice());
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Chat.EditMessage.Title}
|
||||
onClose={props.onClose}
|
||||
actions={[
|
||||
<IconButton
|
||||
text={Locale.UI.Cancel}
|
||||
icon={<CancelIcon />}
|
||||
key="cancel"
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
}}
|
||||
/>,
|
||||
<IconButton
|
||||
type="primary"
|
||||
text={Locale.UI.Confirm}
|
||||
icon={<ConfirmIcon />}
|
||||
key="ok"
|
||||
onClick={() => {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.messages = messages),
|
||||
);
|
||||
props.onClose();
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Chat.EditMessage.Topic.Title}
|
||||
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={session.topic}
|
||||
onChange={(e) =>
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.topic = e || ""),
|
||||
)
|
||||
}
|
||||
className=" text-center"
|
||||
></Input>
|
||||
</ListItem>
|
||||
</List>
|
||||
<ContextPrompts
|
||||
context={messages}
|
||||
updateContext={(updater) => {
|
||||
const newMessages = messages.slice();
|
||||
updater(newMessages);
|
||||
setMessages(newMessages);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
295
app/containers/Chat/components/MessageActions.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import StopIcon from "@/app/icons/pause.svg";
|
||||
import DeleteRequestIcon from "@/app/icons/deleteRequestIcon.svg";
|
||||
import RetryRequestIcon from "@/app/icons/retryRequestIcon.svg";
|
||||
import CopyRequestIcon from "@/app/icons/copyRequestIcon.svg";
|
||||
import EditRequestIcon from "@/app/icons/editRequestIcon.svg";
|
||||
import PinRequestIcon from "@/app/icons/pinRequestIcon.svg";
|
||||
import { showPrompt, showToast } from "@/app/components/ui-lib";
|
||||
import {
|
||||
copyToClipboard,
|
||||
getMessageImages,
|
||||
getMessageTextContent,
|
||||
} from "@/app/utils";
|
||||
import { MultimodalContent } from "@/app/client/api";
|
||||
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||
import ActionsBar from "@/app/components/ActionsBar";
|
||||
import { ChatControllerPool } from "@/app/client/controller";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type RenderMessage = ChatMessage & { preview?: boolean };
|
||||
|
||||
export interface MessageActionsProps {
|
||||
message: RenderMessage;
|
||||
isUser: boolean;
|
||||
isContext: boolean;
|
||||
showActions?: boolean;
|
||||
inputRef: RefObject<HTMLTextAreaElement>;
|
||||
className?: string;
|
||||
setIsLoading?: (value: boolean) => void;
|
||||
setShowPromptModal?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const genActionsShema = (
|
||||
message: RenderMessage,
|
||||
{
|
||||
onEdit,
|
||||
onCopy,
|
||||
onPinMessage,
|
||||
onDelete,
|
||||
onResend,
|
||||
onUserStop,
|
||||
}: Record<
|
||||
| "onEdit"
|
||||
| "onCopy"
|
||||
| "onPinMessage"
|
||||
| "onDelete"
|
||||
| "onResend"
|
||||
| "onUserStop",
|
||||
(message: RenderMessage) => void
|
||||
>,
|
||||
) => {
|
||||
const className =
|
||||
" !p-1 hover:bg-chat-message-actions-btn-hovered !rounded-actions-bar-btn ";
|
||||
return [
|
||||
{
|
||||
id: "Edit",
|
||||
icons: <EditRequestIcon />,
|
||||
title: "Edit",
|
||||
className,
|
||||
onClick: () => onEdit(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Copy,
|
||||
icons: <CopyRequestIcon />,
|
||||
title: Locale.Chat.Actions.Copy,
|
||||
className,
|
||||
onClick: () => onCopy(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Pin,
|
||||
icons: <PinRequestIcon />,
|
||||
title: Locale.Chat.Actions.Pin,
|
||||
className,
|
||||
onClick: () => onPinMessage(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Delete,
|
||||
icons: <DeleteRequestIcon />,
|
||||
title: Locale.Chat.Actions.Delete,
|
||||
className,
|
||||
onClick: () => onDelete(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Retry,
|
||||
icons: <RetryRequestIcon />,
|
||||
title: Locale.Chat.Actions.Retry,
|
||||
className,
|
||||
onClick: () => onResend(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Stop,
|
||||
icons: <StopIcon />,
|
||||
title: Locale.Chat.Actions.Stop,
|
||||
className,
|
||||
onClick: () => onUserStop(message),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
enum GroupType {
|
||||
"streaming" = "streaming",
|
||||
"isContext" = "isContext",
|
||||
"normal" = "normal",
|
||||
}
|
||||
|
||||
const groupsTypes = {
|
||||
[GroupType.streaming]: [[Locale.Chat.Actions.Stop]],
|
||||
[GroupType.isContext]: [["Edit"]],
|
||||
[GroupType.normal]: [
|
||||
[
|
||||
Locale.Chat.Actions.Retry,
|
||||
"Edit",
|
||||
Locale.Chat.Actions.Copy,
|
||||
Locale.Chat.Actions.Pin,
|
||||
Locale.Chat.Actions.Delete,
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default function MessageActions(props: MessageActionsProps) {
|
||||
const {
|
||||
className,
|
||||
message,
|
||||
isUser,
|
||||
isContext,
|
||||
showActions = true,
|
||||
setIsLoading,
|
||||
inputRef,
|
||||
setShowPromptModal,
|
||||
} = props;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
const deleteMessage = (msgId?: string) => {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) =>
|
||||
(session.messages = session.messages.filter((m) => m.id !== msgId)),
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (message: ChatMessage) => {
|
||||
deleteMessage(message.id);
|
||||
};
|
||||
|
||||
const onResend = (message: ChatMessage) => {
|
||||
// when it is resending a message
|
||||
// 1. for a user's message, find the next bot response
|
||||
// 2. for a bot's message, find the last user's input
|
||||
// 3. delete original user input and bot's message
|
||||
// 4. resend the user's input
|
||||
|
||||
const resendingIndex = session.messages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
|
||||
console.error("[Chat] failed to find resending message", message);
|
||||
return;
|
||||
}
|
||||
|
||||
let userMessage: ChatMessage | undefined;
|
||||
let botMessage: ChatMessage | undefined;
|
||||
|
||||
if (message.role === "assistant") {
|
||||
// if it is resending a bot's message, find the user input for it
|
||||
botMessage = message;
|
||||
for (let i = resendingIndex; i >= 0; i -= 1) {
|
||||
if (session.messages[i].role === "user") {
|
||||
userMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (message.role === "user") {
|
||||
// if it is resending a user's input, find the bot's response
|
||||
userMessage = message;
|
||||
for (let i = resendingIndex; i < session.messages.length; i += 1) {
|
||||
if (session.messages[i].role === "assistant") {
|
||||
botMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessage === undefined) {
|
||||
console.error("[Chat] failed to resend", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// delete the original messages
|
||||
deleteMessage(userMessage.id);
|
||||
deleteMessage(botMessage?.id);
|
||||
|
||||
// resend the message
|
||||
setIsLoading?.(true);
|
||||
const textContent = getMessageTextContent(userMessage);
|
||||
const images = getMessageImages(userMessage);
|
||||
chatStore
|
||||
.onUserInput(textContent, images)
|
||||
.then(() => setIsLoading?.(false));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onPinMessage = (message: ChatMessage) => {
|
||||
chatStore.updateCurrentSession((session) =>
|
||||
session.mask.context.push(message),
|
||||
);
|
||||
|
||||
showToast(Locale.Chat.Actions.PinToastContent, {
|
||||
text: Locale.Chat.Actions.PinToastAction,
|
||||
onClick: () => {
|
||||
setShowPromptModal?.(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// stop response
|
||||
const onUserStop = (message: ChatMessage) => {
|
||||
ChatControllerPool.stop(session.id, message.id);
|
||||
};
|
||||
|
||||
const onEdit = async () => {
|
||||
const newMessage = await showPrompt(
|
||||
Locale.Chat.Actions.Edit,
|
||||
getMessageTextContent(message),
|
||||
10,
|
||||
);
|
||||
let newContent: string | MultimodalContent[] = newMessage;
|
||||
const images = getMessageImages(message);
|
||||
if (images.length > 0) {
|
||||
newContent = [{ type: "text", text: newMessage }];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
newContent.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: images[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const m = session.mask.context
|
||||
.concat(session.messages)
|
||||
.find((m) => m.id === message.id);
|
||||
if (m) {
|
||||
m.content = newContent;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = () => copyToClipboard(getMessageTextContent(message));
|
||||
|
||||
const groupsType = [
|
||||
message.streaming && GroupType.streaming,
|
||||
isContext && GroupType.isContext,
|
||||
GroupType.normal,
|
||||
].find((i) => i) as GroupType;
|
||||
|
||||
return (
|
||||
showActions && (
|
||||
<div
|
||||
className={`
|
||||
absolute z-10 w-[100%]
|
||||
${isUser ? "right-0" : "left-0"}
|
||||
transition-all duration-300
|
||||
opacity-0
|
||||
pointer-events-none
|
||||
group-hover:opacity-100
|
||||
group-hover:pointer-events-auto
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<ActionsBar
|
||||
actionsShema={genActionsShema(message, {
|
||||
onCopy,
|
||||
onDelete,
|
||||
onPinMessage,
|
||||
onEdit,
|
||||
onResend,
|
||||
onUserStop,
|
||||
})}
|
||||
groups={groupsTypes[groupsType]}
|
||||
className={`
|
||||
float-right flex flex-row gap-1 p-1
|
||||
bg-chat-message-actions
|
||||
rounded-md
|
||||
shadow-message-actions-bar
|
||||
dark:bg-none
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
159
app/containers/Chat/components/ModelSelect.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import Popover from "@/app/components/Popover";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import Locale from "@/app/locales";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import { ModelType, useAppConfig } from "@/app/store/config";
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import BottomArrow from "@/app/icons/downArrowLgIcon.svg";
|
||||
import BottomArrowMobile from "@/app/icons/bottomArrow.svg";
|
||||
import Modal, { TriggerProps } from "@/app/components/Modal";
|
||||
|
||||
import Selected from "@/app/icons/selectedIcon.svg";
|
||||
|
||||
const ModelSelect = () => {
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
const chatStore = useChatStore();
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
const allModels = useAllModels();
|
||||
const models = useMemo(() => {
|
||||
const filteredModels = allModels.filter((m) => m.available);
|
||||
const defaultModel = filteredModels.find((m) => m.isDefault);
|
||||
|
||||
if (defaultModel) {
|
||||
const arr = [
|
||||
defaultModel,
|
||||
...filteredModels.filter((m) => m !== defaultModel),
|
||||
];
|
||||
return arr;
|
||||
} else {
|
||||
return filteredModels;
|
||||
}
|
||||
}, [allModels]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
const contentRef = useMemo<{ current: HTMLDivElement | null }>(() => {
|
||||
return {
|
||||
current: null,
|
||||
};
|
||||
}, []);
|
||||
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const autoScrollToSelectedModal = () => {
|
||||
window.setTimeout(() => {
|
||||
const distanceToParent = selectedItemRef.current?.offsetTop || 0;
|
||||
const childHeight = selectedItemRef.current?.offsetHeight || 0;
|
||||
const parentHeight = contentRef.current?.offsetHeight || 0;
|
||||
const distanceToParentCenter =
|
||||
distanceToParent + childHeight / 2 - parentHeight / 2;
|
||||
|
||||
if (distanceToParentCenter > 0 && contentRef.current) {
|
||||
contentRef.current.scrollTop = distanceToParentCenter;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const content: TriggerProps["content"] = ({ close }) => (
|
||||
<div
|
||||
className={`flex flex-col gap-1 overflow-x-hidden relative text-sm-title`}
|
||||
>
|
||||
{models?.map((o) => (
|
||||
<div
|
||||
key={o.displayName}
|
||||
className={`flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer`}
|
||||
onClick={() => {
|
||||
close();
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
session.mask.modelConfig.model = o.name as ModelType;
|
||||
session.mask.syncGlobalConfig = false;
|
||||
});
|
||||
showToast(o.name);
|
||||
}}
|
||||
ref={currentModel === o.name ? selectedItemRef : undefined}
|
||||
>
|
||||
<div className={`flex-1 text-text-select`}>{o.name}</div>
|
||||
<div
|
||||
className={currentModel === o.name ? "opacity-100" : "opacity-0"}
|
||||
>
|
||||
<Selected />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobileScreen) {
|
||||
return (
|
||||
<Modal.Trigger
|
||||
content={(e) => (
|
||||
<div className="h-[100%] overflow-y-auto" ref={contentRef}>
|
||||
{content(e)}
|
||||
</div>
|
||||
)}
|
||||
type="bottom-drawer"
|
||||
onOpen={(e) => {
|
||||
if (e) {
|
||||
autoScrollToSelectedModal();
|
||||
getRelativePosition(rootRef.current!, "");
|
||||
}
|
||||
}}
|
||||
title={Locale.Chat.SelectModel}
|
||||
headerBordered
|
||||
noFooter
|
||||
modelClassName="h-model-bottom-drawer"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer text-text-modal-select"
|
||||
ref={rootRef}
|
||||
>
|
||||
{currentModel}
|
||||
<BottomArrowMobile />
|
||||
</div>
|
||||
</Modal.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="max-h-chat-actions-select-model-popover overflow-y-auto">
|
||||
{content({ close: () => {} })}
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
noArrow
|
||||
placement={
|
||||
position?.poi.relativePosition[1] !== Orientation.bottom ? "lb" : "lt"
|
||||
}
|
||||
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-model-select-popover-panel w-[280px]"
|
||||
onShow={(e) => {
|
||||
if (e) {
|
||||
autoScrollToSelectedModal();
|
||||
getRelativePosition(rootRef.current!, "");
|
||||
}
|
||||
}}
|
||||
getPopoverPanelRef={(ref) => (contentRef.current = ref.current)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center gap-1 cursor-pointer rounded-chat-model-select pl-3 pr-2.5 py-2 font-common leading-4 bg-chat-actions-select-model hover:bg-chat-actions-select-model-hover"
|
||||
ref={rootRef}
|
||||
>
|
||||
<div className="line-clamp-1 max-w-chat-actions-select-model text-sm-title text-text-modal-select">
|
||||
{currentModel}
|
||||
</div>
|
||||
<BottomArrow />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelect;
|
96
app/containers/Chat/components/PromptHint.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Prompt } from "@/app/store/prompt";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
import useShowPromptHint from "@/app/hooks/useShowPromptHint";
|
||||
|
||||
export type RenderPompt = Pick<Prompt, "title" | "content">;
|
||||
|
||||
export default function PromptHints(props: {
|
||||
prompts: RenderPompt[];
|
||||
onPromptSelect: (prompt: RenderPompt) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const noPrompts = props.prompts.length === 0;
|
||||
|
||||
const [selectIndex, setSelectIndex] = useState(0);
|
||||
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { internalPrompts, notShowPrompt } = useShowPromptHint({ ...props });
|
||||
|
||||
useEffect(() => {
|
||||
setSelectIndex(0);
|
||||
}, [props.prompts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
// arrow up / down to select prompt
|
||||
const changeIndex = (delta: number) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
Math.min(props.prompts.length - 1, selectIndex + delta),
|
||||
);
|
||||
setSelectIndex(nextIndex);
|
||||
selectedRef.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
changeIndex(1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
changeIndex(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
const selectedPrompt = props.prompts.at(selectIndex);
|
||||
if (selectedPrompt) {
|
||||
props.onPromptSelect(selectedPrompt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.prompts.length, selectIndex]);
|
||||
|
||||
if (!internalPrompts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 shadow-prompt-hint-container rounded-none flex flex-col-reverse overflow-x-hidden
|
||||
${
|
||||
notShowPrompt
|
||||
? "max-h-[0vh] border-none"
|
||||
: "border-b pt-2.5 max-h-[50vh]"
|
||||
}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
{internalPrompts.map((prompt, i) => (
|
||||
<div
|
||||
ref={i === selectIndex ? selectedRef : null}
|
||||
className={
|
||||
styles["prompt-hint"] +
|
||||
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
||||
}
|
||||
key={prompt.title + i.toString()}
|
||||
onClick={() => props.onPromptSelect(prompt)}
|
||||
onMouseEnter={() => setSelectIndex(i)}
|
||||
>
|
||||
<div className={styles["hint-title"]}>{prompt.title}</div>
|
||||
<div className={styles["hint-content"]}>{prompt.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
32
app/containers/Chat/components/PromptToast.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import BrainIcon from "@/app/icons/brain.svg";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
|
||||
export default function PromptToast(props: {
|
||||
showToast?: boolean;
|
||||
setShowModal: (_: boolean) => void;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const context = session.mask.context;
|
||||
|
||||
return (
|
||||
<div className={styles["prompt-toast"]} key="prompt-toast">
|
||||
{props.showToast && (
|
||||
<div
|
||||
className={styles["prompt-toast-inner"] + " clickable"}
|
||||
role="button"
|
||||
onClick={() => props.setShowModal(true)}
|
||||
>
|
||||
<BrainIcon />
|
||||
<span className={styles["prompt-toast-content"]}>
|
||||
{Locale.Context.Toast(context.length)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
77
app/containers/Chat/components/SessionConfigModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Modal, showConfirm } from "@/app/components/ui-lib";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useMaskStore } from "@/app/store/mask";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { Path } from "@/app/constant";
|
||||
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import MaskConfig from "@/app/containers/Settings/components/MaskConfig";
|
||||
import { ListItem } from "@/app/components/List";
|
||||
|
||||
export default function SessionConfigModel(props: { onClose: () => void }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const maskStore = useMaskStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Context.Edit}
|
||||
onClose={() => props.onClose()}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="reset"
|
||||
icon={<ResetIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Config.Reset}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Memory.ResetConfirm)) {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.memoryPrompt = ""),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Config.SaveAs}
|
||||
onClick={() => {
|
||||
navigate(Path.Masks);
|
||||
setTimeout(() => {
|
||||
maskStore.create(session.mask);
|
||||
}, 500);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<MaskConfig
|
||||
mask={session.mask}
|
||||
updateMask={(updater) => {
|
||||
const mask = { ...session.mask };
|
||||
updater(mask);
|
||||
chatStore.updateCurrentSession((session) => (session.mask = mask));
|
||||
}}
|
||||
shouldSyncFromGlobal
|
||||
extraListItems={
|
||||
session.mask.modelConfig.sendMemory ? (
|
||||
<ListItem
|
||||
className="copyable"
|
||||
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
||||
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||
></ListItem>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
></MaskConfig>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
182
app/containers/Chat/components/SessionItem.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
import { Mask } from "@/app/store/mask";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import DeleteChatIcon from "@/app/icons/deleteChatIcon.svg";
|
||||
|
||||
import { getTime } from "@/app/utils";
|
||||
import DeleteIcon from "@/app/icons/deleteIcon.svg";
|
||||
import LogIcon from "@/app/icons/logIcon.svg";
|
||||
|
||||
import HoverPopover from "@/app/components/HoverPopover";
|
||||
import Popover from "@/app/components/Popover";
|
||||
|
||||
export default function SessionItem(props: {
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
title: string;
|
||||
count: number;
|
||||
time: string;
|
||||
selected: boolean;
|
||||
id: string;
|
||||
index: number;
|
||||
narrow?: boolean;
|
||||
mask: Mask;
|
||||
isMobileScreen: boolean;
|
||||
}) {
|
||||
const draggableRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (props.selected && draggableRef.current) {
|
||||
draggableRef.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [props.selected]);
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={`
|
||||
group/chat-menu-list relative flex p-3 items-center gap-2 self-stretch rounded-md mb-2
|
||||
border
|
||||
transition-colors duration-300 ease-in-out
|
||||
bg-chat-menu-session-unselected-mobile border-chat-menu-session-unselected-mobile
|
||||
md:bg-chat-menu-session-unselected md:border-chat-menu-session-unselected
|
||||
${
|
||||
props.selected &&
|
||||
(pathname === Path.Chat || pathname === Path.Home)
|
||||
? `
|
||||
md:!bg-chat-menu-session-selected md:!border-chat-menu-session-selected
|
||||
!bg-chat-menu-session-selected-mobile !border-chat-menu-session-selected-mobile
|
||||
`
|
||||
: `md:hover:bg-chat-menu-session-hovered md:hover:chat-menu-session-hovered`
|
||||
}
|
||||
`}
|
||||
onClick={props.onClick}
|
||||
ref={(ele) => {
|
||||
draggableRef.current = ele;
|
||||
provided.innerRef(ele);
|
||||
}}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
|
||||
props.count,
|
||||
)}`}
|
||||
>
|
||||
<div className="flex-shrink-0 ">
|
||||
<LogIcon />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className={`flex justify-between items-center`}>
|
||||
<div
|
||||
className={` text-text-chat-menu-item-title text-sm-title line-clamp-1 flex-1`}
|
||||
>
|
||||
{props.title}
|
||||
</div>
|
||||
<div
|
||||
className={`text-text-chat-menu-item-time text-sm group-hover/chat-menu-list:opacity-0 pl-3 hidden md:block`}
|
||||
>
|
||||
{getTime(props.time)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-text-chat-menu-item-description text-sm`}>
|
||||
{Locale.ChatItem.ChatItemCount(props.count)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-text-chat-menu-item-time text-sm pl-3 block md:hidden`}
|
||||
>
|
||||
{getTime(props.time)}
|
||||
</div>
|
||||
{props.isMobileScreen ? (
|
||||
<Popover
|
||||
content={
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
|
||||
follow-parent-svg
|
||||
fill-none
|
||||
text-text-chat-menu-item-delete
|
||||
`}
|
||||
onClickCapture={(e) => {
|
||||
props.onDelete?.();
|
||||
}}
|
||||
>
|
||||
<DeleteChatIcon />
|
||||
<div className="flex-1 font-common text-actions-popover-menu-item ">
|
||||
{Locale.Chat.Actions.Delete}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
popoverClassName={`
|
||||
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
|
||||
`}
|
||||
noArrow
|
||||
placement="r"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer rounded-chat-img
|
||||
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
|
||||
md:group-hover/chat-menu-list:pointer-events-auto
|
||||
md:group-hover/chat-menu-list:opacity-100
|
||||
md:hover:bg-select-hover
|
||||
follow-parent-svg
|
||||
fill-none
|
||||
text-text-chat-menu-item-time
|
||||
`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<HoverPopover
|
||||
content={
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
|
||||
follow-parent-svg
|
||||
fill-none
|
||||
text-text-chat-menu-item-delete
|
||||
`}
|
||||
onClickCapture={(e) => {
|
||||
props.onDelete?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DeleteChatIcon />
|
||||
<div className="flex-1 font-common text-actions-popover-menu-item text-text-chat-menu-item-delete">
|
||||
{Locale.Chat.Actions.Delete}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
popoverClassName={`
|
||||
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
|
||||
`}
|
||||
noArrow
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer rounded-chat-img
|
||||
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
|
||||
md:group-hover/chat-menu-list:pointer-events-auto
|
||||
md:group-hover/chat-menu-list:opacity-100
|
||||
md:hover:bg-select-hover
|
||||
`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</HoverPopover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
609
app/containers/Chat/index.module.scss
Normal file
@@ -0,0 +1,609 @@
|
||||
@import "~@/app/styles/animation.scss";
|
||||
|
||||
.attach-images {
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attach-image {
|
||||
cursor: default;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--white);
|
||||
|
||||
.attach-image-mask {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: all ease 0.2s;
|
||||
}
|
||||
|
||||
.attach-image-mask:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-image {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
float: right;
|
||||
background-color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.chat-input-action {
|
||||
display: inline-flex;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
padding: 4px 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: width ease 0.3s;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
width: var(--icon-width);
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
transition: all ease 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--delay: 0.5s;
|
||||
width: var(--full-width);
|
||||
transition-delay: var(--delay);
|
||||
|
||||
.text {
|
||||
transition-delay: var(--delay);
|
||||
opacity: 1;
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.text,
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-toast {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
.prompt-toast-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 10px 20px;
|
||||
border-radius: 100px;
|
||||
|
||||
animation: slide-in-from-top ease 0.3s;
|
||||
|
||||
.prompt-toast-content {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.section-title-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt {
|
||||
.context-prompt-insert {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
opacity: 0.2;
|
||||
transition: all ease 0.3s;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
.context-drag {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.context-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.context-role {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-delete-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.memory-prompt {
|
||||
margin: 20px 0;
|
||||
|
||||
.memory-prompt-content {
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-context {
|
||||
margin: 20px 0 0 0;
|
||||
padding: 4px 0;
|
||||
|
||||
border-top: var(--border-in-light);
|
||||
border-bottom: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: var(--black);
|
||||
transition: all ease 0.3s;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
$linear: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0));
|
||||
mask-image: $linear;
|
||||
|
||||
@mixin show {
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
transition: all ease 0.3s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@mixin hide {
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
transition: all ease 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-tips {
|
||||
@include show;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-revert-btn {
|
||||
color: var(--primary);
|
||||
@include hide;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--primary);
|
||||
|
||||
.clear-context-tips {
|
||||
@include hide;
|
||||
}
|
||||
|
||||
.clear-context-revert-btn {
|
||||
@include show;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
// height: 100%;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
padding-bottom: 40px;
|
||||
position: relative;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.chat-body-main-title {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.chat-body-title {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&:last-child {
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.chat-message-header {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-header {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.chat-message-actions {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
transition: all ease 0.3s;
|
||||
transform: scale(0.9) translateY(5px);
|
||||
margin: 0 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-container {
|
||||
max-width: var(--message-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
.chat-message-edit {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-message-actions {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-avatar {
|
||||
position: relative;
|
||||
|
||||
.chat-message-edit {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
button {
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Specific styles for iOS devices */
|
||||
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.chat-message-edit {
|
||||
top: -8%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-status {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chat-message-item {
|
||||
// box-sizing: border-box;
|
||||
// max-width: 100%;
|
||||
// margin-top: 10px;
|
||||
// border-radius: 10px;
|
||||
// background-color: rgba(0, 0, 0, 0.05);
|
||||
// padding: 10px;
|
||||
// font-size: 14px;
|
||||
// user-select: text;
|
||||
// word-break: break-word;
|
||||
// border: var(--border-in-light);
|
||||
// position: relative;
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-message-item-images {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-content: left;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: repeat(var(--image-count), auto);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
object-fit: cover;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.chat-message-item-image,
|
||||
.chat-message-item-image-multi {
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $calc-image-width;
|
||||
height: $calc-image-width;
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(100vw/3*2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
||||
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $image-width;
|
||||
height: $image-width;
|
||||
max-width: $max-image-width;
|
||||
max-height: $max-image-width;
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||
}
|
||||
}
|
||||
|
||||
// .chat-message-action-date {
|
||||
// // font-size: 12px;
|
||||
// // opacity: 0.2;
|
||||
// // white-space: nowrap;
|
||||
// // transition: all ease 0.6s;
|
||||
// // color: var(--black);
|
||||
// // text-align: right;
|
||||
// // width: 100%;
|
||||
// // box-sizing: border-box;
|
||||
// // padding-right: 10px;
|
||||
// // pointer-events: none;
|
||||
// // z-index: 1;
|
||||
// }
|
||||
|
||||
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||
background-color: var(--second);
|
||||
|
||||
&:hover {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-panel {
|
||||
// position: relative;
|
||||
// width: 100%;
|
||||
// padding: 20px;
|
||||
// padding-top: 10px;
|
||||
// box-sizing: border-box;
|
||||
// flex-direction: column;
|
||||
// border-top: var(--border-in-light);
|
||||
// box-shadow: var(--card-shadow);
|
||||
|
||||
.chat-input-actions {
|
||||
.chat-input-action {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin single-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.prompt-hint {
|
||||
color:var(--btn-default-text);
|
||||
padding: 6px 10px;
|
||||
border: transparent 1px solid;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hint-title {
|
||||
font-size: 12px;
|
||||
font-weight: bolder;
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
.hint-content {
|
||||
font-size: 12px;
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
&-selected,
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
// .chat-input-panel-inner {
|
||||
// cursor: text;
|
||||
// display: flex;
|
||||
// flex: 1;
|
||||
// border-radius: 10px;
|
||||
// border: var(--border-in-light);
|
||||
// }
|
||||
|
||||
.chat-input-panel-inner-attach {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.chat-input-panel-inner:has(.chat-input:focus) {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
font-family: inherit;
|
||||
padding: 10px 90px 10px 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
bottom: 32px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.chat-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
bottom: 30px;
|
||||
}
|
||||
}
|
148
app/containers/Chat/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import AddIcon from "@/app/icons/addIcon.svg";
|
||||
import NextChatTitle from "@/app/icons/nextchatTitle.svg";
|
||||
|
||||
import MenuLayout from "@/app/components/MenuLayout";
|
||||
import Panel from "./ChatPanel";
|
||||
import Modal from "@/app/components/Modal";
|
||||
import SessionItem from "./components/SessionItem";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export default MenuLayout(function SessionList(props) {
|
||||
const { setShowPanel } = props;
|
||||
|
||||
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
|
||||
(state) => [
|
||||
state.sessions,
|
||||
state.currentSessionIndex,
|
||||
state.selectSession,
|
||||
state.moveSession,
|
||||
],
|
||||
);
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
setShowPanel?.(pathname === Path.Chat);
|
||||
}, [pathname]);
|
||||
|
||||
const onDragEnd: OnDragEndResponder = (result) => {
|
||||
const { destination, source } = result;
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
moveSession(source.index, destination.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
h-[100%] flex flex-col
|
||||
md:px-0
|
||||
`}
|
||||
>
|
||||
<div data-tauri-drag-region>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between
|
||||
py-6 max-md:box-content max-md:h-0
|
||||
md:py-7
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="">
|
||||
<NextChatTitle />
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer "
|
||||
onClick={() => {
|
||||
if (config.dontShowMaskSplashScreen) {
|
||||
chatStore.newSession();
|
||||
// navigate(Path.Chat);
|
||||
router.push(Path.Chat);
|
||||
} else {
|
||||
// navigate(Path.NewChat);
|
||||
router.push(Path.NewChat);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pb-3 text-sm sm:text-sm-mobile text-text-chat-header-subtitle`}
|
||||
>
|
||||
Build your own AI assistant.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 overflow-y-auto max-md:pb-chat-panel-mobile `}>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="chat-list">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`w-[100%]`}
|
||||
>
|
||||
{sessions.map((item, i) => (
|
||||
<SessionItem
|
||||
title={item.topic}
|
||||
time={new Date(item.lastUpdate).toLocaleString()}
|
||||
count={item.messages.length}
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
index={i}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
// navigate(Path.Chat);
|
||||
selectSession(i);
|
||||
router.push(Path.Chat);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
await Modal.warn({
|
||||
okText: Locale.ChatItem.DeleteOkBtn,
|
||||
cancelText: Locale.ChatItem.DeleteCancelBtn,
|
||||
title: Locale.ChatItem.DeleteTitle,
|
||||
content: Locale.ChatItem.DeleteContent,
|
||||
})
|
||||
) {
|
||||
chatStore.deleteSession(i);
|
||||
}
|
||||
}}
|
||||
mask={item.mask}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, Panel);
|
137
app/containers/Settings/SettingPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useAccessStore, useAppConfig } from "@/app/store";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import { Path } from "@/app/constant";
|
||||
import List from "@/app/components/List";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import Card from "@/app/components/Card";
|
||||
import SettingHeader from "./components/SettingHeader";
|
||||
import { MenuWrapperInspectProps } from "@/app/components/MenuLayout";
|
||||
import SyncItems from "./components/SyncItems";
|
||||
import DangerItems from "./components/DangerItems";
|
||||
import AppSetting from "./components/AppSetting";
|
||||
import MaskSetting from "./components/MaskSetting";
|
||||
import PromptSetting from "./components/PromptSetting";
|
||||
import ProviderSetting from "./components/ProviderSetting";
|
||||
import ModelConfigList from "./components/ModelSetting";
|
||||
|
||||
export default function Settings(props: MenuWrapperInspectProps) {
|
||||
const { setShowPanel, id } = props;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const accessStore = useAccessStore();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
useEffect(() => {
|
||||
const keydownEvent = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
};
|
||||
if (clientConfig?.isApp) {
|
||||
// Force to set custom endpoint to true if it's app
|
||||
accessStore.update((state) => {
|
||||
state.useCustomConfig = true;
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", keydownEvent);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", keydownEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
|
||||
const cardClassName = "mb-6 md:mb-8 last:mb-0";
|
||||
|
||||
const itemMap = {
|
||||
[Locale.Settings.GeneralSettings]: (
|
||||
<>
|
||||
<Card className={cardClassName} title={Locale.Settings.Basic.Title}>
|
||||
<AppSetting />
|
||||
</Card>
|
||||
|
||||
<Card className={cardClassName} title={Locale.Settings.Mask.Title}>
|
||||
<MaskSetting />
|
||||
</Card>
|
||||
<Card className={cardClassName} title={Locale.Settings.Prompt.Title}>
|
||||
<PromptSetting />
|
||||
</Card>
|
||||
<Card className={cardClassName} title={Locale.Settings.Provider.Title}>
|
||||
<ProviderSetting />
|
||||
</Card>
|
||||
|
||||
<Card className={cardClassName} title={Locale.Settings.Danger.Title}>
|
||||
<DangerItems />
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
[Locale.Settings.ModelSettings]: (
|
||||
<Card className={cardClassName} title={Locale.Settings.Models.Title}>
|
||||
<List
|
||||
widgetStyle={{
|
||||
// selectClassName: "min-w-select-mobile-lg",
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ModelConfigList
|
||||
modelConfig={config.modelConfig}
|
||||
updateConfig={(updater) => {
|
||||
const modelConfig = { ...config.modelConfig };
|
||||
updater(modelConfig);
|
||||
config.update((config) => (config.modelConfig = modelConfig));
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
</Card>
|
||||
),
|
||||
[Locale.Settings.DataSettings]: (
|
||||
<Card className={cardClassName} title={Locale.Settings.Sync.Title}>
|
||||
<SyncItems />
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col overflow-hidden bg-settings-panel
|
||||
h-setting-panel-mobile
|
||||
md:h-[100%] md:mr-2.5 md:rounded-md
|
||||
`}
|
||||
>
|
||||
<SettingHeader
|
||||
isMobileScreen={isMobileScreen}
|
||||
goback={() => setShowPanel?.(false)}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
max-md:w-[100%]
|
||||
px-4 py-5
|
||||
md:px-6 md:py-8
|
||||
flex items-start justify-center
|
||||
overflow-y-auto
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-full
|
||||
max-w-screen-md
|
||||
!overflow-x-hidden
|
||||
overflow-y-auto
|
||||
`}
|
||||
>
|
||||
{itemMap[id] || null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
200
app/containers/Settings/components/AppSetting.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarPicker } from "@/app/components/emoji";
|
||||
import { Popover } from "@/app/components/ui-lib";
|
||||
import Locale, {
|
||||
ALL_LANG_OPTIONS,
|
||||
AllLangs,
|
||||
changeLang,
|
||||
getLang,
|
||||
} from "@/app/locales";
|
||||
import Link from "next/link";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { useUpdateStore } from "@/app/store/update";
|
||||
import {
|
||||
SubmitKey,
|
||||
Theme,
|
||||
ThemeConfig,
|
||||
useAppConfig,
|
||||
} from "@/app/store/config";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { RELEASE_URL, UPDATE_URL } from "@/app/constant";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Select from "@/app/components/Select";
|
||||
import SlideRange from "@/app/components/SlideRange";
|
||||
import Switch from "@/app/components/Switch";
|
||||
|
||||
export interface AppSettingProps {}
|
||||
|
||||
export default function AppSetting(props: AppSettingProps) {
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const updateStore = useUpdateStore();
|
||||
const config = useAppConfig();
|
||||
const { update: updateConfig, isMobileScreen } = config;
|
||||
|
||||
const currentVersion = updateStore.formatVersion(updateStore.version);
|
||||
const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
|
||||
const hasNewVersion = currentVersion !== remoteId;
|
||||
const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
|
||||
|
||||
function checkUpdate(force = false) {
|
||||
setCheckingUpdate(true);
|
||||
updateStore.getLatestVersion(force).then(() => {
|
||||
setCheckingUpdate(false);
|
||||
});
|
||||
|
||||
console.log("[Update] local version ", updateStore.version);
|
||||
console.log("[Update] remote version ", updateStore.remoteVersion);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// checks per minutes
|
||||
checkUpdate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<List
|
||||
widgetStyle={{
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ListItem title={Locale.Settings.Avatar}>
|
||||
<Popover
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
content={
|
||||
<AvatarPicker
|
||||
onEmojiClick={(avatar: string) => {
|
||||
updateConfig((config) => (config.avatar = avatar));
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
open={showEmojiPicker}
|
||||
>
|
||||
<div
|
||||
className={styles.avatar}
|
||||
onClick={() => {
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
}}
|
||||
>
|
||||
<Avatar avatar={config.avatar} />
|
||||
</div>
|
||||
</Popover>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
|
||||
subTitle={
|
||||
checkingUpdate
|
||||
? Locale.Settings.Update.IsChecking
|
||||
: hasNewVersion
|
||||
? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
|
||||
: Locale.Settings.Update.IsLatest
|
||||
}
|
||||
>
|
||||
{checkingUpdate ? (
|
||||
<LoadingIcon />
|
||||
) : hasNewVersion ? (
|
||||
<Link href={updateUrl} target="_blank" className="link">
|
||||
{Locale.Settings.Update.GoToUpdate}
|
||||
</Link>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<ResetIcon />}
|
||||
text={Locale.Settings.Update.CheckUpdate}
|
||||
onClick={() => checkUpdate(true)}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.SendKey}>
|
||||
<Select
|
||||
value={config.submitKey}
|
||||
options={Object.values(SubmitKey).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
onSelect={(v) => {
|
||||
updateConfig((config) => (config.submitKey = v));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Theme}>
|
||||
<Select
|
||||
value={config.theme}
|
||||
options={Object.entries(ThemeConfig).map(([k, t]) => ({
|
||||
value: k as Theme,
|
||||
label: t.title,
|
||||
icon: <t.icon />,
|
||||
}))}
|
||||
onSelect={(e) => {
|
||||
updateConfig((config) => (config.theme = e));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Lang.Name}>
|
||||
<Select
|
||||
value={getLang()}
|
||||
options={AllLangs.map((lang) => ({
|
||||
value: lang,
|
||||
label: ALL_LANG_OPTIONS[lang],
|
||||
}))}
|
||||
onSelect={(e) => {
|
||||
changeLang(e);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.FontSize.Title}
|
||||
subTitle={Locale.Settings.FontSize.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={config.fontSize}
|
||||
range={{
|
||||
start: 12,
|
||||
stroke: 28,
|
||||
}}
|
||||
step={1}
|
||||
onSlide={(e) => updateConfig((config) => (config.fontSize = e))}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.AutoGenerateTitle.Title}
|
||||
subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.enableAutoGenerateTitle}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.enableAutoGenerateTitle = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.SendPreviewBubble.Title}
|
||||
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.sendPreviewBubble}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.sendPreviewBubble = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
153
app/containers/Settings/components/DangerItems.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { showConfirm } from "@/app/components/ui-lib";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAccessStore } from "@/app/store/access";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { OPENAI_BASE_URL, ServiceProvider } from "@/app/constant";
|
||||
import { useUpdateStore } from "@/app/store/update";
|
||||
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Input from "@/app/components/Input";
|
||||
import Btn from "@/app/components/Btn";
|
||||
|
||||
export default function DangerItems() {
|
||||
const chatStore = useChatStore();
|
||||
const appConfig = useAppConfig();
|
||||
const accessStore = useAccessStore();
|
||||
const updateStore = useUpdateStore();
|
||||
const { isMobileScreen } = appConfig;
|
||||
|
||||
const enabledAccessControl = useMemo(
|
||||
() => accessStore.enabledAccessControl(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
|
||||
const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
|
||||
|
||||
const shouldHideBalanceQuery = useMemo(() => {
|
||||
const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
|
||||
return (
|
||||
accessStore.hideBalanceQuery ||
|
||||
isOpenAiUrl ||
|
||||
accessStore.provider === ServiceProvider.Azure
|
||||
);
|
||||
}, [
|
||||
accessStore.hideBalanceQuery,
|
||||
accessStore.openaiUrl,
|
||||
accessStore.provider,
|
||||
]);
|
||||
|
||||
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||
const usage = {
|
||||
used: updateStore.used,
|
||||
subscription: updateStore.subscription,
|
||||
};
|
||||
|
||||
function checkUsage(force = false) {
|
||||
if (shouldHideBalanceQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingUsage(true);
|
||||
updateStore.updateUsage(force).finally(() => {
|
||||
setLoadingUsage(false);
|
||||
});
|
||||
}
|
||||
|
||||
const showUsage = accessStore.isAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
showUsage && checkUsage();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<List
|
||||
widgetStyle={{
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
rangeNextLine: isMobileScreen,
|
||||
inputNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
{showAccessCode && (
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.AccessCode.Title}
|
||||
subTitle={Locale.Settings.Access.AccessCode.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.accessCode}
|
||||
type="password"
|
||||
placeholder={Locale.Settings.Access.AccessCode.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update((access) => (access.accessCode = e));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{!shouldHideBalanceQuery && !clientConfig?.isApp ? (
|
||||
<ListItem
|
||||
title={Locale.Settings.Usage.Title}
|
||||
subTitle={
|
||||
showUsage
|
||||
? loadingUsage
|
||||
? Locale.Settings.Usage.IsChecking
|
||||
: Locale.Settings.Usage.SubTitle(
|
||||
usage?.used ?? "[?]",
|
||||
usage?.subscription ?? "[?]",
|
||||
)
|
||||
: Locale.Settings.Usage.NoAccess
|
||||
}
|
||||
>
|
||||
{!showUsage || loadingUsage ? (
|
||||
<div />
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<ResetIcon />}
|
||||
text={Locale.Settings.Usage.Check}
|
||||
onClick={() => checkUsage(true)}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Danger.Reset.Title}
|
||||
subTitle={Locale.Settings.Danger.Reset.SubTitle}
|
||||
>
|
||||
<Btn
|
||||
text={Locale.Settings.Danger.Reset.Action}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
|
||||
appConfig.reset();
|
||||
}
|
||||
}}
|
||||
type="danger"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Danger.Clear.Title}
|
||||
subTitle={Locale.Settings.Danger.Clear.SubTitle}
|
||||
>
|
||||
<Btn
|
||||
text={Locale.Settings.Danger.Clear.Action}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
|
||||
chatStore.clearAllData();
|
||||
}
|
||||
}}
|
||||
type="danger"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
162
app/containers/Settings/components/MaskConfig.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import { ContextPrompts, MaskAvatar } from "@/app/components/mask";
|
||||
import { Path } from "@/app/constant";
|
||||
import { ModelConfig, useAppConfig } from "@/app/store/config";
|
||||
import { Mask } from "@/app/store/mask";
|
||||
import { Updater } from "@/app/typing";
|
||||
import { copyToClipboard } from "@/app/utils";
|
||||
import Locale from "@/app/locales";
|
||||
import { Popover, showConfirm } from "@/app/components/ui-lib";
|
||||
import { AvatarPicker } from "@/app/components/emoji";
|
||||
import ModelSetting from "@/app/containers/Settings/components/ModelSetting";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export default function MaskConfig(props: {
|
||||
mask: Mask;
|
||||
updateMask: Updater<Mask>;
|
||||
extraListItems?: JSX.Element;
|
||||
readonly?: boolean;
|
||||
shouldSyncFromGlobal?: boolean;
|
||||
}) {
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const updateConfig = (updater: (config: ModelConfig) => void) => {
|
||||
if (props.readonly) return;
|
||||
|
||||
const config = { ...props.mask.modelConfig };
|
||||
updater(config);
|
||||
props.updateMask((mask) => {
|
||||
mask.modelConfig = config;
|
||||
// if user changed current session mask, it will disable auto sync
|
||||
mask.syncGlobalConfig = false;
|
||||
});
|
||||
};
|
||||
|
||||
const copyMaskLink = () => {
|
||||
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
|
||||
copyToClipboard(maskLink);
|
||||
};
|
||||
|
||||
const globalConfig = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = globalConfig;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextPrompts
|
||||
context={props.mask.context}
|
||||
updateContext={(updater) => {
|
||||
const context = props.mask.context.slice();
|
||||
updater(context);
|
||||
props.updateMask((mask) => (mask.context = context));
|
||||
}}
|
||||
/>
|
||||
|
||||
<List
|
||||
widgetStyle={{
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ListItem title={Locale.Mask.Config.Avatar}>
|
||||
<Popover
|
||||
content={
|
||||
<AvatarPicker
|
||||
onEmojiClick={(emoji) => {
|
||||
props.updateMask((mask) => (mask.avatar = emoji));
|
||||
setShowPicker(false);
|
||||
}}
|
||||
></AvatarPicker>
|
||||
}
|
||||
open={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
>
|
||||
<div
|
||||
onClick={() => setShowPicker(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<MaskAvatar
|
||||
avatar={props.mask.avatar}
|
||||
model={props.mask.modelConfig.model}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Mask.Config.Name}>
|
||||
<Input
|
||||
type="text"
|
||||
value={props.mask.name}
|
||||
onChange={(e) =>
|
||||
props.updateMask((mask) => {
|
||||
mask.name = e;
|
||||
})
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.HideContext.Title}
|
||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={!!props.mask.hideContext}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.hideContext = e;
|
||||
});
|
||||
}}
|
||||
></Switch>
|
||||
</ListItem>
|
||||
|
||||
{!props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Share.Title}
|
||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||
>
|
||||
<IconButton
|
||||
icon={<CopyIcon />}
|
||||
text={Locale.Mask.Config.Share.Action}
|
||||
onClick={copyMaskLink}
|
||||
/>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
{props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Sync.Title}
|
||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={!!props.mask.syncGlobalConfig}
|
||||
onChange={async (e) => {
|
||||
const checked = e;
|
||||
if (
|
||||
checked &&
|
||||
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
||||
) {
|
||||
props.updateMask((mask) => {
|
||||
mask.syncGlobalConfig = checked;
|
||||
mask.modelConfig = { ...globalConfig.modelConfig };
|
||||
});
|
||||
} else if (!checked) {
|
||||
props.updateMask((mask) => {
|
||||
mask.syncGlobalConfig = checked;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<ModelSetting
|
||||
modelConfig={{ ...props.mask.modelConfig }}
|
||||
updateConfig={updateConfig}
|
||||
/>
|
||||
{props.extraListItems}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
39
app/containers/Settings/components/MaskSetting.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
|
||||
export interface MaskSettingProps {}
|
||||
|
||||
export default function MaskSetting(props: MaskSettingProps) {
|
||||
const config = useAppConfig();
|
||||
const updateConfig = config.update;
|
||||
|
||||
return (
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Settings.Mask.Splash.Title}
|
||||
subTitle={Locale.Settings.Mask.Splash.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={!config.dontShowMaskSplashScreen}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.dontShowMaskSplashScreen = !e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Mask.Builtin.Title}
|
||||
subTitle={Locale.Settings.Mask.Builtin.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.hideBuiltinMasks}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.hideBuiltinMasks = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
220
app/containers/Settings/components/ModelSetting.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ListItem } from "@/app/components/List";
|
||||
import {
|
||||
ModalConfigValidator,
|
||||
ModelConfig,
|
||||
useAppConfig,
|
||||
} from "@/app/store/config";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import Locale from "@/app/locales";
|
||||
import Select from "@/app/components/Select";
|
||||
import SlideRange from "@/app/components/SlideRange";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export default function ModelSetting(props: {
|
||||
modelConfig: ModelConfig;
|
||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||
}) {
|
||||
const allModels = useAllModels();
|
||||
const { isMobileScreen } = useAppConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Model}>
|
||||
<Select
|
||||
value={props.modelConfig.model}
|
||||
options={allModels
|
||||
.filter((v) => v.available)
|
||||
.map((v) => ({
|
||||
value: v.name,
|
||||
label: `${v.displayName}(${v.provider?.providerName})`,
|
||||
}))}
|
||||
onSelect={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.model = ModalConfigValidator.model(e)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Temperature.Title}
|
||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.temperature}
|
||||
range={{
|
||||
start: 0,
|
||||
stroke: 1,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.temperature = ModalConfigValidator.temperature(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.TopP.Title}
|
||||
subTitle={Locale.Settings.TopP.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.top_p ?? 1}
|
||||
range={{
|
||||
start: 0,
|
||||
stroke: 1,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.top_p = ModalConfigValidator.top_p(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.MaxTokens.Title}
|
||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1024}
|
||||
max={512000}
|
||||
value={props.modelConfig.max_tokens}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.max_tokens = ModalConfigValidator.max_tokens(e)),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
|
||||
{props.modelConfig.model.startsWith("gemini") ? null : (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.PresencePenalty.Title}
|
||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.presence_penalty}
|
||||
range={{
|
||||
start: -2,
|
||||
stroke: 4,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.presence_penalty =
|
||||
ModalConfigValidator.presence_penalty(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.FrequencyPenalty.Title}
|
||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.frequency_penalty}
|
||||
range={{
|
||||
start: -2,
|
||||
stroke: 4,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.frequency_penalty =
|
||||
ModalConfigValidator.frequency_penalty(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={props.modelConfig.enableInjectSystemPrompts}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.enableInjectSystemPrompts = e),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.InputTemplate.Title}
|
||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||
nextline={isMobileScreen}
|
||||
validator={(v: string) => {
|
||||
if (!v.includes("{{input}}")) {
|
||||
return {
|
||||
error: true,
|
||||
message: Locale.Settings.InputTemplate.Error,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: false };
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={props.modelConfig.template}
|
||||
onChange={(e = "") =>
|
||||
props.updateConfig((config) => (config.template = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
<ListItem
|
||||
title={Locale.Settings.HistoryCount.Title}
|
||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.historyMessageCount}
|
||||
range={{
|
||||
start: 0,
|
||||
stroke: 64,
|
||||
}}
|
||||
step={1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig((config) => (config.historyMessageCount = e));
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.CompressThreshold.Title}
|
||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={500}
|
||||
max={4000}
|
||||
value={props.modelConfig.compressMessageLengthThreshold}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.compressMessageLengthThreshold = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||
<Switch
|
||||
value={props.modelConfig.sendMemory}
|
||||
onChange={(e) =>
|
||||
props.updateConfig((config) => (config.sendMemory = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
63
app/containers/Settings/components/PromptSetting.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import UserPromptModal from "./UserPromptModal";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import { SearchService, usePromptStore } from "@/app/store/prompt";
|
||||
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Btn from "@/app/components/Btn";
|
||||
|
||||
import EditIcon from "@/app/icons/editIcon.svg";
|
||||
|
||||
export interface PromptSettingProps {}
|
||||
|
||||
export default function PromptSetting(props: PromptSettingProps) {
|
||||
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const config = useAppConfig();
|
||||
const updateConfig = config.update;
|
||||
|
||||
const builtinCount = SearchService.count.builtin;
|
||||
|
||||
const promptStore = usePromptStore();
|
||||
const customCount = promptStore.getUserPrompts().length ?? 0;
|
||||
|
||||
const textStyle = " !text-sm";
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Settings.Prompt.Disable.Title}
|
||||
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.disablePromptHint}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.disablePromptHint = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Prompt.List}
|
||||
subTitle={Locale.Settings.Prompt.ListCount(builtinCount, customCount)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Btn
|
||||
onClick={() => setShowPromptModal(true)}
|
||||
text={
|
||||
<span className={textStyle}>{Locale.Settings.Prompt.Edit}</span>
|
||||
}
|
||||
prefixIcon={config.isMobileScreen ? undefined : <EditIcon />}
|
||||
></Btn>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
{shouldShowPromptModal && (
|
||||
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
283
app/containers/Settings/components/ProviderSetting.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
Google,
|
||||
OPENAI_BASE_URL,
|
||||
ServiceProvider,
|
||||
SlotID,
|
||||
} from "@/app/constant";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAccessStore } from "@/app/store/access";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Select from "@/app/components/Select";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export default function ProviderSetting() {
|
||||
const accessStore = useAccessStore();
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
|
||||
return (
|
||||
<List
|
||||
id={SlotID.CustomModel}
|
||||
widgetStyle={{
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
inputNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
{!accessStore.hideUserApiKey && (
|
||||
<>
|
||||
{
|
||||
// Conditionally render the following ListItem based on clientConfig.isApp
|
||||
!clientConfig?.isApp && ( // only show if isApp is false
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.CustomEndpoint.Title}
|
||||
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={accessStore.useCustomConfig}
|
||||
onChange={(e) =>
|
||||
accessStore.update((access) => (access.useCustomConfig = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
{accessStore.useCustomConfig && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Provider.Title}
|
||||
subTitle={Locale.Settings.Access.Provider.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={accessStore.provider}
|
||||
onSelect={(e) => {
|
||||
accessStore.update((access) => (access.provider = e));
|
||||
}}
|
||||
options={Object.entries(ServiceProvider).map(([k, v]) => ({
|
||||
value: v,
|
||||
label: k,
|
||||
}))}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{accessStore.provider === ServiceProvider.OpenAI && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
|
||||
subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.openaiUrl}
|
||||
placeholder={OPENAI_BASE_URL}
|
||||
onChange={(e = "") =>
|
||||
accessStore.update((access) => (access.openaiUrl = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.OpenAI.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.openaiApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.OpenAI.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.openaiApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
{accessStore.provider === ServiceProvider.Azure && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Azure.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Azure.Endpoint.SubTitle +
|
||||
Azure.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.azureUrl}
|
||||
placeholder={Azure.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update((access) => (access.azureUrl = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Azure.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.azureApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.Azure.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.azureApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Azure.ApiVerion.Title}
|
||||
subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.azureApiVersion}
|
||||
placeholder="2023-08-01-preview"
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.azureApiVersion = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
{accessStore.provider === ServiceProvider.Google && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Google.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Google.Endpoint.SubTitle +
|
||||
Google.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.googleUrl}
|
||||
placeholder={Google.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update((access) => (access.googleUrl = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Google.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.googleApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.Google.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.googleApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Google.ApiVersion.Title}
|
||||
subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.googleApiVersion}
|
||||
placeholder="2023-08-01-preview"
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.googleApiVersion = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
{accessStore.provider === ServiceProvider.Anthropic && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Anthropic.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
|
||||
Anthropic.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.anthropicUrl}
|
||||
placeholder={Anthropic.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicUrl = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.anthropicApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.anthropicApiVersion}
|
||||
placeholder={Anthropic.Vision}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicApiVersion = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.CustomModel.Title}
|
||||
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.customModels}
|
||||
placeholder="model1,model2,model3"
|
||||
onChange={(e) => config.update((config) => (config.customModels = e))}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
47
app/containers/Settings/components/SettingHeader.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Locale from "@/app/locales";
|
||||
import GobackIcon from "@/app/icons/goback.svg";
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
isMobileScreen: boolean;
|
||||
goback: () => void;
|
||||
}
|
||||
|
||||
export default function SettingHeader(props: ChatHeaderProps) {
|
||||
const { isMobileScreen, goback } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap border-b border-settings-header
|
||||
max-md:h-menu-title-mobile max-md:bg-settings-header-mobile
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{isMobileScreen ? (
|
||||
<div
|
||||
className="absolute left-4 top-[50%] translate-y-[-50%] cursor-pointer"
|
||||
onClick={() => goback()}
|
||||
>
|
||||
<GobackIcon />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex-1
|
||||
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
|
||||
md:mr-4
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
line-clamp-1 cursor-pointer text-text-settings-panel-header-title text-chat-header-title font-common
|
||||
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5 !font-medium
|
||||
`}
|
||||
>
|
||||
{Locale.Settings.Title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
199
app/containers/Settings/components/SyncConfigModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Modal } from "@/app/components/ui-lib";
|
||||
import { useSyncStore } from "@/app/store/sync";
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { ProviderType } from "@/app/utils/cloud";
|
||||
import { STORAGE_KEY } from "@/app/constant";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import ConnectionIcon from "@/app/icons/connection.svg";
|
||||
import CloudSuccessIcon from "@/app/icons/cloud-success.svg";
|
||||
import CloudFailIcon from "@/app/icons/cloud-fail.svg";
|
||||
import ConfirmIcon from "@/app/icons/confirm.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Select from "@/app/components/Select";
|
||||
import Input from "@/app/components/Input";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
|
||||
function CheckButton() {
|
||||
const syncStore = useSyncStore();
|
||||
|
||||
const couldCheck = useMemo(() => {
|
||||
return syncStore.cloudSync();
|
||||
}, [syncStore]);
|
||||
|
||||
const [checkState, setCheckState] = useState<
|
||||
"none" | "checking" | "success" | "failed"
|
||||
>("none");
|
||||
|
||||
async function check() {
|
||||
setCheckState("checking");
|
||||
const valid = await syncStore.check();
|
||||
setCheckState(valid ? "success" : "failed");
|
||||
}
|
||||
|
||||
if (!couldCheck) return null;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
text={Locale.Settings.Sync.Config.Modal.Check}
|
||||
bordered
|
||||
onClick={check}
|
||||
icon={
|
||||
checkState === "none" ? (
|
||||
<ConnectionIcon />
|
||||
) : checkState === "checking" ? (
|
||||
<LoadingIcon />
|
||||
) : checkState === "success" ? (
|
||||
<CloudSuccessIcon />
|
||||
) : checkState === "failed" ? (
|
||||
<CloudFailIcon />
|
||||
) : (
|
||||
<ConnectionIcon />
|
||||
)
|
||||
}
|
||||
></IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SyncConfigModal(props: { onClose?: () => void }) {
|
||||
const syncStore = useSyncStore();
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Sync.Config.Modal.Title}
|
||||
onClose={() => props.onClose?.()}
|
||||
actions={[
|
||||
<CheckButton key="check" />,
|
||||
<IconButton
|
||||
key="confirm"
|
||||
onClick={props.onClose}
|
||||
icon={<ConfirmIcon />}
|
||||
bordered
|
||||
text={Locale.UI.Confirm}
|
||||
/>,
|
||||
]}
|
||||
className="!bg-modal-mask active-new"
|
||||
>
|
||||
<List
|
||||
widgetStyle={{
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.SyncType.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={syncStore.provider}
|
||||
options={Object.entries(ProviderType).map(([k, v]) => ({
|
||||
value: v,
|
||||
label: k,
|
||||
}))}
|
||||
onSelect={(v) => {
|
||||
syncStore.update((config) => (config.provider = v));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.Proxy.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={syncStore.useProxy}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.useProxy = e));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{syncStore.useProxy ? (
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.ProxyUrl.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.proxyUrl}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.proxyUrl = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
{syncStore.provider === ProviderType.WebDAV && (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.webdav.endpoint}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.webdav.endpoint = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.webdav.username}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.webdav.username = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
|
||||
<Input
|
||||
value={syncStore.webdav.password}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.webdav.password = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{syncStore.provider === ProviderType.UpStash && (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.upstash.endpoint}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.upstash.endpoint = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.upstash.username}
|
||||
placeholder={STORAGE_KEY}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.upstash.username = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
|
||||
<Input
|
||||
value={syncStore.upstash.apiKey}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.upstash.apiKey = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
112
app/containers/Settings/components/SyncItems.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import ConfigIcon from "@/app/icons/configIcon2.svg";
|
||||
import ExportIcon from "@/app/icons/exportIcon.svg";
|
||||
import ImportIcon from "@/app/icons/importIcon.svg";
|
||||
import SyncIcon from "@/app/icons/syncIcon.svg";
|
||||
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useMaskStore } from "@/app/store/mask";
|
||||
import { usePromptStore } from "@/app/store/prompt";
|
||||
import { useSyncStore } from "@/app/store/sync";
|
||||
import { useMemo, useState } from "react";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import SyncConfigModal from "./SyncConfigModal";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Btn from "@/app/components/Btn";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
|
||||
export default function SyncItems() {
|
||||
const syncStore = useSyncStore();
|
||||
const chatStore = useChatStore();
|
||||
const promptStore = usePromptStore();
|
||||
const maskStore = useMaskStore();
|
||||
const couldSync = useMemo(() => {
|
||||
return syncStore.cloudSync();
|
||||
}, [syncStore]);
|
||||
|
||||
const { isMobileScreen } = useAppConfig();
|
||||
|
||||
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
|
||||
|
||||
const stateOverview = useMemo(() => {
|
||||
const sessions = chatStore.sessions;
|
||||
const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
|
||||
|
||||
return {
|
||||
chat: sessions.length,
|
||||
message: messageCount,
|
||||
prompt: Object.keys(promptStore.prompts).length,
|
||||
mask: Object.keys(maskStore.masks).length,
|
||||
};
|
||||
}, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
|
||||
|
||||
const textStyle = "!text-sm";
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.CloudState}
|
||||
subTitle={
|
||||
syncStore.lastProvider
|
||||
? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
|
||||
syncStore.lastProvider
|
||||
}]`
|
||||
: Locale.Settings.Sync.NotSyncYet
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Btn
|
||||
onClick={() => {
|
||||
setShowSyncConfigModal(true);
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Config}</span>}
|
||||
prefixIcon={isMobileScreen ? undefined : <ConfigIcon />}
|
||||
></Btn>
|
||||
{couldSync && (
|
||||
<Btn
|
||||
onClick={async () => {
|
||||
try {
|
||||
await syncStore.sync();
|
||||
showToast(Locale.Settings.Sync.Success);
|
||||
} catch (e) {
|
||||
showToast(Locale.Settings.Sync.Fail);
|
||||
console.error("[Sync]", e);
|
||||
}
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Sync}</span>}
|
||||
prefixIcon={<SyncIcon />}
|
||||
></Btn>
|
||||
)}
|
||||
</div>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.LocalState}
|
||||
subTitle={Locale.Settings.Sync.Overview(stateOverview)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Btn
|
||||
onClick={() => {
|
||||
syncStore.export();
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Export}</span>}
|
||||
prefixIcon={<ExportIcon />}
|
||||
></Btn>
|
||||
<Btn
|
||||
onClick={async () => {
|
||||
syncStore.import();
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Import}</span>}
|
||||
prefixIcon={<ImportIcon />}
|
||||
></Btn>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{showSyncConfigModal && (
|
||||
<SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
169
app/containers/Settings/components/UserPromptModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Prompt, SearchService, usePromptStore } from "@/app/store/prompt";
|
||||
import { Input as Textarea, Modal } from "@/app/components/ui-lib";
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
|
||||
import AddIcon from "@/app/icons/add.svg";
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import ClearIcon from "@/app/icons/clear.svg";
|
||||
import EditIcon from "@/app/icons/edit.svg";
|
||||
import EyeIcon from "@/app/icons/eye.svg";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
import { copyToClipboard } from "@/app/utils";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
function EditPromptModal(props: { id: string; onClose: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
const prompt = promptStore.get(props.id);
|
||||
|
||||
return prompt ? (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Prompt.EditModal.Title}
|
||||
onClose={props.onClose}
|
||||
actions={[
|
||||
<IconButton
|
||||
key=""
|
||||
onClick={props.onClose}
|
||||
text={Locale.UI.Confirm}
|
||||
bordered
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<div className={styles["edit-prompt-modal"]}>
|
||||
<Input
|
||||
type="text"
|
||||
value={prompt.title}
|
||||
readOnly={!prompt.isUser}
|
||||
className={styles["edit-prompt-title"]}
|
||||
onChange={(e) =>
|
||||
promptStore.updatePrompt(props.id, (prompt) => (prompt.title = e))
|
||||
}
|
||||
></Input>
|
||||
<Textarea
|
||||
value={prompt.content}
|
||||
readOnly={!prompt.isUser}
|
||||
className={styles["edit-prompt-content"]}
|
||||
rows={10}
|
||||
onInput={(e) =>
|
||||
promptStore.updatePrompt(
|
||||
props.id,
|
||||
(prompt) => (prompt.content = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></Textarea>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default function UserPromptModal(props: { onClose?: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
const userPrompts = promptStore.getUserPrompts();
|
||||
const builtinPrompts = SearchService.builtinPrompts;
|
||||
const allPrompts = userPrompts.concat(builtinPrompts);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
|
||||
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
|
||||
|
||||
const [editingPromptId, setEditingPromptId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchInput.length > 0) {
|
||||
const searchResult = SearchService.search(searchInput);
|
||||
setSearchPrompts(searchResult);
|
||||
} else {
|
||||
setSearchPrompts([]);
|
||||
}
|
||||
}, [searchInput]);
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Prompt.Modal.Title}
|
||||
onClose={() => props.onClose?.()}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="add"
|
||||
onClick={() => {
|
||||
const promptId = promptStore.add({
|
||||
id: nanoid(),
|
||||
createdAt: Date.now(),
|
||||
title: "Empty Prompt",
|
||||
content: "Empty Prompt Content",
|
||||
});
|
||||
setEditingPromptId(promptId);
|
||||
}}
|
||||
icon={<AddIcon />}
|
||||
bordered
|
||||
text={Locale.Settings.Prompt.Modal.Add}
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<div className={styles["user-prompt-modal"]}>
|
||||
<Input
|
||||
type="text"
|
||||
className={styles["user-prompt-search"]}
|
||||
placeholder={Locale.Settings.Prompt.Modal.Search}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e)}
|
||||
></Input>
|
||||
|
||||
<div className={styles["user-prompt-list"]}>
|
||||
{prompts.map((v, _) => (
|
||||
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
|
||||
<div className={styles["user-prompt-header"]}>
|
||||
<div className={styles["user-prompt-title"]}>{v.title}</div>
|
||||
<div className={styles["user-prompt-content"] + " one-line"}>
|
||||
{v.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["user-prompt-buttons"]}>
|
||||
{v.isUser && (
|
||||
<IconButton
|
||||
icon={<ClearIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => promptStore.remove(v.id!)}
|
||||
/>
|
||||
)}
|
||||
{v.isUser ? (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => setEditingPromptId(v.id)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => setEditingPromptId(v.id)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={<CopyIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => copyToClipboard(v.content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{editingPromptId !== undefined && (
|
||||
<EditPromptModal
|
||||
id={editingPromptId!}
|
||||
onClose={() => setEditingPromptId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
69
app/containers/Settings/index.module.scss
Normal file
@@ -0,0 +1,69 @@
|
||||
.avatar {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.edit-prompt-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.edit-prompt-title {
|
||||
max-width: unset;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.edit-prompt-content {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.user-prompt-modal {
|
||||
min-height: 40vh;
|
||||
|
||||
.user-prompt-search {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.user-prompt-list {
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
|
||||
.user-prompt-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.user-prompt-header {
|
||||
max-width: calc(100% - 100px);
|
||||
|
||||
.user-prompt-title {
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
font-weight: bold;
|
||||
}
|
||||
.user-prompt-content {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-prompt-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 2px;
|
||||
|
||||
.user-prompt-button {
|
||||
//height: 100%;
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
98
app/containers/Settings/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
import Locale from "@/app/locales";
|
||||
import MenuLayout from "@/app/components/MenuLayout";
|
||||
|
||||
import Panel from "./SettingPanel";
|
||||
|
||||
import GotoIcon from "@/app/icons/goto.svg";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const list = [
|
||||
{
|
||||
id: Locale.Settings.GeneralSettings,
|
||||
title: Locale.Settings.GeneralSettings,
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
id: Locale.Settings.ModelSettings,
|
||||
title: Locale.Settings.ModelSettings,
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
id: Locale.Settings.DataSettings,
|
||||
title: Locale.Settings.DataSettings,
|
||||
icon: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default MenuLayout(function SettingList(props) {
|
||||
const { setShowPanel, setExternalProps } = props;
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const [selected, setSelected] = useState(list[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
setExternalProps?.(list[0]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
max-md:h-[100%] max-md:mx-[-1rem] max-md:py-6 max-md:px-4 max-md:bg-settings-menu-mobile
|
||||
md:pt-7
|
||||
`}
|
||||
>
|
||||
<div data-tauri-drag-region>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between
|
||||
max-md:h-menu-title-mobile
|
||||
md:pb-5 md:px-4
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="text-setting-title text-text-settings-menu-title font-common !font-bold">
|
||||
{Locale.Settings.Title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col gap-2 overflow-y-auto overflow-x-hidden w-[100%]`}
|
||||
>
|
||||
{list.map((i) => (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`
|
||||
p-4 font-common text-setting-items font-normal text-text-settings-menu-item-title
|
||||
cursor-pointer
|
||||
border
|
||||
rounded-md
|
||||
border-transparent
|
||||
${
|
||||
selected === i.id && !isMobileScreen
|
||||
? `!bg-chat-menu-session-selected !border-chat-menu-session-selected !font-medium`
|
||||
: `hover:bg-chat-menu-session-unselected hover:border-chat-menu-session-unselected`
|
||||
}
|
||||
|
||||
flex justify-between items-center
|
||||
max-md:bg-settings-menu-item-mobile
|
||||
`}
|
||||
onClick={() => {
|
||||
setShowPanel?.(true);
|
||||
setExternalProps?.(i);
|
||||
setSelected(i.id);
|
||||
}}
|
||||
>
|
||||
{i.title}
|
||||
{i.icon}
|
||||
{isMobileScreen && <GotoIcon />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, Panel);
|
130
app/containers/Sidebar/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import GitHubIcon from "@/app/icons/githubIcon.svg";
|
||||
import DiscoverIcon from "@/app/icons/discoverActive.svg";
|
||||
import DiscoverInactiveIcon from "@/app/icons/discoverInactive.svg";
|
||||
import DiscoverMobileActive from "@/app/icons/discoverMobileActive.svg";
|
||||
import DiscoverMobileInactive from "@/app/icons/discoverMobileInactive.svg";
|
||||
import SettingIcon from "@/app/icons/settingActive.svg";
|
||||
import SettingInactiveIcon from "@/app/icons/settingInactive.svg";
|
||||
import SettingMobileActive from "@/app/icons/settingMobileActive.svg";
|
||||
import SettingMobileInactive from "@/app/icons/settingMobileInactive.svg";
|
||||
import AssistantActiveIcon from "@/app/icons/assistantActive.svg";
|
||||
import AssistantInactiveIcon from "@/app/icons/assistantInactive.svg";
|
||||
import AssistantMobileActive from "@/app/icons/assistantMobileActive.svg";
|
||||
import AssistantMobileInactive from "@/app/icons/assistantMobileInactive.svg";
|
||||
|
||||
import { useAppConfig } from "@/app/store";
|
||||
import { Path, REPO_URL } from "@/app/constant";
|
||||
import useHotKey from "@/app/hooks/useHotKey";
|
||||
import ActionsBar from "@/app/components/ActionsBar";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
// const navigate = useNavigate();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
useHotKey();
|
||||
|
||||
let selectedTab: string;
|
||||
switch (pathname) {
|
||||
case Path.Masks:
|
||||
case Path.NewChat:
|
||||
selectedTab = Path.Masks;
|
||||
break;
|
||||
case Path.Settings:
|
||||
selectedTab = Path.Settings;
|
||||
break;
|
||||
default:
|
||||
selectedTab = Path.Home;
|
||||
}
|
||||
console.log("======", selectedTab);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex h-[100%]
|
||||
max-md:flex-col-reverse max-md:w-[100%]
|
||||
md:relative
|
||||
`}
|
||||
>
|
||||
<ActionsBar
|
||||
inMobile={isMobileScreen}
|
||||
actionsShema={[
|
||||
{
|
||||
id: Path.Masks,
|
||||
icons: {
|
||||
active: <DiscoverIcon />,
|
||||
inactive: <DiscoverInactiveIcon />,
|
||||
mobileActive: <DiscoverMobileActive />,
|
||||
mobileInactive: <DiscoverMobileInactive />,
|
||||
},
|
||||
title: "Discover",
|
||||
activeClassName: "shadow-sidebar-btn-shadow",
|
||||
className: "mb-4 hover:bg-sidebar-btn-hovered",
|
||||
},
|
||||
{
|
||||
id: Path.Home,
|
||||
icons: {
|
||||
active: <AssistantActiveIcon />,
|
||||
inactive: <AssistantInactiveIcon />,
|
||||
mobileActive: <AssistantMobileActive />,
|
||||
mobileInactive: <AssistantMobileInactive />,
|
||||
},
|
||||
title: "Assistant",
|
||||
activeClassName: "shadow-sidebar-btn-shadow",
|
||||
className: "mb-4 hover:bg-sidebar-btn-hovered",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
icons: <GitHubIcon />,
|
||||
className: "!p-2 mb-3 hover:bg-sidebar-btn-hovered",
|
||||
},
|
||||
{
|
||||
id: Path.Settings,
|
||||
icons: {
|
||||
active: <SettingIcon />,
|
||||
inactive: <SettingInactiveIcon />,
|
||||
mobileActive: <SettingMobileActive />,
|
||||
mobileInactive: <SettingMobileInactive />,
|
||||
},
|
||||
className: "!p-2 hover:bg-sidebar-btn-hovered",
|
||||
title: "Settrings",
|
||||
},
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
if (id === "github") {
|
||||
return window.open(REPO_URL, "noopener noreferrer");
|
||||
}
|
||||
if (id !== Path.Masks) {
|
||||
router.push(id);
|
||||
return;
|
||||
}
|
||||
if (config.dontShowMaskSplashScreen !== true) {
|
||||
// navigate(Path.NewChat, { state: { fromHome: true } });
|
||||
router.push(Path.NewChat);
|
||||
return;
|
||||
} else {
|
||||
// navigate(Path.Masks, { state: { fromHome: true } });
|
||||
router.push(Path.Masks);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
groups={{
|
||||
normal: [
|
||||
[Path.Home, Path.Masks],
|
||||
["github", Path.Settings],
|
||||
],
|
||||
mobile: [[Path.Home, Path.Masks, Path.Settings]],
|
||||
}}
|
||||
selected={selectedTab}
|
||||
className={`
|
||||
max-md:bg-sidebar-mobile max-md:h-mobile max-md:justify-around
|
||||
2xl:px-5 xl:px-4 md:px-2 md:py-6 md:flex-col
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
146
app/containers/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
require("../polyfill");
|
||||
|
||||
import { HashRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import { useState, useEffect, useLayoutEffect } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Path } from "@/app/constant";
|
||||
import { ErrorBoundary } from "@/app/components/error";
|
||||
import { getISOLang } from "@/app/locales";
|
||||
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
|
||||
import { AuthPage } from "@/app/components/auth";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { useAccessStore, useAppConfig } from "@/app/store";
|
||||
import { useLoadData } from "@/app/hooks/useLoadData";
|
||||
import Loading from "@/app/components/Loading";
|
||||
import Screen from "@/app/components/Screen";
|
||||
import { SideBar } from "./Sidebar";
|
||||
import GlobalLoading from "@/app/components/GlobalLoading";
|
||||
import { MOBILE_MAX_WIDTH } from "../hooks/useListenWinResize";
|
||||
|
||||
const Settings = dynamic(
|
||||
async () => await import("@/app/containers/Settings"),
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const Chat = dynamic(async () => await import("@/app/containers/Chat"), {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const NewChat = dynamic(
|
||||
async () => (await import("@/app/components/new-chat")).NewChat,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const MaskPage = dynamic(
|
||||
async () => (await import("@/app/components/mask")).MaskPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
function useHtmlLang() {
|
||||
useEffect(() => {
|
||||
const lang = getISOLang();
|
||||
const htmlLang = document.documentElement.lang;
|
||||
|
||||
if (lang !== htmlLang) {
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
const useHasHydrated = () => {
|
||||
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
return hasHydrated;
|
||||
};
|
||||
|
||||
const loadAsyncGoogleFont = () => {
|
||||
const linkEl = document.createElement("link");
|
||||
const proxyFontUrl = "/google-fonts";
|
||||
const remoteFontUrl = "https://fonts.googleapis.com";
|
||||
const googleFontUrl =
|
||||
getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
|
||||
linkEl.rel = "stylesheet";
|
||||
linkEl.href =
|
||||
googleFontUrl +
|
||||
"/css2?family=" +
|
||||
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
|
||||
"&display=swap";
|
||||
document.head.appendChild(linkEl);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
useSwitchTheme();
|
||||
useLoadData();
|
||||
useHtmlLang();
|
||||
const config = useAppConfig();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[Config] got config from build time", getClientConfig());
|
||||
useAccessStore.getState().fetch();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
loadAsyncGoogleFont();
|
||||
config.update(
|
||||
(config) =>
|
||||
(config.isMobileScreen = window.innerWidth <= MOBILE_MAX_WIDTH),
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!useHasHydrated()) {
|
||||
return <GlobalLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route
|
||||
path={Path.NewChat}
|
||||
element={
|
||||
<NewChat
|
||||
className={`
|
||||
md:w-[100%] px-1
|
||||
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
|
||||
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={Path.Masks}
|
||||
element={
|
||||
<MaskPage
|
||||
className={`
|
||||
md:w-[100%]
|
||||
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
|
||||
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Screen>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
BIN
app/fonts/Satoshi-Variable.ttf
Normal file
BIN
app/fonts/Satoshi-Variable.woff
Normal file
BIN
app/fonts/Satoshi-Variable.woff2
Normal file
44
app/hooks/useDeviceInfo.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// retur user device info
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDeviceInfo() {
|
||||
const [deviceInfo, setDeviceInfo] = useState({});
|
||||
|
||||
const [systemInfo, setSystemInfo] = useState<string | null>(null);
|
||||
const [deviceType, setDeviceType] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
if (/iphone|ipad|ipod/.test(userAgent)) {
|
||||
setSystemInfo("iOS");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setDeviceInfo({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
if (window.innerWidth < 600) {
|
||||
setDeviceType("mobile");
|
||||
} else {
|
||||
setDeviceType("desktop");
|
||||
}
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
windowSize: deviceInfo,
|
||||
systemInfo,
|
||||
deviceType,
|
||||
};
|
||||
}
|
59
app/hooks/useDrag.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { RefObject, useRef } from "react";
|
||||
|
||||
export default function useDrag(options: {
|
||||
customDragMove: (nextWidth: number, start?: number) => void;
|
||||
customToggle: () => void;
|
||||
customLimit?: (x: number, start?: number) => number;
|
||||
customDragEnd?: (nextWidth: number, start?: number) => void;
|
||||
}) {
|
||||
const { customDragMove, customToggle, customLimit, customDragEnd } =
|
||||
options || {};
|
||||
const limit = customLimit;
|
||||
|
||||
const startX = useRef(0);
|
||||
const lastUpdateTime = useRef(Date.now());
|
||||
|
||||
const toggleSideBar = customToggle;
|
||||
|
||||
const onDragMove = customDragMove;
|
||||
|
||||
const onDragStart = (e: MouseEvent) => {
|
||||
// Remembers the initial width each time the mouse is pressed
|
||||
startX.current = e.clientX;
|
||||
const dragStartTime = Date.now();
|
||||
|
||||
const handleDragMove = (e: MouseEvent) => {
|
||||
if (Date.now() < lastUpdateTime.current + 20) {
|
||||
return;
|
||||
}
|
||||
lastUpdateTime.current = Date.now();
|
||||
const d = e.clientX - startX.current;
|
||||
const nextWidth = limit?.(d, startX.current) ?? d;
|
||||
|
||||
onDragMove(nextWidth, startX.current);
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: MouseEvent) => {
|
||||
// In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
|
||||
window.removeEventListener("pointermove", handleDragMove);
|
||||
window.removeEventListener("pointerup", handleDragEnd);
|
||||
|
||||
// if user click the drag icon, should toggle the sidebar
|
||||
const shouldFireClick = Date.now() - dragStartTime < 300;
|
||||
if (shouldFireClick) {
|
||||
toggleSideBar();
|
||||
} else {
|
||||
const d = e.clientX - startX.current;
|
||||
const nextWidth = limit?.(d, startX.current) ?? d;
|
||||
customDragEnd?.(nextWidth, startX.current);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handleDragMove);
|
||||
window.addEventListener("pointerup", handleDragEnd);
|
||||
};
|
||||
|
||||
return {
|
||||
onDragStart,
|
||||
};
|
||||
}
|
21
app/hooks/useHotKey.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
export default function useHotKey() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey) {
|
||||
if (e.key === "ArrowUp") {
|
||||
chatStore.nextSession(-1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
chatStore.nextSession(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
});
|
||||
}
|
55
app/hooks/useListenWinResize.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useWindowSize } from "@/app/hooks/useWindowSize";
|
||||
import {
|
||||
WINDOW_WIDTH_2XL,
|
||||
WINDOW_WIDTH_LG,
|
||||
WINDOW_WIDTH_MD,
|
||||
WINDOW_WIDTH_SM,
|
||||
WINDOW_WIDTH_XL,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
} from "@/app/constant";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||
|
||||
export const MOBILE_MAX_WIDTH = 768;
|
||||
|
||||
const widths = [
|
||||
WINDOW_WIDTH_2XL,
|
||||
WINDOW_WIDTH_XL,
|
||||
WINDOW_WIDTH_LG,
|
||||
WINDOW_WIDTH_MD,
|
||||
WINDOW_WIDTH_SM,
|
||||
];
|
||||
|
||||
export default function useListenWinResize() {
|
||||
const config = useAppConfig();
|
||||
|
||||
useWindowSize((size) => {
|
||||
let nextSidebar = config.sidebarWidth;
|
||||
if (!nextSidebar) {
|
||||
switch (widths.find((w) => w < size.width)) {
|
||||
case WINDOW_WIDTH_2XL:
|
||||
nextSidebar = MAX_SIDEBAR_WIDTH;
|
||||
break;
|
||||
case WINDOW_WIDTH_XL:
|
||||
case WINDOW_WIDTH_LG:
|
||||
nextSidebar = DEFAULT_SIDEBAR_WIDTH;
|
||||
break;
|
||||
case WINDOW_WIDTH_MD:
|
||||
case WINDOW_WIDTH_SM:
|
||||
default:
|
||||
nextSidebar = MIN_SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
const { menuWidth } = updateGlobalCSSVars(nextSidebar);
|
||||
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = menuWidth;
|
||||
});
|
||||
config.update((config) => {
|
||||
config.isMobileScreen = size.width <= MOBILE_MAX_WIDTH;
|
||||
});
|
||||
});
|
||||
}
|
25
app/hooks/useLoadData.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import { ClientApi } from "@/app/client/api";
|
||||
import { ModelProvider } from "@/app/constant";
|
||||
import { identifyDefaultClaudeModel } from "@/app/utils/checkers";
|
||||
|
||||
export function useLoadData() {
|
||||
const config = useAppConfig();
|
||||
|
||||
var api: ClientApi;
|
||||
if (config.modelConfig.model.startsWith("gemini")) {
|
||||
api = new ClientApi(ModelProvider.GeminiPro);
|
||||
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||
api = new ClientApi(ModelProvider.Claude);
|
||||
} else {
|
||||
api = new ClientApi(ModelProvider.GPT);
|
||||
}
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const models = await api.llm.models();
|
||||
config.mergeModels(models);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
8
app/hooks/useMobileScreen.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useWindowSize } from "@/app/hooks/useWindowSize";
|
||||
import { MOBILE_MAX_WIDTH } from "@/app/hooks/useListenWinResize";
|
||||
|
||||
export default function useMobileScreen() {
|
||||
const { width } = useWindowSize();
|
||||
|
||||
return width <= MOBILE_MAX_WIDTH;
|
||||
}
|
72
app/hooks/usePaste.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { compressImage, isVisionModel } from "@/app/utils";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
interface UseUploadImageOptions {
|
||||
setUploading?: (v: boolean) => void;
|
||||
emitImages?: (imgs: string[]) => void;
|
||||
}
|
||||
|
||||
export default function usePaste(
|
||||
attachImages: string[],
|
||||
options: UseUploadImageOptions,
|
||||
) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const attachImagesRef = useRef<string[]>([]);
|
||||
const optionsRef = useRef<UseUploadImageOptions>({});
|
||||
const chatStoreRef = useRef<typeof chatStore | undefined>();
|
||||
|
||||
attachImagesRef.current = attachImages;
|
||||
optionsRef.current = options;
|
||||
chatStoreRef.current = chatStore;
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const { setUploading, emitImages } = optionsRef.current;
|
||||
const currentModel =
|
||||
chatStoreRef.current?.currentSession().mask.modelConfig.model;
|
||||
if (currentModel && !isVisionModel(currentModel)) {
|
||||
return;
|
||||
}
|
||||
const items = (event.clipboardData || window.clipboardData).items;
|
||||
for (const item of items) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
event.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const images: string[] = [];
|
||||
images.push(...attachImages);
|
||||
images.push(
|
||||
...(await new Promise<string[]>((res, rej) => {
|
||||
setUploading?.(true);
|
||||
const imagesData: string[] = [];
|
||||
compressImage(file, 256 * 1024)
|
||||
.then((dataUrl) => {
|
||||
imagesData.push(dataUrl);
|
||||
setUploading?.(false);
|
||||
res(imagesData);
|
||||
})
|
||||
.catch((e) => {
|
||||
setUploading?.(false);
|
||||
rej(e);
|
||||
});
|
||||
})),
|
||||
);
|
||||
const imagesLength = images.length;
|
||||
|
||||
if (imagesLength > 3) {
|
||||
images.splice(3, imagesLength - 3);
|
||||
}
|
||||
emitImages?.(images);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handlePaste,
|
||||
};
|
||||
}
|
104
app/hooks/useRelativePosition.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { RefObject, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export interface Options {
|
||||
containerRef?: RefObject<HTMLElement | null>;
|
||||
delay?: number;
|
||||
offsetDistance?: number;
|
||||
}
|
||||
|
||||
export enum Orientation {
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
top,
|
||||
}
|
||||
|
||||
export type X = Orientation.left | Orientation.right;
|
||||
export type Y = Orientation.top | Orientation.bottom;
|
||||
|
||||
interface Position {
|
||||
id: string;
|
||||
poi: {
|
||||
targetH: number;
|
||||
targetW: number;
|
||||
distanceToRightBoundary: number;
|
||||
distanceToLeftBoundary: number;
|
||||
distanceToTopBoundary: number;
|
||||
distanceToBottomBoundary: number;
|
||||
overlapPositions: Record<Orientation, boolean>;
|
||||
relativePosition: [X, Y];
|
||||
};
|
||||
}
|
||||
|
||||
export default function useRelativePosition({
|
||||
containerRef = { current: null },
|
||||
delay = 100,
|
||||
offsetDistance = 0,
|
||||
}: Options) {
|
||||
const [position, setPosition] = useState<Position | undefined>();
|
||||
|
||||
const getRelativePosition = useDebouncedCallback(
|
||||
(target: HTMLDivElement, id: string) => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
} = target.getBoundingClientRect();
|
||||
|
||||
const {
|
||||
x: containerX,
|
||||
y: containerY,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
} = containerRef.current.getBoundingClientRect();
|
||||
|
||||
const distanceToRightBoundary =
|
||||
containerX + containerWidth - (targetX + targetW) - offsetDistance;
|
||||
const distanceToLeftBoundary = targetX - containerX - offsetDistance;
|
||||
const distanceToTopBoundary = targetY - containerY - offsetDistance;
|
||||
const distanceToBottomBoundary =
|
||||
containerY + containerHeight - (targetY + targetH) - offsetDistance;
|
||||
|
||||
setPosition({
|
||||
id,
|
||||
poi: {
|
||||
targetW: targetW + 2 * offsetDistance,
|
||||
targetH: targetH + 2 * offsetDistance,
|
||||
distanceToRightBoundary,
|
||||
distanceToLeftBoundary,
|
||||
distanceToTopBoundary,
|
||||
distanceToBottomBoundary,
|
||||
overlapPositions: {
|
||||
[Orientation.left]: distanceToLeftBoundary <= 0,
|
||||
[Orientation.top]: distanceToTopBoundary <= 0,
|
||||
[Orientation.right]: distanceToRightBoundary <= 0,
|
||||
[Orientation.bottom]: distanceToBottomBoundary <= 0,
|
||||
},
|
||||
relativePosition: [
|
||||
distanceToLeftBoundary <= distanceToRightBoundary
|
||||
? Orientation.left
|
||||
: Orientation.right,
|
||||
distanceToTopBoundary <= distanceToBottomBoundary
|
||||
? Orientation.top
|
||||
: Orientation.bottom,
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
delay,
|
||||
{
|
||||
leading: true,
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
getRelativePosition,
|
||||
position,
|
||||
};
|
||||
}
|
39
app/hooks/useRows.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { autoGrowTextArea } from "../utils";
|
||||
import { useAppConfig } from "../store";
|
||||
|
||||
export default function useRows({
|
||||
inputRef,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}) {
|
||||
const [inputRows, setInputRows] = useState(2);
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const measure = useDebouncedCallback(
|
||||
() => {
|
||||
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
|
||||
const inputRows = Math.min(
|
||||
20,
|
||||
Math.max(2 + (isMobileScreen ? -1 : 1), rows),
|
||||
);
|
||||
setInputRows(inputRows);
|
||||
},
|
||||
100,
|
||||
{
|
||||
leading: true,
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
}, [isMobileScreen]);
|
||||
|
||||
return {
|
||||
inputRows,
|
||||
measure,
|
||||
};
|
||||
}
|
61
app/hooks/useScrollToBottom.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
) {
|
||||
const detach = scrollRef?.current
|
||||
? Math.abs(
|
||||
scrollRef.current.scrollHeight -
|
||||
(scrollRef.current.scrollTop + scrollRef.current.clientHeight),
|
||||
) <= 1
|
||||
: false;
|
||||
|
||||
// for auto-scroll
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const autoScrollRef = useRef<typeof autoScroll>();
|
||||
|
||||
autoScrollRef.current = autoScroll;
|
||||
|
||||
function scrollDomToBottom() {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
setAutoScroll(true);
|
||||
dom.scrollTo(0, dom.scrollHeight);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// useEffect(() => {
|
||||
// const dom = scrollRef.current;
|
||||
// if (dom) {
|
||||
// dom.ontouchstart = (e) => {
|
||||
// const autoScroll = autoScrollRef.current;
|
||||
// if (autoScroll) {
|
||||
// setAutoScroll(false);
|
||||
// }
|
||||
// }
|
||||
// dom.onscroll = (e) => {
|
||||
// const autoScroll = autoScrollRef.current;
|
||||
// if (autoScroll) {
|
||||
// setAutoScroll(false);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
// auto scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
}
|
29
app/hooks/useShowPromptHint.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useShowPromptHint<RenderPompt>(props: {
|
||||
prompts: RenderPompt[];
|
||||
}) {
|
||||
const [internalPrompts, setInternalPrompts] = useState<RenderPompt[]>([]);
|
||||
const [notShowPrompt, setNotShowPrompt] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.prompts.length !== 0) {
|
||||
setInternalPrompts(props.prompts);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setNotShowPrompt(false);
|
||||
}, 50);
|
||||
|
||||
return;
|
||||
}
|
||||
setNotShowPrompt(true);
|
||||
window.setTimeout(() => {
|
||||
setInternalPrompts(props.prompts);
|
||||
}, 300);
|
||||
}, [props.prompts]);
|
||||
|
||||
return {
|
||||
notShowPrompt,
|
||||
internalPrompts,
|
||||
};
|
||||
}
|
49
app/hooks/useSubmitHandler.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { SubmitKey, useAppConfig } from "../store/config";
|
||||
|
||||
export default function useSubmitHandler() {
|
||||
const config = useAppConfig();
|
||||
const submitKey = config.submitKey;
|
||||
const isComposing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onCompositionStart = () => {
|
||||
isComposing.current = true;
|
||||
};
|
||||
const onCompositionEnd = () => {
|
||||
isComposing.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener("compositionstart", onCompositionStart);
|
||||
window.addEventListener("compositionend", onCompositionEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("compositionstart", onCompositionStart);
|
||||
window.removeEventListener("compositionend", onCompositionEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Fix Chinese input method "Enter" on Safari
|
||||
if (e.keyCode == 229) return false;
|
||||
if (e.key !== "Enter") return false;
|
||||
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
|
||||
return false;
|
||||
return (
|
||||
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
|
||||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
|
||||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
|
||||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
|
||||
(config.submitKey === SubmitKey.Enter &&
|
||||
!e.altKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
submitKey,
|
||||
shouldSubmit,
|
||||
};
|
||||
}
|
48
app/hooks/useSwitchTheme.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { Theme, useAppConfig } from "@/app/store/config";
|
||||
import { getCSSVar } from "../utils";
|
||||
|
||||
const DARK_CLASS = "dark-new";
|
||||
const LIGHT_CLASS = "light-new";
|
||||
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.remove(DARK_CLASS);
|
||||
document.body.classList.remove(LIGHT_CLASS);
|
||||
|
||||
if (config.theme === Theme.Dark) {
|
||||
document.body.classList.add(DARK_CLASS);
|
||||
} else {
|
||||
document.body.classList.add(LIGHT_CLASS);
|
||||
}
|
||||
}, [config.theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.remove("light");
|
||||
document.body.classList.remove("dark");
|
||||
|
||||
if (config.theme === "dark") {
|
||||
document.body.classList.add("dark");
|
||||
} else if (config.theme === "light") {
|
||||
document.body.classList.add("light");
|
||||
}
|
||||
|
||||
const metaDescriptionDark = document.querySelector(
|
||||
'meta[name="theme-color"][media*="dark"]',
|
||||
);
|
||||
const metaDescriptionLight = document.querySelector(
|
||||
'meta[name="theme-color"][media*="light"]',
|
||||
);
|
||||
|
||||
if (config.theme === "auto") {
|
||||
metaDescriptionDark?.setAttribute("content", "#151515");
|
||||
metaDescriptionLight?.setAttribute("content", "#fafafa");
|
||||
} else {
|
||||
const themeColor = getCSSVar("--theme-color");
|
||||
metaDescriptionDark?.setAttribute("content", themeColor);
|
||||
metaDescriptionLight?.setAttribute("content", themeColor);
|
||||
}
|
||||
}, [config.theme]);
|
||||
}
|
69
app/hooks/useUploadImage.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { compressImage } from "@/app/utils";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
interface UseUploadImageOptions {
|
||||
setUploading?: (v: boolean) => void;
|
||||
emitImages?: (imgs: string[]) => void;
|
||||
}
|
||||
|
||||
export default function useUploadImage(
|
||||
attachImages: string[],
|
||||
options: UseUploadImageOptions,
|
||||
) {
|
||||
const attachImagesRef = useRef<string[]>([]);
|
||||
const optionsRef = useRef<UseUploadImageOptions>({});
|
||||
|
||||
attachImagesRef.current = attachImages;
|
||||
optionsRef.current = options;
|
||||
|
||||
const uploadImage = useCallback(async function uploadImage() {
|
||||
const images: string[] = [];
|
||||
images.push(...attachImagesRef.current);
|
||||
|
||||
const { setUploading, emitImages } = optionsRef.current;
|
||||
|
||||
images.push(
|
||||
...(await new Promise<string[]>((res, rej) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept =
|
||||
"image/png, image/jpeg, image/webp, image/heic, image/heif";
|
||||
fileInput.multiple = true;
|
||||
fileInput.onchange = (event: any) => {
|
||||
setUploading?.(true);
|
||||
const files = event.target.files;
|
||||
const imagesData: string[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = event.target.files[i];
|
||||
compressImage(file, 256 * 1024)
|
||||
.then((dataUrl) => {
|
||||
imagesData.push(dataUrl);
|
||||
if (
|
||||
imagesData.length === 3 ||
|
||||
imagesData.length === files.length
|
||||
) {
|
||||
setUploading?.(false);
|
||||
res(imagesData);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setUploading?.(false);
|
||||
rej(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
})),
|
||||
);
|
||||
|
||||
const imagesLength = images.length;
|
||||
if (imagesLength > 3) {
|
||||
images.splice(3, imagesLength - 3);
|
||||
}
|
||||
emitImages?.(images);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploadImage,
|
||||
};
|
||||
}
|
47
app/hooks/useWindowSize.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function useWindowSize(callback?: (size: Size) => void) {
|
||||
const callbackRef = useRef<typeof callback>();
|
||||
|
||||
callbackRef.current = callback;
|
||||
|
||||
const [size, setSize] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const onResize = () => {
|
||||
callbackRef.current?.({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
callback?.({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
5
app/icons/addCircle.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.1001 12.0001C1.1001 6.00304 6.00304 1.1001 12.0001 1.1001C17.9972 1.1001 22.9001 6.00304 22.9001 12.0001C22.9001 17.9972 17.9972 22.9001 12.0001 22.9001C6.00304 22.9001 1.1001 17.9972 1.1001 12.0001ZM12.0001 2.9001C6.99715 2.9001 2.9001 6.99715 2.9001 12.0001C2.9001 17.003 6.99715 21.1001 12.0001 21.1001C17.003 21.1001 21.1001 17.003 21.1001 12.0001C21.1001 6.99715 17.003 2.9001 12.0001 2.9001Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.1001 12.0001C7.1001 11.503 7.50304 11.1001 8.0001 11.1001H16.0001C16.4972 11.1001 16.9001 11.503 16.9001 12.0001C16.9001 12.4972 16.4972 12.9001 16.0001 12.9001H8.0001C7.50304 12.9001 7.1001 12.4972 7.1001 12.0001Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0001 7.1001C12.4972 7.1001 12.9001 7.50304 12.9001 8.0001V16.0001C12.9001 16.4972 12.4972 16.9001 12.0001 16.9001C11.503 16.9001 11.1001 16.4972 11.1001 16.0001V8.0001C11.1001 7.50304 11.503 7.1001 12.0001 7.1001Z" fill="#606078"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
3
app/icons/addIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.91663 0.666504C4.36028 0.666504 0.666626 4.36015 0.666626 8.9165V15.5832C0.666626 16.4576 1.37551 17.1665 2.24996 17.1665H8.91663C13.473 17.1665 17.1666 13.4729 17.1666 8.9165C17.1666 4.36015 13.473 0.666504 8.91663 0.666504ZM8.50004 5.25C8.91425 5.25 9.25004 5.58579 9.25004 6V8.16667H11.4167C11.8309 8.16667 12.1667 8.50245 12.1667 8.91667C12.1667 9.33088 11.8309 9.66667 11.4167 9.66667H9.25004V11.8333C9.25004 12.2475 8.91425 12.5833 8.50004 12.5833C8.08583 12.5833 7.75004 12.2475 7.75004 11.8333V9.66667H5.58333C5.16912 9.66667 4.83333 9.33088 4.83333 8.91667C4.83333 8.50245 5.16912 8.16667 5.58333 8.16667H7.75004V6C7.75004 5.58579 8.08583 5.25 8.50004 5.25Z" fill="#2E42F3"/>
|
||||
</svg>
|
After Width: | Height: | Size: 839 B |
9
app/icons/assistantActive.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C2 2.89543 2.89543 2 4 2H12H12.1639V2.00132C17.6112 2.08887 22 6.5319 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 11.947 2.00041 11.8941 2.00123 11.8413H2V4ZM7.57373 9.78713C7.57373 9.10809 8.1242 8.55762 8.80324 8.55762C9.48228 8.55762 10.0327 9.10809 10.0327 9.78713V11.2625C10.0327 11.9416 9.48228 12.492 8.80324 12.492C8.1242 12.492 7.57373 11.9416 7.57373 11.2625V9.78713ZM13.9673 9.78713C13.9673 9.10809 14.5178 8.55762 15.1968 8.55762C15.8758 8.55762 16.4263 9.10809 16.4263 9.78713V11.2625C16.4263 11.9416 15.8758 12.492 15.1968 12.492C14.5178 12.492 13.9673 11.9416 13.9673 11.2625V9.78713Z" fill="url(#paint0_linear_434_27904)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_434_27904" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E5E6FF"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1021 B |
3
app/icons/assistantInactive.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C2 2.89543 2.89543 2 4 2H12H12.1639V2.00132C17.6112 2.08887 22 6.5319 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 11.947 2.00041 11.8941 2.00123 11.8413H2V4ZM7.57373 9.78713C7.57373 9.10809 8.1242 8.55762 8.80324 8.55762C9.48228 8.55762 10.0327 9.10809 10.0327 9.78713V11.2625C10.0327 11.9416 9.48228 12.492 8.80324 12.492C8.1242 12.492 7.57373 11.9416 7.57373 11.2625V9.78713ZM13.9673 9.78713C13.9673 9.10809 14.5178 8.55762 15.1968 8.55762C15.8758 8.55762 16.4263 9.10809 16.4263 9.78713V11.2625C16.4263 11.9416 15.8758 12.492 15.1968 12.492C14.5178 12.492 13.9673 11.9416 13.9673 11.2625V9.78713Z" fill="#A5A5B3"/>
|
||||
</svg>
|
After Width: | Height: | Size: 792 B |
9
app/icons/assistantMobileActive.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C2 2.89543 2.89543 2 4 2H12H12.1639V2.00132C17.6112 2.08887 22 6.5319 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 11.947 2.00041 11.8941 2.00123 11.8413H2V4ZM7.57373 9.78713C7.57373 9.10809 8.1242 8.55762 8.80324 8.55762C9.48228 8.55762 10.0327 9.10809 10.0327 9.78713V11.2625C10.0327 11.9416 9.48228 12.492 8.80324 12.492C8.1242 12.492 7.57373 11.9416 7.57373 11.2625V9.78713ZM13.9673 9.78713C13.9673 9.10809 14.5178 8.55762 15.1968 8.55762C15.8758 8.55762 16.4263 9.10809 16.4263 9.78713V11.2625C16.4263 11.9416 15.8758 12.492 15.1968 12.492C14.5178 12.492 13.9673 11.9416 13.9673 11.2625V9.78713Z" fill="url(#paint0_linear_460_34351)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_460_34351" x1="2" y1="12" x2="22.022" y2="12" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2A33FF"/>
|
||||
<stop offset="0.997219" stop-color="#7963FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
7
app/icons/assistantMobileInactive.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="path-1-inside-1_769_8890" fill="white">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 2C3.39543 2 2.5 2.89543 2.5 4V11.8413H2.50123C2.50041 11.8941 2.5 11.947 2.5 12C2.5 17.5228 6.97715 22 12.5 22C18.0228 22 22.5 17.5228 22.5 12C22.5 6.5319 18.1112 2.08887 12.6639 2.00132V2H12.5H4.5Z"/>
|
||||
</mask>
|
||||
<path d="M2.5 11.8413H0.7V13.6413H2.5V11.8413ZM2.50123 11.8413L4.30102 11.8693L4.32946 10.0413H2.50123V11.8413ZM12.6639 2.00132H10.8639V3.77262L12.635 3.80108L12.6639 2.00132ZM12.6639 2H14.4639V0.2H12.6639V2ZM4.3 4C4.3 3.88954 4.38954 3.8 4.5 3.8V0.2C2.40132 0.2 0.7 1.90131 0.7 4H4.3ZM4.3 11.8413V4H0.7V11.8413H4.3ZM2.50123 10.0413H2.5V13.6413H2.50123V10.0413ZM4.3 12C4.3 11.9563 4.30034 11.9128 4.30102 11.8693L0.701452 11.8133C0.700485 11.8754 0.7 11.9377 0.7 12H4.3ZM12.5 20.2C7.97126 20.2 4.3 16.5287 4.3 12H0.7C0.7 18.517 5.98304 23.8 12.5 23.8V20.2ZM20.7 12C20.7 16.5287 17.0287 20.2 12.5 20.2V23.8C19.017 23.8 24.3 18.517 24.3 12H20.7ZM12.635 3.80108C17.1011 3.87287 20.7 7.51632 20.7 12H24.3C24.3 5.54748 19.1212 0.30487 12.6929 0.201549L12.635 3.80108ZM10.8639 2V2.00132H14.4639V2H10.8639ZM12.5 3.8H12.6639V0.2H12.5V3.8ZM4.5 3.8H12.5V0.2H4.5V3.8Z" fill="#A5A5B3" mask="url(#path-1-inside-1_769_8890)"/>
|
||||
<path opacity="0.89" fill-rule="evenodd" clip-rule="evenodd" d="M9.30324 8.55762C8.6242 8.55762 8.07373 9.10809 8.07373 9.78713V11.2625C8.07373 11.9416 8.6242 12.492 9.30324 12.492C9.98228 12.492 10.5327 11.9416 10.5327 11.2625V9.78713C10.5327 9.10809 9.98228 8.55762 9.30324 8.55762ZM15.6968 8.55762C15.0178 8.55762 14.4673 9.10809 14.4673 9.78713V11.2625C14.4673 11.9416 15.0178 12.492 15.6968 12.492C16.3758 12.492 16.9263 11.9416 16.9263 11.2625V9.78713C16.9263 9.10809 16.3758 8.55762 15.6968 8.55762Z" fill="#A5A5B3"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
3
app/icons/bottomArrow.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.37893 2.95457C7.26177 2.83741 7.07182 2.83741 6.95466 2.95457L4.5237 5.38553C4.51068 5.39855 4.48958 5.39855 4.47656 5.38553L2.0456 2.95457C1.92844 2.83741 1.73849 2.83741 1.62133 2.95457C1.50417 3.07172 1.50417 3.26167 1.62133 3.37883L4.0523 5.8098C4.29963 6.05713 4.70063 6.05713 4.94796 5.8098L7.37893 3.37883C7.49609 3.26167 7.49609 3.07172 7.37893 2.95457Z" fill="#88889A"/>
|
||||
</svg>
|
After Width: | Height: | Size: 531 B |
3
app/icons/closeIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="28" viewBox="0 0 16 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1486 20.0156C13.388 20.255 13.7762 20.255 14.0156 20.0156C14.255 19.7762 14.255 19.388 14.0156 19.1486L8.86702 14L14.0157 8.85133C14.2551 8.6119 14.2551 8.22371 14.0157 7.98428C13.7762 7.74486 13.3881 7.74486 13.1486 7.98428L7.99998 13.1329L2.8513 7.98426C2.61187 7.74483 2.22368 7.74483 1.98426 7.98426C1.74483 8.22368 1.74483 8.61187 1.98426 8.8513L7.13294 14L1.98432 19.1486C1.74489 19.388 1.74489 19.7762 1.98432 20.0156C2.22374 20.2551 2.61193 20.2551 2.85136 20.0156L7.99998 14.867L13.1486 20.0156Z" fill="#606078"/>
|
||||
</svg>
|
After Width: | Height: | Size: 679 B |
3
app/icons/comandIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 1.75C3.21079 1.75 1.75 3.21079 1.75 5C1.75 6.78921 3.21079 8.25 5 8.25H6.75V11.75H5C3.21079 11.75 1.75 13.2108 1.75 15C1.75 16.7892 3.21079 18.25 5 18.25C6.78921 18.25 8.25 16.7892 8.25 15V13.25H11.75V15C11.75 16.7892 13.2108 18.25 15 18.25C16.7892 18.25 18.25 16.7892 18.25 15C18.25 13.2108 16.7892 11.75 15 11.75H13.25V8.25H15C16.7892 8.25 18.25 6.78921 18.25 5C18.25 3.21079 16.7892 1.75 15 1.75C13.2108 1.75 11.75 3.21079 11.75 5V6.75H8.25V5C8.25 3.21079 6.78921 1.75 5 1.75ZM3.25 5C3.25 4.03921 4.03921 3.25 5 3.25C5.96079 3.25 6.75 4.03921 6.75 5V6.75H5C4.03921 6.75 3.25 5.96079 3.25 5ZM8.25 8.25V11.75H11.75V8.25H8.25ZM5 13.25C4.03921 13.25 3.25 14.0392 3.25 15C3.25 15.9608 4.03921 16.75 5 16.75C5.96079 16.75 6.75 15.9608 6.75 15V13.25H5ZM15 3.25C14.0392 3.25 13.25 4.03921 13.25 5V6.75H15C15.9608 6.75 16.75 5.96079 16.75 5C16.75 4.03921 15.9608 3.25 15 3.25ZM13.25 15V13.25H15C15.9608 13.25 16.75 14.0392 16.75 15C16.75 15.9608 15.9608 16.75 15 16.75C14.0392 16.75 13.25 15.9608 13.25 15Z" fill="#606078"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
6
app/icons/command&enterIcon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="36" height="20" viewBox="0 0 36 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="20" rx="4" fill="#F0F0F3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 7.04688C5.5 6.19256 6.19256 5.5 7.04688 5.5C7.90119 5.5 8.59375 6.19256 8.59375 7.04688V7.65625H11.4062V7.04688C11.4062 6.19255 12.0988 5.5 12.9531 5.5C13.8074 5.5 14.5 6.19256 14.5 7.04688C14.5 7.90118 13.8074 8.59375 12.9531 8.59375H12.3438V11.3594H12.9531C13.8074 11.3594 14.5 12.0519 14.5 12.9062C14.5 13.7606 13.8074 14.4531 12.9531 14.4531C12.0988 14.4531 11.4062 13.7606 11.4062 12.9062V12.2969H8.59375V12.9062C8.59375 13.7606 7.90119 14.4531 7.04688 14.4531C6.19255 14.4531 5.5 13.7606 5.5 12.9062C5.5 12.0519 6.19256 11.3594 7.04688 11.3594H7.65625V8.59375H7.04688C6.19256 8.59375 5.5 7.90119 5.5 7.04688ZM7.04688 6.4375C6.71033 6.4375 6.4375 6.71033 6.4375 7.04688C6.4375 7.38342 6.71033 7.65625 7.04688 7.65625H7.65625V7.04688C7.65625 6.71033 7.38342 6.4375 7.04688 6.4375ZM7.04688 12.2969C6.71032 12.2969 6.4375 12.5697 6.4375 12.9062C6.4375 13.2428 6.71033 13.5156 7.04688 13.5156C7.38342 13.5156 7.65625 13.2428 7.65625 12.9062V12.2969H7.04688ZM8.59375 11.3594V8.59375H11.4062V11.3594H8.59375ZM12.9531 6.4375C12.6166 6.4375 12.3438 6.71033 12.3438 7.04688V7.65625H12.9531C13.2897 7.65625 13.5625 7.38342 13.5625 7.04688C13.5625 6.71032 13.2897 6.4375 12.9531 6.4375ZM12.3438 12.9062V12.2969H12.9531C13.2897 12.2969 13.5625 12.5697 13.5625 12.9062C13.5625 13.2428 13.2897 13.5156 12.9531 13.5156C12.6166 13.5156 12.3438 13.2428 12.3438 12.9062Z" fill="#88889A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.0156 12.8125C22.0156 13.0714 22.2255 13.2812 22.4844 13.2812H27.5938C29.4577 13.2812 30.9688 11.7702 30.9688 9.90625C30.9688 8.04229 29.4577 6.53125 27.5938 6.53125H22.4844C22.2255 6.53125 22.0156 6.74112 22.0156 7C22.0156 7.25888 22.2255 7.46875 22.4844 7.46875H27.5938C28.9399 7.46875 30.0313 8.56006 30.0313 9.90625C30.0313 11.2524 28.9399 12.3438 27.5938 12.3438H22.4844C22.2255 12.3438 22.0156 12.5536 22.0156 12.8125Z" fill="#88889A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.6215 14.8224C23.7996 14.6344 23.7916 14.3378 23.6036 14.1597L22.1816 12.8125L21.5368 13.4931L22.9589 14.8403C23.1468 15.0183 23.4435 15.0103 23.6215 14.8224ZM21.5368 13.4931C21.1465 13.1233 21.1465 12.5017 21.5368 12.1319L22.9589 10.7847C23.1468 10.6067 23.4435 10.6147 23.6215 10.8026C23.7996 10.9906 23.7916 11.2872 23.6036 11.4653L22.1816 12.8125L21.5368 13.4931Z" fill="#88889A"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
8
app/icons/configIcon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5834 5.4165C12.5834 5.00229 12.9192 4.6665 13.3334 4.6665H18.3334C18.7476 4.6665 19.0834 5.00229 19.0834 5.4165C19.0834 5.83072 18.7476 6.1665 18.3334 6.1665H13.3334C12.9192 6.1665 12.5834 5.83072 12.5834 5.4165Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.916687 5.4165C0.916687 5.00229 1.25247 4.6665 1.66669 4.6665H5.00002C5.41423 4.6665 5.75002 5.00229 5.75002 5.4165C5.75002 5.83072 5.41423 6.1665 5.00002 6.1665H1.66669C1.25247 6.1665 0.916687 5.83072 0.916687 5.4165Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.33335 3.25C7.13674 3.25 6.16669 4.22005 6.16669 5.41667C6.16669 6.61328 7.13674 7.58333 8.33335 7.58333C9.52997 7.58333 10.5 6.61328 10.5 5.41667C10.5 4.22005 9.52997 3.25 8.33335 3.25ZM4.66669 5.41667C4.66669 3.39162 6.30831 1.75 8.33335 1.75C10.3584 1.75 12 3.39162 12 5.41667C12 7.44171 10.3584 9.08333 8.33335 9.08333C6.30831 9.08333 4.66669 7.44171 4.66669 5.41667Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.25 14.5835C14.25 14.1693 14.5858 13.8335 15 13.8335H18.3333C18.7475 13.8335 19.0833 14.1693 19.0833 14.5835C19.0833 14.9977 18.7475 15.3335 18.3333 15.3335H15C14.5858 15.3335 14.25 14.9977 14.25 14.5835Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.916687 14.5835C0.916687 14.1693 1.25247 13.8335 1.66669 13.8335H6.66669C7.0809 13.8335 7.41669 14.1693 7.41669 14.5835C7.41669 14.9977 7.0809 15.3335 6.66669 15.3335H1.66669C1.25247 15.3335 0.916687 14.9977 0.916687 14.5835Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6667 12.4165C10.47 12.4165 9.5 13.3866 9.5 14.5832C9.5 15.7798 10.47 16.7498 11.6667 16.7498C12.8633 16.7498 13.8333 15.7798 13.8333 14.5832C13.8333 13.3866 12.8633 12.4165 11.6667 12.4165ZM8 14.5832C8 12.5581 9.64162 10.9165 11.6667 10.9165C13.6917 10.9165 15.3333 12.5581 15.3333 14.5832C15.3333 16.6082 13.6917 18.2498 11.6667 18.2498C9.64162 18.2498 8 16.6082 8 14.5832Z" fill="#606078"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |