diff --git a/app/components/Btn/index.tsx b/app/components/Btn/index.tsx index d05919d9c..59bea6cc0 100644 --- a/app/components/Btn/index.tsx +++ b/app/components/Btn/index.tsx @@ -50,7 +50,7 @@ export default function IconButton(props: { autoFocus={autoFocus} > {text && ( -
+
{text}
)} diff --git a/app/components/GlobalLoading/index.tsx b/app/components/GlobalLoading/index.tsx new file mode 100644 index 000000000..7681f4ec6 --- /dev/null +++ b/app/components/GlobalLoading/index.tsx @@ -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 ( +
+ {!noLogo && } + +
+ ); +} diff --git a/app/components/MenuWrapper/index.tsx b/app/components/MenuWrapper/index.tsx new file mode 100644 index 000000000..79ab3d088 --- /dev/null +++ b/app/components/MenuWrapper/index.tsx @@ -0,0 +1,85 @@ +import { useNavigate } from "react-router-dom"; +import { Path } from "@/app/constant"; +import useDragSideBar from "@/app/hooks/useDragSideBar"; +import useMobileScreen from "@/app/hooks/useMobileScreen"; +import { + ComponentType, + Context, + createContext, + useContext, + useState, +} from "react"; + +import DragIcon from "@/app/icons/drag.svg"; + +export interface MenuWrapperInspectProps { + setShowPanel?: (v: boolean) => void; + showPanel?: boolean; +} + +export default function MenuWrapper< + ListComponentProps extends MenuWrapperInspectProps, + PanelComponentProps extends MenuWrapperInspectProps, +>( + ListComponent: ComponentType, + PanelComponent: ComponentType, +) { + return function MenuHood(props: ListComponentProps & PanelComponentProps) { + const [showPanel, setShowPanel] = useState(false); + + const navigate = useNavigate(); + + const isMobileScreen = useMobileScreen(); + // drag side bar + const { onDragStart } = useDragSideBar(); + + let containerClassName = "flex h-[100%] w-[100%]"; + let listClassName = + "relative basis-sidebar h-[calc(100%-1.25rem)] pb-6 max-md:px-4 max-md:pb-4 rounded-md my-2.5 bg-gray-50"; + let panelClassName = "flex-1 h-[100%] w-page"; + + if (isMobileScreen) { + containerClassName = "h-[100%] w-[100%] relative bg-center"; + listClassName = `h-[100%] w-[100%] flex-1 px-4`; + panelClassName = `transition-all duration-300 absolute top-0 max-h-[100vh] w-[100%] ${ + showPanel ? "left-0" : "left-[101%]" + } z-10`; + } + + return ( +
+
{ + if (e.target === e.currentTarget) { + navigate(Path.Home); + } + }} + > + + {!isMobileScreen && ( +
onDragStart(e as any)} + > +
+ +
+
+ )} +
+
+ +
+
+ ); + }; +} diff --git a/app/components/Screen/index.tsx b/app/components/Screen/index.tsx index 617a10a92..8650eb4c0 100644 --- a/app/components/Screen/index.tsx +++ b/app/components/Screen/index.tsx @@ -18,7 +18,6 @@ interface ScreenProps { export default function Screen(props: ScreenProps) { const location = useLocation(); const isAuth = location.pathname === Path.Auth; - const isHome = location.pathname === Path.Home; const isMobileScreen = useMobileScreen(); const isIOSMobile = useMemo( @@ -28,16 +27,15 @@ export default function Screen(props: ScreenProps) { useListenWinResize(); - let containerClassName = "flex h-[100%] w-[100%]"; - let pageClassName = "flex-1 h-[100%] w-page"; - let sidebarClassName = "basis-sidebar h-[100%]"; + let containerClassName = "flex h-[100%] w-[100%] bg-center overflow-hidden"; + let sidebarClassName = "flex-0 overflow-hidden"; + let pageClassName = "flex-1 h-[100%] min-w-0 overflow-hidden"; if (isMobileScreen) { - containerClassName = "h-[100%] w-[100%] relative bg-center"; - pageClassName = `absolute top-0 h-[100%] w-[100%] ${ - !isHome ? "left-0" : "left-[101%]" - } z-10`; - sidebarClassName = `h-[100%] w-[100%]`; + containerClassName = + "relative flex flex-col-reverse h-[100%] w-[100%] bg-center"; + sidebarClassName = "absolute w-[100%] bottom-0 z-10"; + pageClassName = "w-[100%] h-[100%]"; } return ( diff --git a/app/components/mask.tsx b/app/components/mask.tsx index 32a16c942..537fccd14 100644 --- a/app/components/mask.tsx +++ b/app/components/mask.tsx @@ -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(list: T[], startIndex: number, endIndex: number): T[] { @@ -465,9 +465,15 @@ export function MaskPage() { }); }; + const isMobileScreen = useMobileScreen(); + return ( - -
+ <> +
@@ -645,6 +651,6 @@ export function MaskPage() {
)} - + ); } diff --git a/app/components/new-chat.tsx b/app/components/new-chat.tsx index 54c646f23..aba3c5b1c 100644 --- a/app/components/new-chat.tsx +++ b/app/components/new-chat.tsx @@ -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 ( @@ -110,8 +111,14 @@ export function NewChat() { } }, [groups]); + const isMobileScreen = useMobileScreen(); + return ( -
+
} diff --git a/app/constant.ts b/app/constant.ts index a5a39a2d0..a94a23b86 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -49,9 +49,9 @@ export enum StoreKey { Sync = "sync", } -export const DEFAULT_SIDEBAR_WIDTH = 404; -export const MAX_SIDEBAR_WIDTH = 504; -export const MIN_SIDEBAR_WIDTH = 294; +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; diff --git a/app/containers/Chat/ChatAction.tsx b/app/containers/Chat/ChatAction.tsx deleted file mode 100644 index 25c69954c..000000000 --- a/app/containers/Chat/ChatAction.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useRef, useState } from "react"; - -import styles from "./index.module.scss"; - -export default function ChatAction(props: { - text: string; - icon: JSX.Element; - onClick: () => void; -}) { - const iconRef = useRef(null); - const textRef = useRef(null); - const [width, setWidth] = useState({ - full: 16, - icon: 16, - }); - - function updateWidth() { - if (!iconRef.current || !textRef.current) return; - const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; - const textWidth = getWidth(textRef.current); - const iconWidth = getWidth(iconRef.current); - setWidth({ - full: textWidth + iconWidth, - icon: iconWidth, - }); - } - - return ( -
{ - props.onClick(); - setTimeout(updateWidth, 1); - }} - onMouseEnter={updateWidth} - onTouchStart={updateWidth} - style={ - { - "--icon-width": `${width.icon}px`, - "--full-width": `${width.full}px`, - } as React.CSSProperties - } - > -
- {props.icon} -
-
- {props.text} -
-
- ); -} diff --git a/app/containers/Chat/ChatActions.tsx b/app/containers/Chat/ChatActions.tsx index 262451884..3e53eba27 100644 --- a/app/containers/Chat/ChatActions.tsx +++ b/app/containers/Chat/ChatActions.tsx @@ -176,19 +176,22 @@ export function ChatActions(props: { if (props.isMobileScreen) { const content = (
- {actions.map((act) => { - return ( -
- {act.icon} -
- {act.text} + {actions + .filter((v) => v.isShow) + .map((act) => { + return ( +
+ {act.icon} +
+ {act.text} +
-
- ); - })} + ); + })}
); return ( diff --git a/app/containers/Chat/ChatInputPanel.tsx b/app/containers/Chat/ChatInputPanel.tsx index 9d783165d..44593faa6 100644 --- a/app/containers/Chat/ChatInputPanel.tsx +++ b/app/containers/Chat/ChatInputPanel.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useImperativeHandle, useMemo, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useDebouncedCallback } from "use-debounce"; import useUploadImage from "@/app/hooks/useUploadImage"; @@ -10,7 +10,6 @@ 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 useScrollToBottom from "@/app/hooks/useScrollToBottom"; import usePaste from "@/app/hooks/usePaste"; import { ChatActions } from "./ChatActions"; @@ -168,10 +167,14 @@ export default forwardRef( useImperativeHandle(ref, () => ({ setUploading, doSubmit, - setAutoScroll, setMsgRenderIndex, })); + function scrollToBottom() { + setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); + scrollDomToBottom(); + } + const onInput = (text: string) => { setUserInput(text); const n = text.trim().length; @@ -196,11 +199,6 @@ export default forwardRef( _setMsgRenderIndex(newIndex); } - function scrollToBottom() { - setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); - scrollDomToBottom(); - } - const { handlePaste } = usePaste(attachImages, { emitImages: setAttachImages, setUploading, diff --git a/app/containers/Chat/ChatPanel.tsx b/app/containers/Chat/ChatPanel.tsx new file mode 100644 index 000000000..50933e63e --- /dev/null +++ b/app/containers/Chat/ChatPanel.tsx @@ -0,0 +1,334 @@ +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 "./PromptToast"; +import { EditMessageModal } from "./EditMessageModal"; +import ChatHeader from "./ChatHeader"; +import ChatInputPanel, { ChatInputPanelInstance } from "./ChatInputPanel"; +import ChatMessagePanel, { RenderMessage } from "./ChatMessagePanel"; +import { useAllModels } from "@/app/utils/hooks"; +import useRows from "@/app/hooks/useRows"; +import useMobileScreen from "@/app/hooks/useMobileScreen"; +import SessionConfigModel from "./SessionConfigModal"; +import useScrollToBottom from "@/app/hooks/useScrollToBottom"; + +function _Chat() { + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + const config = useAppConfig(); + + const [showExport, setShowExport] = useState(false); + const [showModelSelector, setShowModelSelector] = useState(false); + + const inputRef = useRef(null); + const [userInput, setUserInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const scrollRef = useRef(null); + const chatInputPanelRef = useRef(null); + + const [hitBottom, setHitBottom] = useState(true); + const isMobileScreen = useMobileScreen(); + + const [attachImages, setAttachImages] = useState([]); + + // 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, + showModelSelector: setShowModelSelector, + scrollDomToBottom, + setAutoScroll, + }; + + const chatMessagePanelProps = { + scrollRef, + inputRef, + isMobileScreen, + msgRenderIndex, + userInput, + context, + renderMessages, + setAutoScroll, + setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex, + setHitBottom, + setUserInput, + setIsLoading, + setShowPromptModal, + scrollDomToBottom, + }; + + const currentModel = chatStore.currentSession().mask.modelConfig.model; + const allModels = useAllModels(); + const models = useMemo( + () => allModels.filter((m) => m.available), + [allModels], + ); + + return ( +
+ + + + + + + {showExport && ( + setShowExport(false)} /> + )} + + {isEditingMessage && ( + { + setIsEditingMessage(false); + }} + /> + )} + + + + {showPromptModal && ( + setShowPromptModal(false)} /> + )} + + {showModelSelector && ( + ({ + title: m.displayName, + value: m.name, + }))} + onClose={() => setShowModelSelector(false)} + onSelection={(s) => { + if (s.length === 0) return; + chatStore.updateCurrentSession((session) => { + session.mask.modelConfig.model = s[0] as ModelType; + session.mask.syncGlobalConfig = false; + }); + showToast(s[0]); + }} + /> + )} +
+ ); +} + +export default function Chat() { + const chatStore = useChatStore(); + const sessionIndex = chatStore.currentSessionIndex; + return <_Chat key={sessionIndex}>; +} diff --git a/app/containers/Chat/MessageActions.tsx b/app/containers/Chat/MessageActions.tsx index 300874c4c..2341808b6 100644 --- a/app/containers/Chat/MessageActions.tsx +++ b/app/containers/Chat/MessageActions.tsx @@ -49,7 +49,7 @@ const genActionsShema = ( (message: RenderMessage) => void >, ) => { - const className = "!p-1 hover:bg-gray-100 !rounded-actions-bar-btn"; + const className = " !p-1 hover:bg-gray-100 !rounded-actions-bar-btn "; return [ { id: "Edit", @@ -231,9 +231,19 @@ export default function MessageActions(props: MessageActionsProps) { return ( showActions && (
(null); - const [userInput, setUserInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const scrollRef = useRef(null); - const chatInputPanelRef = useRef(null); - - const [hitBottom, setHitBottom] = useState(true); - const isMobileScreen = useMobileScreen(); - - const [attachImages, setAttachImages] = useState([]); - - // auto grow input - const { measure, inputRows } = useRows({ - inputRef, - }); - - const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(measure, [userInput]); +import MenuWrapper, { + MenuWrapperInspectProps, +} from "@/app/components/MenuWrapper"; +import Panel from "./ChatPanel"; +export function SessionItem(props: { + onClick?: () => void; + onDelete?: () => void; + title: string; + count: number; + time: string; + selected: boolean; + id: string; + index: number; + narrow?: boolean; + mask: Mask; +}) { + const draggableRef = useRef(null); 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", - }); - } - } + if (props.selected && draggableRef.current) { + draggableRef.current?.scrollIntoView({ + block: "center", }); - - // 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); - } + }, [props.selected]); - // 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, - }), - 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, - showModelSelector: setShowModelSelector, - scrollDomToBottom, - setAutoScroll, - }; - - const chatMessagePanelProps = { - scrollRef, - inputRef, - isMobileScreen, - msgRenderIndex, - userInput, - context, - renderMessages, - setAutoScroll, - setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex, - setHitBottom, - setUserInput, - setIsLoading, - setShowPromptModal, - scrollDomToBottom, - }; - - const currentModel = chatStore.currentSession().mask.modelConfig.model; - const allModels = useAllModels(); - const models = useMemo( - () => allModels.filter((m) => m.available), - [allModels], - ); + const { pathname: currentPath } = useLocation(); return ( -
- - - - - - - {showExport && ( - setShowExport(false)} /> - )} - - {isEditingMessage && ( - { - setIsEditingMessage(false); + + {(provided) => ( +
{ + draggableRef.current = ele; + provided.innerRef(ele); }} - /> - )} + {...provided.draggableProps} + {...provided.dragHandleProps} + title={`${props.title}\n${Locale.ChatItem.ChatItemCount( + props.count, + )}`} + > +
+ +
+
+
+
+ {props.title} +
+
+ {getTime(props.time)} +
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
- - - {showPromptModal && ( - setShowPromptModal(false)} /> +
{ + props.onDelete?.(); + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
)} - - {showModelSelector && ( - ({ - title: m.displayName, - value: m.name, - }))} - onClose={() => setShowModelSelector(false)} - onSelection={(s) => { - if (s.length === 0) return; - chatStore.updateCurrentSession((session) => { - session.mask.modelConfig.model = s[0] as ModelType; - session.mask.syncGlobalConfig = false; - }); - showToast(s[0]); - }} - /> - )} -
+ ); } -export default function Chat() { +export default MenuWrapper(function SessionList(props) { + const { setShowPanel } = props; + + const [sessions, selectedIndex, selectSession, moveSession] = useChatStore( + (state) => [ + state.sessions, + state.currentSessionIndex, + state.selectSession, + state.moveSession, + ], + ); + const navigate = useNavigate(); + const isMobileScreen = useMobileScreen(); + const config = useAppConfig(); const chatStore = useChatStore(); - const sessionIndex = chatStore.currentSessionIndex; - return <_Chat key={sessionIndex}>; -} + const { pathname: currentPath } = useLocation(); + + useEffect(() => { + setShowPanel?.(currentPath === Path.Chat); + }, [currentPath]); + + 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); + }; + + let layoutClassName = "flex flex-col py-7 px-0"; + + if (isMobileScreen) { + layoutClassName = "flex flex-col py-6 pb-chat-panel-mobile "; + } + + return ( +
+
+
+
+ +
+
{ + if (config.dontShowMaskSplashScreen) { + chatStore.newSession(); + navigate(Path.Chat); + } else { + navigate(Path.NewChat); + } + }} + > + +
+
+
+ Build your own AI assistant. +
+
+ +
{ + if (e.target === e.currentTarget) { + navigate(Path.Home); + } + }} + > + + + {(provided) => ( +
+ {sessions.map((item, i) => ( + { + navigate(Path.Chat); + selectSession(i); + }} + onDelete={async () => { + if ( + !isMobileScreen || + (await showConfirm(Locale.Home.DeleteChat)) + ) { + chatStore.deleteSession(i); + } + }} + mask={item.mask} + /> + ))} + {provided.placeholder} +
+ )} +
+
+
+
+ ); +}, Panel); diff --git a/app/containers/Settings/SettingHeader.tsx b/app/containers/Settings/SettingHeader.tsx new file mode 100644 index 000000000..069808576 --- /dev/null +++ b/app/containers/Settings/SettingHeader.tsx @@ -0,0 +1,51 @@ +import { useNavigate } from "react-router-dom"; +import { Path } from "@/app/constant"; +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; + + const navigate = useNavigate(); + + let containerClassName = ""; + let titleClassName = "mr-4"; + let mainTitleClassName = ""; + let subTitleClassName = ""; + + if (isMobileScreen) { + containerClassName = "h-menu-title-mobile"; + titleClassName = "flex flex-col items-center justify-center gap-0.5 text"; + mainTitleClassName = "text-sm-title h-[19px] leading-5"; + subTitleClassName = "text-sm-mobile-tab leading-4"; + } + + return ( +
+ {isMobileScreen ? ( +
goback()} + > + +
+ ) : null} + +
+
+ {Locale.Settings.Title} +
+
+
+ ); +} diff --git a/app/containers/Settings/SettingPanel.tsx b/app/containers/Settings/SettingPanel.tsx new file mode 100644 index 000000000..8e1c19631 --- /dev/null +++ b/app/containers/Settings/SettingPanel.tsx @@ -0,0 +1,1251 @@ +import { useState, useEffect, useMemo } from "react"; + +import styles from "./index.module.scss"; + +import ResetIcon from "@/app/icons/reload.svg"; +import AddIcon from "@/app/icons/add.svg"; +import CopyIcon from "@/app/icons/copy.svg"; +import ClearIcon from "@/app/icons/clear.svg"; +import LoadingIcon from "@/app/icons/three-dots.svg"; +import EditIcon from "@/app/icons/edit.svg"; +import EyeIcon from "@/app/icons/eye.svg"; +import DownloadIcon from "@/app/icons/download.svg"; +import UploadIcon from "@/app/icons/upload.svg"; +import ConfigIcon from "@/app/icons/config.svg"; +import ConfirmIcon from "@/app/icons/confirm.svg"; + +import ConnectionIcon from "@/app/icons/connection.svg"; +import CloudSuccessIcon from "@/app/icons/cloud-success.svg"; +import CloudFailIcon from "@/app/icons/cloud-fail.svg"; + +import { + Input, + List, + ListItem, + Modal, + PasswordInput, + Popover, + Select, + showConfirm, + showToast, +} from "@/app/components/ui-lib"; +import { ModelConfigList } from "@/app/components/model-config"; + +import { IconButton } from "@/app/components/button"; +import { + SubmitKey, + useChatStore, + Theme, + useUpdateStore, + useAccessStore, + useAppConfig, +} from "@/app/store"; + +import Locale, { + AllLangs, + ALL_LANG_OPTIONS, + changeLang, + getLang, +} from "@/app/locales"; +import { copyToClipboard } from "@/app/utils"; +import Link from "next/link"; +import { + Anthropic, + Azure, + Google, + OPENAI_BASE_URL, + Path, + RELEASE_URL, + STORAGE_KEY, + ServiceProvider, + SlotID, + UPDATE_URL, +} from "@/app/constant"; +import { Prompt, SearchService, usePromptStore } from "@/app/store/prompt"; +import { InputRange } from "@/app/components/input-range"; +import { useNavigate } from "react-router-dom"; +import { Avatar, AvatarPicker } from "@/app/components/emoji"; +import { getClientConfig } from "@/app/config/client"; +import { useSyncStore } from "@/app/store/sync"; +import { nanoid } from "nanoid"; +import { useMaskStore } from "@/app/store/mask"; +import { ProviderType } from "@/app/utils/cloud"; +import SettingHeader from "./SettingHeader"; +import useMobileScreen from "@/app/hooks/useMobileScreen"; +import { MenuWrapperInspectProps } from "@/app/components/MenuWrapper"; + +function EditPromptModal(props: { id: string; onClose: () => void }) { + const promptStore = usePromptStore(); + const prompt = promptStore.get(props.id); + + return prompt ? ( +
+ , + ]} + > +
+ + promptStore.updatePrompt( + props.id, + (prompt) => (prompt.title = e.currentTarget.value), + ) + } + > + + promptStore.updatePrompt( + props.id, + (prompt) => (prompt.content = e.currentTarget.value), + ) + } + > +
+
+
+ ) : null; +} + +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([]); + const prompts = searchInput.length > 0 ? searchPrompts : allPrompts; + + const [editingPromptId, setEditingPromptId] = useState(); + + useEffect(() => { + if (searchInput.length > 0) { + const searchResult = SearchService.search(searchInput); + setSearchPrompts(searchResult); + } else { + setSearchPrompts([]); + } + }, [searchInput]); + + return ( +
+ props.onClose?.()} + actions={[ + { + const promptId = promptStore.add({ + id: nanoid(), + createdAt: Date.now(), + title: "Empty Prompt", + content: "Empty Prompt Content", + }); + setEditingPromptId(promptId); + }} + icon={} + bordered + text={Locale.Settings.Prompt.Modal.Add} + />, + ]} + > +
+ setSearchInput(e.currentTarget.value)} + > + +
+ {prompts.map((v, _) => ( +
+
+
{v.title}
+
+ {v.content} +
+
+ +
+ {v.isUser && ( + } + className={styles["user-prompt-button"]} + onClick={() => promptStore.remove(v.id!)} + /> + )} + {v.isUser ? ( + } + className={styles["user-prompt-button"]} + onClick={() => setEditingPromptId(v.id)} + /> + ) : ( + } + className={styles["user-prompt-button"]} + onClick={() => setEditingPromptId(v.id)} + /> + )} + } + className={styles["user-prompt-button"]} + onClick={() => copyToClipboard(v.content)} + /> +
+
+ ))} +
+
+
+ + {editingPromptId !== undefined && ( + setEditingPromptId(undefined)} + /> + )} +
+ ); +} + +function DangerItems() { + const chatStore = useChatStore(); + const appConfig = useAppConfig(); + + return ( + + + { + if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) { + appConfig.reset(); + } + }} + type="danger" + /> + + + { + if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) { + chatStore.clearAllData(); + } + }} + type="danger" + /> + + + ); +} + +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 ( + + ) : checkState === "checking" ? ( + + ) : checkState === "success" ? ( + + ) : checkState === "failed" ? ( + + ) : ( + + ) + } + > + ); +} + +function SyncConfigModal(props: { onClose?: () => void }) { + const syncStore = useSyncStore(); + + return ( +
+ props.onClose?.()} + actions={[ + , + } + bordered + text={Locale.UI.Confirm} + />, + ]} + > + + + + + + + { + syncStore.update( + (config) => (config.useProxy = e.currentTarget.checked), + ); + }} + > + + {syncStore.useProxy ? ( + + { + syncStore.update( + (config) => (config.proxyUrl = e.currentTarget.value), + ); + }} + > + + ) : null} + + + {syncStore.provider === ProviderType.WebDAV && ( + <> + + + { + syncStore.update( + (config) => + (config.webdav.endpoint = e.currentTarget.value), + ); + }} + > + + + + { + syncStore.update( + (config) => + (config.webdav.username = e.currentTarget.value), + ); + }} + > + + + { + syncStore.update( + (config) => + (config.webdav.password = e.currentTarget.value), + ); + }} + > + + + + )} + + {syncStore.provider === ProviderType.UpStash && ( + + + { + syncStore.update( + (config) => + (config.upstash.endpoint = e.currentTarget.value), + ); + }} + > + + + + { + syncStore.update( + (config) => + (config.upstash.username = e.currentTarget.value), + ); + }} + > + + + { + syncStore.update( + (config) => (config.upstash.apiKey = e.currentTarget.value), + ); + }} + > + + + )} + +
+ ); +} + +function SyncItems() { + const syncStore = useSyncStore(); + const chatStore = useChatStore(); + const promptStore = usePromptStore(); + const maskStore = useMaskStore(); + const couldSync = useMemo(() => { + return syncStore.cloudSync(); + }, [syncStore]); + + 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]); + + return ( + <> + + +
+ } + text={Locale.UI.Config} + onClick={() => { + setShowSyncConfigModal(true); + }} + /> + {couldSync && ( + } + text={Locale.UI.Sync} + onClick={async () => { + try { + await syncStore.sync(); + showToast(Locale.Settings.Sync.Success); + } catch (e) { + showToast(Locale.Settings.Sync.Fail); + console.error("[Sync]", e); + } + }} + /> + )} +
+
+ + +
+ } + text={Locale.UI.Export} + onClick={() => { + syncStore.export(); + }} + /> + } + text={Locale.UI.Import} + onClick={() => { + syncStore.import(); + }} + /> +
+
+
+ + {showSyncConfigModal && ( + setShowSyncConfigModal(false)} /> + )} + + ); +} + +export default function Settings(props: MenuWrapperInspectProps) { + const { setShowPanel } = props; + + const navigate = useNavigate(); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const config = useAppConfig(); + const updateConfig = config.update; + + const isMobileScreen = useMobileScreen(); + + const updateStore = useUpdateStore(); + const [checkingUpdate, setCheckingUpdate] = useState(false); + 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); + } + + const accessStore = useAccessStore(); + 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 usage = { + used: updateStore.used, + subscription: updateStore.subscription, + }; + const [loadingUsage, setLoadingUsage] = useState(false); + function checkUsage(force = false) { + if (shouldHideBalanceQuery) { + return; + } + + setLoadingUsage(true); + updateStore.updateUsage(force).finally(() => { + setLoadingUsage(false); + }); + } + + const enabledAccessControl = useMemo( + () => accessStore.enabledAccessControl(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const promptStore = usePromptStore(); + const builtinCount = SearchService.count.builtin; + const customCount = promptStore.getUserPrompts().length ?? 0; + const [shouldShowPromptModal, setShowPromptModal] = useState(false); + + const showUsage = accessStore.isAuthorized(); + useEffect(() => { + // checks per minutes + checkUpdate(); + showUsage && checkUsage(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + 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 showAccessCode = enabledAccessControl && !clientConfig?.isApp; + + return ( +
+ setShowPanel?.(false)} + /> +
+ + + setShowEmojiPicker(false)} + content={ + { + updateConfig((config) => (config.avatar = avatar)); + setShowEmojiPicker(false); + }} + /> + } + open={showEmojiPicker} + > +
{ + setShowEmojiPicker(!showEmojiPicker); + }} + > + +
+
+
+ + + {checkingUpdate ? ( + + ) : hasNewVersion ? ( + + {Locale.Settings.Update.GoToUpdate} + + ) : ( + } + text={Locale.Settings.Update.CheckUpdate} + onClick={() => checkUpdate(true)} + /> + )} + + + + + + + + + + + + + + + + + updateConfig( + (config) => + (config.fontSize = Number.parseInt(e.currentTarget.value)), + ) + } + > + + + + + updateConfig( + (config) => + (config.enableAutoGenerateTitle = e.currentTarget.checked), + ) + } + > + + + + + updateConfig( + (config) => + (config.sendPreviewBubble = e.currentTarget.checked), + ) + } + > + +
+ + + + + + + updateConfig( + (config) => + (config.dontShowMaskSplashScreen = + !e.currentTarget.checked), + ) + } + > + + + + + updateConfig( + (config) => + (config.hideBuiltinMasks = e.currentTarget.checked), + ) + } + > + + + + + + + updateConfig( + (config) => + (config.disablePromptHint = e.currentTarget.checked), + ) + } + > + + + + } + text={Locale.Settings.Prompt.Edit} + onClick={() => setShowPromptModal(true)} + /> + + + + + {showAccessCode && ( + + { + accessStore.update( + (access) => (access.accessCode = e.currentTarget.value), + ); + }} + /> + + )} + + {!accessStore.hideUserApiKey && ( + <> + { + // Conditionally render the following ListItem based on clientConfig.isApp + !clientConfig?.isApp && ( // only show if isApp is false + + + accessStore.update( + (access) => + (access.useCustomConfig = e.currentTarget.checked), + ) + } + > + + ) + } + {accessStore.useCustomConfig && ( + <> + + + + + {accessStore.provider === ServiceProvider.OpenAI && ( + <> + + + accessStore.update( + (access) => + (access.openaiUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => + (access.openaiApiKey = e.currentTarget.value), + ); + }} + /> + + + )} + {accessStore.provider === ServiceProvider.Azure && ( + <> + + + accessStore.update( + (access) => + (access.azureUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => + (access.azureApiKey = e.currentTarget.value), + ); + }} + /> + + + + accessStore.update( + (access) => + (access.azureApiVersion = + e.currentTarget.value), + ) + } + > + + + )} + {accessStore.provider === ServiceProvider.Google && ( + <> + + + accessStore.update( + (access) => + (access.googleUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => + (access.googleApiKey = e.currentTarget.value), + ); + }} + /> + + + + accessStore.update( + (access) => + (access.googleApiVersion = + e.currentTarget.value), + ) + } + > + + + )} + {accessStore.provider === ServiceProvider.Anthropic && ( + <> + + + accessStore.update( + (access) => + (access.anthropicUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => + (access.anthropicApiKey = + e.currentTarget.value), + ); + }} + /> + + + + accessStore.update( + (access) => + (access.anthropicApiVersion = + e.currentTarget.value), + ) + } + > + + + )} + + )} + + )} + + {!shouldHideBalanceQuery && !clientConfig?.isApp ? ( + + {!showUsage || loadingUsage ? ( +
+ ) : ( + } + text={Locale.Settings.Usage.Check} + onClick={() => checkUsage(true)} + /> + )} + + ) : null} + + + + config.update( + (config) => (config.customModels = e.currentTarget.value), + ) + } + > + + + + + { + const modelConfig = { ...config.modelConfig }; + updater(modelConfig); + config.update((config) => (config.modelConfig = modelConfig)); + }} + /> + + + {shouldShowPromptModal && ( + setShowPromptModal(false)} /> + )} + + +
+
+ ); +} diff --git a/app/containers/Settings/index.module.scss b/app/containers/Settings/index.module.scss new file mode 100644 index 000000000..c6aec4203 --- /dev/null +++ b/app/containers/Settings/index.module.scss @@ -0,0 +1,74 @@ +.settings { + padding: 20px; + overflow: auto; +} + +.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; + } + } + } + } +} diff --git a/app/containers/Settings/index.tsx b/app/containers/Settings/index.tsx new file mode 100644 index 000000000..e80789a47 --- /dev/null +++ b/app/containers/Settings/index.tsx @@ -0,0 +1,61 @@ +import Locale from "@/app/locales"; +import useMobileScreen from "@/app/hooks/useMobileScreen"; +import MenuWrapper from "@/app/components/MenuWrapper"; + +import Panel from "./SettingPanel"; + +import GotoIcon from "@/app/icons/goto.svg"; + +export default MenuWrapper(function SettingList(props) { + const { setShowPanel } = props; + const isMobileScreen = useMobileScreen(); + + let layoutClassName = "pt-7 px-4"; + let titleClassName = "pb-5"; + let itemClassName = ""; + + if (isMobileScreen) { + layoutClassName = "h-[100%] mx-[-1.5rem] px-6 py-6 bg-blue-50"; + titleClassName = "h-menu-title-mobile"; + itemClassName = "p-4 bg-white"; + } + + return ( +
+
+
+
+ {Locale.Settings.Title} +
+
+ {/*
+ {Locale.Settings.SubTitle} +
*/} +
+ +
+
{ + setShowPanel?.(true); + }} + > + {Locale.Settings.GeneralSettings} + {isMobileScreen && } +
+
+
+ ); +}, Panel); diff --git a/app/containers/Sidebar/MenuWrapper.tsx b/app/containers/Sidebar/MenuWrapper.tsx deleted file mode 100644 index 7fcc51e22..000000000 --- a/app/containers/Sidebar/MenuWrapper.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Path } from "@/app/constant"; -import { ComponentType } from "react"; -import { useNavigate } from "react-router-dom"; - -export interface MenuWrapperProps { - show: boolean; - wrapperClassName?: string; -} - -export default function MenuWrapper( - Component: ComponentType, -) { - return function MenuHood(props: MenuWrapperProps & ComponentProps) { - const { show, wrapperClassName } = props; - - const navigate = useNavigate(); - - if (!show) { - return null; - } - - return ( -
{ - if (e.target === e.currentTarget) { - navigate(Path.Home); - } - }} - > - -
- ); - }; -} diff --git a/app/containers/Sidebar/SessionList.tsx b/app/containers/Sidebar/SessionList.tsx deleted file mode 100644 index 7a2480dda..000000000 --- a/app/containers/Sidebar/SessionList.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { - DragDropContext, - Droppable, - Draggable, - OnDragEndResponder, -} from "@hello-pangea/dnd"; - -import { useAppConfig, useChatStore } from "@/app/store"; - -import Locale from "@/app/locales"; -import { useLocation, useNavigate } from "react-router-dom"; -import { Path } from "@/app/constant"; -import { Mask } from "@/app/store/mask"; -import { useRef, useEffect, useMemo } from "react"; -import { showConfirm } from "@/app/components/ui-lib"; - -import AddIcon from "@/app/icons/addIcon.svg"; -import NextChatTitle from "@/app/icons/nextchatTitle.svg"; -import { ListHoodProps } from "./types"; -import useMobileScreen from "@/app/hooks/useMobileScreen"; -import { getTime } from "@/app/utils"; -import DeleteIcon from "@/app/icons/deleteIcon.svg"; -import LogIcon from "@/app/icons/logIcon.svg"; - -export function SessionItem(props: { - onClick?: () => void; - onDelete?: () => void; - title: string; - count: number; - time: string; - selected: boolean; - id: string; - index: number; - narrow?: boolean; - mask: Mask; -}) { - const draggableRef = useRef(null); - useEffect(() => { - if (props.selected && draggableRef.current) { - draggableRef.current?.scrollIntoView({ - block: "center", - }); - } - }, [props.selected]); - - const { pathname: currentPath } = useLocation(); - - return ( - - {(provided) => ( -
{ - draggableRef.current = ele; - provided.innerRef(ele); - }} - {...provided.draggableProps} - {...provided.dragHandleProps} - title={`${props.title}\n${Locale.ChatItem.ChatItemCount( - props.count, - )}`} - > -
- -
-
-
-
- {props.title} -
-
- {getTime(props.time)} -
-
-
- {Locale.ChatItem.ChatItemCount(props.count)} -
-
- -
{ - props.onDelete?.(); - e.preventDefault(); - e.stopPropagation(); - }} - > - -
-
- )} -
- ); -} - -export default function SessionList(props: ListHoodProps) { - const [sessions, selectedIndex, selectSession, moveSession] = useChatStore( - (state) => [ - state.sessions, - state.currentSessionIndex, - state.selectSession, - state.moveSession, - ], - ); - const chatStore = useChatStore(); - const navigate = useNavigate(); - const isMobileScreen = useMobileScreen(); - - const config = useAppConfig(); - - 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); - }; - - let layoutClassName = "py-7 px-0"; - - if (isMobileScreen) { - layoutClassName = "h-menu-title-mobile py-6"; - } - - return ( - <> -
-
-
- -
-
{ - if (config.dontShowMaskSplashScreen) { - chatStore.newSession(); - navigate(Path.Chat); - } else { - navigate(Path.NewChat); - } - }} - > - -
-
-
- Build your own AI assistant. -
-
- -
{ - if (e.target === e.currentTarget) { - navigate(Path.Home); - } - }} - > - - - {(provided) => ( -
- {sessions.map((item, i) => ( - { - navigate(Path.Chat); - selectSession(i); - }} - onDelete={async () => { - if ( - !isMobileScreen || - (await showConfirm(Locale.Home.DeleteChat)) - ) { - chatStore.deleteSession(i); - } - }} - mask={item.mask} - /> - ))} - {provided.placeholder} -
- )} -
-
-
- - ); -} diff --git a/app/containers/Sidebar/SettingList.tsx b/app/containers/Sidebar/SettingList.tsx deleted file mode 100644 index d579ebebf..000000000 --- a/app/containers/Sidebar/SettingList.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ListHoodProps } from "./types"; - -export default function SettingList(props: ListHoodProps) { - return <>; -} diff --git a/app/containers/Sidebar/index.tsx b/app/containers/Sidebar/index.tsx index 7613b40cc..d2c64deb0 100644 --- a/app/containers/Sidebar/index.tsx +++ b/app/containers/Sidebar/index.tsx @@ -1,42 +1,25 @@ -import DragIcon from "@/app/icons/drag.svg"; -import DiscoverIcon from "@/app/icons/discoverActive.svg"; -import AssistantActiveIcon from "@/app/icons/assistantActive.svg"; import GitHubIcon from "@/app/icons/githubIcon.svg"; -import SettingIcon from "@/app/icons/settingActive.svg"; +import DiscoverIcon from "@/app/icons/discoverActive.svg"; import DiscoverInactiveIcon from "@/app/icons/discoverInactive.svg"; -import AssistantInactiveIcon from "@/app/icons/assistantInactive.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 DiscoverMobileActive from "@/app/icons/discoverMobileActive.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 { useNavigate, useLocation } from "react-router-dom"; -import dynamic from "next/dynamic"; import useHotKey from "@/app/hooks/useHotKey"; -import useDragSideBar from "@/app/hooks/useDragSideBar"; import useMobileScreen from "@/app/hooks/useMobileScreen"; -import MenuWrapper from "./MenuWrapper"; import ActionsBar from "@/app/components/ActionsBar"; -const SessionList = MenuWrapper( - dynamic(async () => await import("./SessionList"), { - loading: () => null, - }), -); - -const SettingList = MenuWrapper( - dynamic(async () => await import("./SettingList"), { - loading: () => null, - }), -); - export function SideBar(props: { className?: string }) { - // drag side bar - const { onDragStart } = useDragSideBar(); - const navigate = useNavigate(); const loc = useLocation(); @@ -56,18 +39,15 @@ export function SideBar(props: { className?: string }) { selectedTab = Path.Settings; break; default: - selectedTab = Path.Chat; + selectedTab = Path.Home; } - let containerClassName = "relative flex h-[100%] w-[100%]"; + let containerClassName = "relative flex h-[100%]"; let tabActionsClassName = "2xl:px-5 xl:px-4 px-2 py-6"; - let menuClassName = - "max-md:px-4 max-md:pb-4 rounded-md my-2.5 bg-gray-50 flex-1"; if (isMobileScreen) { containerClassName = "flex flex-col-reverse w-[100%] h-[100%]"; tabActionsClassName = "bg-gray-100 rounded-tl-md rounded-tr-md h-mobile"; - menuClassName = `flex-1 px-4`; } return ( @@ -81,12 +61,12 @@ export function SideBar(props: { className?: string }) { active: , inactive: , mobileActive: , - mobileInactive: , + mobileInactive: , }, title: "Discover", }, { - id: Path.Chat, + id: Path.Home, icons: { active: , inactive: , @@ -106,7 +86,7 @@ export function SideBar(props: { className?: string }) { active: , inactive: , mobileActive: , - mobileInactive: , + mobileInactive: , }, className: "p-2", title: "Settrings", @@ -127,36 +107,16 @@ export function SideBar(props: { className?: string }) { }} groups={{ normal: [ - [Path.Chat, Path.Masks], + [Path.Home, Path.Masks], ["github", Path.Settings], ], - mobile: [[Path.Chat, Path.Masks, Path.Settings]], + mobile: [[Path.Home, Path.Masks, Path.Settings]], }} selected={selectedTab} className={`${ isMobileScreen ? "justify-around" : "flex-col" } ${tabActionsClassName}`} /> - - - - - {!isMobileScreen && ( -
onDragStart(e as any)} - > -
- -
-
- )}
); } diff --git a/app/containers/Sidebar/types.ts b/app/containers/Sidebar/types.ts deleted file mode 100644 index 73325ab60..000000000 --- a/app/containers/Sidebar/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ListHoodProps { - // narrow?: boolean; - className?: string; -} diff --git a/app/containers/index.tsx b/app/containers/index.tsx index d9d7a67ca..50015d5d4 100644 --- a/app/containers/index.tsx +++ b/app/containers/index.tsx @@ -17,9 +17,10 @@ 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"; const Settings = dynamic( - async () => (await import("@/app/components/settings")).Settings, + async () => await import("@/app/containers/Settings"), { loading: () => , }, @@ -94,7 +95,7 @@ export default function Home() { }, []); if (!useHasHydrated()) { - return ; + return ; } return ( diff --git a/app/hooks/useListenWinResize.ts b/app/hooks/useListenWinResize.ts index 156775ed5..d0ea816d0 100644 --- a/app/hooks/useListenWinResize.ts +++ b/app/hooks/useListenWinResize.ts @@ -10,7 +10,6 @@ import { MIN_SIDEBAR_WIDTH, } from "@/app/constant"; import { useAppConfig } from "../store/config"; -import { useReducer, useState } from "react"; export const MOBILE_MAX_WIDTH = 768; @@ -25,8 +24,6 @@ const widths = [ export default function useListenWinResize() { const config = useAppConfig(); - const [_, refresh] = useReducer((x) => x + 1, 0); - useWindowSize((size) => { let nextSidebar = config.sidebarWidth; if (!nextSidebar) { @@ -56,6 +53,5 @@ export default function useListenWinResize() { config.update((config) => { config.sidebarWidth = nextSidebar; }); - refresh(); }); } diff --git a/app/hooks/useMobileScreen.ts b/app/hooks/useMobileScreen.ts index ec365b762..7d1aee590 100644 --- a/app/hooks/useMobileScreen.ts +++ b/app/hooks/useMobileScreen.ts @@ -1,16 +1,9 @@ import { useWindowSize } from "@/app/hooks/useWindowSize"; -import { useRef } from "react"; export const MOBILE_MAX_WIDTH = 768; export default function useMobileScreen() { - const widthRef = useRef(0); + const { width } = useWindowSize(); - useWindowSize((size) => { - widthRef.current = size.width; - }); - - const isMobile = widthRef.current <= MOBILE_MAX_WIDTH; - - return isMobile; + return width <= MOBILE_MAX_WIDTH; } diff --git a/app/hooks/useScrollToBottom.ts b/app/hooks/useScrollToBottom.ts index 7fd604cda..f2c35348a 100644 --- a/app/hooks/useScrollToBottom.ts +++ b/app/hooks/useScrollToBottom.ts @@ -10,9 +10,13 @@ export default function useScrollToBottom( ) <= 1 : false; - const initScrolled = useRef(false); // for auto-scroll const [autoScroll, setAutoScroll] = useState(true); + + const autoScrollRef = useRef(); + + autoScrollRef.current = autoScroll; + function scrollDomToBottom() { const dom = scrollRef.current; if (dom) { @@ -23,13 +27,30 @@ export default function useScrollToBottom( } } + // 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 && !initScrolled.current) { + if (autoScroll && !detach) { scrollDomToBottom(); - initScrolled.current = true; } - }, [autoScroll, detach]); + }); return { scrollRef, diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts index 20ad18a5a..a4dbf2ef9 100644 --- a/app/hooks/useWindowSize.ts +++ b/app/hooks/useWindowSize.ts @@ -1,26 +1,35 @@ -import { useLayoutEffect, useRef } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; type Size = { width: number; height: number; }; -export function useWindowSize(callback: (size: Size) => void) { +export function useWindowSize(callback?: (size: Size) => void) { const callbackRef = useRef(); callbackRef.current = callback; + const [size, setSize] = useState({ + 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({ + callback?.({ width: window.innerWidth, height: window.innerHeight, }); @@ -29,4 +38,6 @@ export function useWindowSize(callback: (size: Size) => void) { window.removeEventListener("resize", onResize); }; }, []); + + return size; } diff --git a/app/icons/assistantMobileInactive.svg b/app/icons/assistantMobileInactive.svg index d76154b51..cbe807278 100644 --- a/app/icons/assistantMobileInactive.svg +++ b/app/icons/assistantMobileInactive.svg @@ -1,3 +1,7 @@ - - - \ No newline at end of file + + + + + + + diff --git a/app/icons/discoverMobileActive.svg b/app/icons/discoverMobileActive.svg index c341db4ec..7809e40a2 100644 --- a/app/icons/discoverMobileActive.svg +++ b/app/icons/discoverMobileActive.svg @@ -1,9 +1,9 @@ - - + + - - - + + + - \ No newline at end of file + diff --git a/app/icons/discoverMobileInactive.svg b/app/icons/discoverMobileInactive.svg index 2781d9fdf..099d32bab 100644 --- a/app/icons/discoverMobileInactive.svg +++ b/app/icons/discoverMobileInactive.svg @@ -1,7 +1,8 @@ - - - + + + - - + + + diff --git a/app/icons/goto.svg b/app/icons/goto.svg new file mode 100644 index 000000000..29ff0ebb5 --- /dev/null +++ b/app/icons/goto.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/settingMobileActive.svg b/app/icons/settingMobileActive.svg index 61b1e44c5..eed56e59d 100644 --- a/app/icons/settingMobileActive.svg +++ b/app/icons/settingMobileActive.svg @@ -1,9 +1,9 @@ - - + + - - - + + + - \ No newline at end of file + diff --git a/app/icons/settingMobileInactive.svg b/app/icons/settingMobileInactive.svg index b4df8064b..aae79566d 100644 --- a/app/icons/settingMobileInactive.svg +++ b/app/icons/settingMobileInactive.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 3320bec71..2074b6d7b 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -128,7 +128,7 @@ const cn = { Settings: { Title: "设置", SubTitle: "所有设置选项", - + GeneralSettings: "通用设置", Danger: { Reset: { Title: "重置所有设置", diff --git a/app/locales/en.ts b/app/locales/en.ts index 2b1c697ef..f7391658d 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -131,6 +131,7 @@ const en: LocaleType = { Settings: { Title: "Settings", SubTitle: "All Settings", + GeneralSettings: "General settings", Danger: { Reset: { Title: "Reset All Settings", diff --git a/app/locales/jp.ts b/app/locales/jp.ts index dcbd0f282..8f052eb31 100644 --- a/app/locales/jp.ts +++ b/app/locales/jp.ts @@ -95,6 +95,7 @@ const jp: PartialLocaleType = { Settings: { Title: "設定", SubTitle: "設定オプション", + GeneralSettings: "一般設定", Danger: { Reset: { Title: "設定をリセット", diff --git a/app/store/chat.ts b/app/store/chat.ts index eeddd8463..6fc2eee59 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -30,9 +30,26 @@ export type ChatMessage = RequestMessage & { model?: ModelType; }; -export function createMessage(override: Partial): ChatMessage { +let tempGlobalId = 0; + +export function createMessage( + override: Partial, + options?: { temp?: boolean; customId?: string }, +): ChatMessage { + const { temp, customId } = options ?? {}; + + let id: string; + if (customId) { + id = customId; + } else if (temp) { + tempGlobalId += 1; + id = String(tempGlobalId); + } else { + id = nanoid(); + } + return { - id: nanoid(), + id, date: new Date().toLocaleString(), role: "user", content: "", diff --git a/app/styles/globals.css b/app/styles/globals.css index 3b9bb97d4..037008e28 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -27,4 +27,5 @@ body { :root { --tip-popover-color: #434360; --chat-panel-bg: rgb(249, 250, 251, 1); + --siderbar-mobile-height: 3.125rem; } diff --git a/tailwind.config.js b/tailwind.config.js index a2c6db11b..ab82d1535 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -11,6 +11,11 @@ module.exports = { 'sm-mobile-tab': '0.625rem', 'chat-header-title': '1rem', 'actions-popover-menu-item': '15px', + 'setting-title': '1.25rem', + 'setting-items': '1rem', + }, + fontWeight: { + 'setting-title': '700', }, fontFamily: { 'common': ['Satoshi Variable', 'Variable'], @@ -23,10 +28,6 @@ module.exports = { xl: '1440px', '2xl': '1980px' }, - // spacing: Array.from({ length: 1000 }).reduce((map, _, index) => { - // map[index] = `${index}rem`; - // return map; - // }, {}), extend: { minHeight: { 'chat-input-mobile': '19px', @@ -41,11 +42,14 @@ module.exports = { 'actions-popover': '203px', }, height: { - mobile: '3.125rem', + mobile: 'var(--siderbar-mobile-height)', + // mobile: '3.125rem', 'menu-title-mobile': '3rem', 'thumbnail': '5rem', 'chat-input-mobile': '19px', 'chat-input': '60px', + 'chat-panel-mobile': '- var(--siderbar-mobile-height)', + 'setting-panel-mobile': 'calc(100vh - var(--siderbar-mobile-height))', }, flexBasis: { 'sidebar': 'var(--sidebar-width)', @@ -53,6 +57,7 @@ module.exports = { }, spacing: { 'chat-header-gap': '0.625rem', + 'chat-panel-mobile': 'var(--siderbar-mobile-height)' }, backgroundImage: { 'message-bg': 'linear-gradient(259deg, #9786FF 8.42%, #4A5CFF 90.13%)', @@ -75,6 +80,7 @@ module.exports = { 'chat-input': '0px 4px 20px 0px rgba(60, 68, 255, 0.13)', 'actions-popover': '0px 14px 40px 0px rgba(0, 0, 0, 0.12)', 'actions-bar': '0px 4px 30px 0px rgba(0, 0, 0, 0.10)', + 'prompt-hint-container': 'inset 0 4px 8px 0 rgba(0, 0, 0, 0.1)' } }, borderRadius: {