Browse Source

Merge branch 'main' of https://github.com/Yidadaa/ChatGPT-Next-Web

GH Action - Upstream Sync 2 years ago
parent
commit
8a92904287

+ 4 - 0
README.md

@@ -146,6 +146,10 @@ Access passsword, separated by comma.
 
 
 Override openai api request base url.
 Override openai api request base url.
 
 
+### `OPENAI_ORG_ID` (optional)
+
+Specify OpenAI organization ID.
+
 ## Development
 ## Development
 
 
 > [简体中文 > 如何进行二次开发](./README_CN.md#开发)
 > [简体中文 > 如何进行二次开发](./README_CN.md#开发)

+ 4 - 0
README_CN.md

@@ -94,6 +94,10 @@ OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填
 
 
 > 如果遇到 ssl 证书问题,请将 `BASE_URL` 的协议设置为 http。
 > 如果遇到 ssl 证书问题,请将 `BASE_URL` 的协议设置为 http。
 
 
+### `OPENAI_ORG_ID` (可选)
+
+指定 OpenAI 中的组织 ID。
+
 ## 开发
 ## 开发
 
 
 > 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。
 > 强烈不建议在本地进行开发或者部署,由于一些技术原因,很难在本地配置好 OpenAI API 代理,除非你能保证可以直连 OpenAI 服务器。

+ 29 - 11
app/components/chat-list.tsx

@@ -10,7 +10,8 @@ import {
 import { useChatStore } from "../store";
 import { useChatStore } from "../store";
 
 
 import Locale from "../locales";
 import Locale from "../locales";
-import { isMobileScreen } from "../utils";
+import { Link, useNavigate } from "react-router-dom";
+import { Path } from "../constant";
 
 
 export function ChatItem(props: {
 export function ChatItem(props: {
   onClick?: () => void;
   onClick?: () => void;
@@ -21,6 +22,7 @@ export function ChatItem(props: {
   selected: boolean;
   selected: boolean;
   id: number;
   id: number;
   index: number;
   index: number;
+  narrow?: boolean;
 }) {
 }) {
   return (
   return (
     <Draggable draggableId={`${props.id}`} index={props.index}>
     <Draggable draggableId={`${props.id}`} index={props.index}>
@@ -34,13 +36,20 @@ export function ChatItem(props: {
           {...provided.draggableProps}
           {...provided.draggableProps}
           {...provided.dragHandleProps}
           {...provided.dragHandleProps}
         >
         >
-          <div className={styles["chat-item-title"]}>{props.title}</div>
-          <div className={styles["chat-item-info"]}>
-            <div className={styles["chat-item-count"]}>
-              {Locale.ChatItem.ChatItemCount(props.count)}
-            </div>
-            <div className={styles["chat-item-date"]}>{props.time}</div>
-          </div>
+          {props.narrow ? (
+            <div className={styles["chat-item-narrow"]}>{props.count}</div>
+          ) : (
+            <>
+              <div className={styles["chat-item-title"]}>{props.title}</div>
+              <div className={styles["chat-item-info"]}>
+                <div className={styles["chat-item-count"]}>
+                  {Locale.ChatItem.ChatItemCount(props.count)}
+                </div>
+                <div className={styles["chat-item-date"]}>{props.time}</div>
+              </div>
+            </>
+          )}
+
           <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
           <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
             <DeleteIcon />
             <DeleteIcon />
           </div>
           </div>
@@ -50,7 +59,7 @@ export function ChatItem(props: {
   );
   );
 }
 }
 
 
-export function ChatList() {
+export function ChatList(props: { narrow?: boolean }) {
   const [sessions, selectedIndex, selectSession, removeSession, moveSession] =
   const [sessions, selectedIndex, selectSession, removeSession, moveSession] =
     useChatStore((state) => [
     useChatStore((state) => [
       state.sessions,
       state.sessions,
@@ -60,6 +69,7 @@ export function ChatList() {
       state.moveSession,
       state.moveSession,
     ]);
     ]);
   const chatStore = useChatStore();
   const chatStore = useChatStore();
+  const navigate = useNavigate();
 
 
   const onDragEnd: OnDragEndResponder = (result) => {
   const onDragEnd: OnDragEndResponder = (result) => {
     const { destination, source } = result;
     const { destination, source } = result;
@@ -95,8 +105,16 @@ export function ChatList() {
                 id={item.id}
                 id={item.id}
                 index={i}
                 index={i}
                 selected={i === selectedIndex}
                 selected={i === selectedIndex}
-                onClick={() => selectSession(i)}
-                onDelete={() => chatStore.deleteSession(i)}
+                onClick={() => {
+                  navigate(Path.Chat);
+                  selectSession(i);
+                }}
+                onDelete={() => {
+                  if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
+                    chatStore.deleteSession(i);
+                  }
+                }}
+                narrow={props.narrow}
               />
               />
             ))}
             ))}
             {provided.placeholder}
             {provided.placeholder}

+ 57 - 46
app/components/chat.tsx

@@ -10,6 +10,7 @@ import CopyIcon from "../icons/copy.svg";
 import DownloadIcon from "../icons/download.svg";
 import DownloadIcon from "../icons/download.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import BotIcon from "../icons/bot.svg";
 import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
 import AddIcon from "../icons/add.svg";
 import AddIcon from "../icons/add.svg";
 import DeleteIcon from "../icons/delete.svg";
 import DeleteIcon from "../icons/delete.svg";
 import MaxIcon from "../icons/max.svg";
 import MaxIcon from "../icons/max.svg";
@@ -30,15 +31,16 @@ import {
   createMessage,
   createMessage,
   useAccessStore,
   useAccessStore,
   Theme,
   Theme,
+  ModelType,
 } from "../store";
 } from "../store";
 
 
 import {
 import {
   copyToClipboard,
   copyToClipboard,
   downloadAs,
   downloadAs,
   getEmojiUrl,
   getEmojiUrl,
-  isMobileScreen,
   selectOrCopy,
   selectOrCopy,
   autoGrowTextArea,
   autoGrowTextArea,
+  useMobileScreen,
 } from "../utils";
 } from "../utils";
 
 
 import dynamic from "next/dynamic";
 import dynamic from "next/dynamic";
@@ -52,6 +54,8 @@ import styles from "./home.module.scss";
 import chatStyle from "./chat.module.scss";
 import chatStyle from "./chat.module.scss";
 
 
 import { Input, Modal, showModal } from "./ui-lib";
 import { Input, Modal, showModal } from "./ui-lib";
+import { useNavigate } from "react-router-dom";
+import { Path } from "../constant";
 
 
 const Markdown = dynamic(
 const Markdown = dynamic(
   async () => memo((await import("./markdown")).Markdown),
   async () => memo((await import("./markdown")).Markdown),
@@ -64,13 +68,17 @@ const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
   loading: () => <LoadingIcon />,
   loading: () => <LoadingIcon />,
 });
 });
 
 
-export function Avatar(props: { role: Message["role"] }) {
+export function Avatar(props: { role: Message["role"]; model?: ModelType }) {
   const config = useChatStore((state) => state.config);
   const config = useChatStore((state) => state.config);
 
 
   if (props.role !== "user") {
   if (props.role !== "user") {
     return (
     return (
       <div className="no-dark">
       <div className="no-dark">
-        <BotIcon className={styles["user-avtar"]} />
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className={styles["user-avtar"]} />
+        ) : (
+          <BotIcon className={styles["user-avtar"]} />
+        )}
       </div>
       </div>
     );
     );
   }
   }
@@ -412,10 +420,7 @@ export function ChatActions(props: {
   );
   );
 }
 }
 
 
-export function Chat(props: {
-  showSideBar?: () => void;
-  sideBarShowing?: boolean;
-}) {
+export function Chat() {
   type RenderMessage = Message & { preview?: boolean };
   type RenderMessage = Message & { preview?: boolean };
 
 
   const chatStore = useChatStore();
   const chatStore = useChatStore();
@@ -432,6 +437,8 @@ export function Chat(props: {
   const { submitKey, shouldSubmit } = useSubmitHandler();
   const { submitKey, shouldSubmit } = useSubmitHandler();
   const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
   const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
   const [hitBottom, setHitBottom] = useState(false);
   const [hitBottom, setHitBottom] = useState(false);
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
 
 
   const onChatBodyScroll = (e: HTMLElement) => {
   const onChatBodyScroll = (e: HTMLElement) => {
     const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
     const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
@@ -462,7 +469,7 @@ export function Chat(props: {
       const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
       const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
       const inputRows = Math.min(
       const inputRows = Math.min(
         5,
         5,
-        Math.max(2 + Number(!isMobileScreen()), rows),
+        Math.max(2 + Number(!isMobileScreen), rows),
       );
       );
       setInputRows(inputRows);
       setInputRows(inputRows);
     },
     },
@@ -502,7 +509,7 @@ export function Chat(props: {
     setBeforeInput(userInput);
     setBeforeInput(userInput);
     setUserInput("");
     setUserInput("");
     setPromptHints([]);
     setPromptHints([]);
-    if (!isMobileScreen()) inputRef.current?.focus();
+    if (!isMobileScreen) inputRef.current?.focus();
     setAutoScroll(true);
     setAutoScroll(true);
   };
   };
 
 
@@ -634,7 +641,7 @@ export function Chat(props: {
 
 
   // Auto focus
   // Auto focus
   useEffect(() => {
   useEffect(() => {
-    if (props.sideBarShowing && isMobileScreen()) return;
+    if (isMobileScreen) return;
     inputRef.current?.focus();
     inputRef.current?.focus();
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
   }, []);
@@ -659,7 +666,7 @@ export function Chat(props: {
               icon={<ReturnIcon />}
               icon={<ReturnIcon />}
               bordered
               bordered
               title={Locale.Chat.Actions.ChatList}
               title={Locale.Chat.Actions.ChatList}
-              onClick={props?.showSideBar}
+              onClick={() => navigate(Path.Home)}
             />
             />
           </div>
           </div>
           <div className={styles["window-action-button"]}>
           <div className={styles["window-action-button"]}>
@@ -682,7 +689,7 @@ export function Chat(props: {
               }}
               }}
             />
             />
           </div>
           </div>
-          {!isMobileScreen() && (
+          {!isMobileScreen && (
             <div className={styles["window-action-button"]}>
             <div className={styles["window-action-button"]}>
               <IconButton
               <IconButton
                 icon={chatStore.config.tightBorder ? <MinIcon /> : <MaxIcon />}
                 icon={chatStore.config.tightBorder ? <MinIcon /> : <MaxIcon />}
@@ -717,6 +724,11 @@ export function Chat(props: {
       >
       >
         {messages.map((message, i) => {
         {messages.map((message, i) => {
           const isUser = message.role === "user";
           const isUser = message.role === "user";
+          const showActions =
+            !isUser &&
+            i > 0 &&
+            !(message.preview || message.content.length === 0);
+          const showTyping = message.preview || message.streaming;
 
 
           return (
           return (
             <div
             <div
@@ -727,49 +739,48 @@ export function Chat(props: {
             >
             >
               <div className={styles["chat-message-container"]}>
               <div className={styles["chat-message-container"]}>
                 <div className={styles["chat-message-avatar"]}>
                 <div className={styles["chat-message-avatar"]}>
-                  <Avatar role={message.role} />
+                  <Avatar role={message.role} model={message.model} />
                 </div>
                 </div>
-                {(message.preview || message.streaming) && (
+                {showTyping && (
                   <div className={styles["chat-message-status"]}>
                   <div className={styles["chat-message-status"]}>
                     {Locale.Chat.Typing}
                     {Locale.Chat.Typing}
                   </div>
                   </div>
                 )}
                 )}
                 <div className={styles["chat-message-item"]}>
                 <div className={styles["chat-message-item"]}>
-                  {!isUser &&
-                    !(message.preview || message.content.length === 0) && (
-                      <div className={styles["chat-message-top-actions"]}>
-                        {message.streaming ? (
-                          <div
-                            className={styles["chat-message-top-action"]}
-                            onClick={() => onUserStop(message.id ?? i)}
-                          >
-                            {Locale.Chat.Actions.Stop}
-                          </div>
-                        ) : (
-                          <>
-                            <div
-                              className={styles["chat-message-top-action"]}
-                              onClick={() => onDelete(message.id ?? i)}
-                            >
-                              {Locale.Chat.Actions.Delete}
-                            </div>
-                            <div
-                              className={styles["chat-message-top-action"]}
-                              onClick={() => onResend(message.id ?? i)}
-                            >
-                              {Locale.Chat.Actions.Retry}
-                            </div>
-                          </>
-                        )}
-
+                  {showActions && (
+                    <div className={styles["chat-message-top-actions"]}>
+                      {message.streaming ? (
                         <div
                         <div
                           className={styles["chat-message-top-action"]}
                           className={styles["chat-message-top-action"]}
-                          onClick={() => copyToClipboard(message.content)}
+                          onClick={() => onUserStop(message.id ?? i)}
                         >
                         >
-                          {Locale.Chat.Actions.Copy}
+                          {Locale.Chat.Actions.Stop}
                         </div>
                         </div>
+                      ) : (
+                        <>
+                          <div
+                            className={styles["chat-message-top-action"]}
+                            onClick={() => onDelete(message.id ?? i)}
+                          >
+                            {Locale.Chat.Actions.Delete}
+                          </div>
+                          <div
+                            className={styles["chat-message-top-action"]}
+                            onClick={() => onResend(message.id ?? i)}
+                          >
+                            {Locale.Chat.Actions.Retry}
+                          </div>
+                        </>
+                      )}
+
+                      <div
+                        className={styles["chat-message-top-action"]}
+                        onClick={() => copyToClipboard(message.content)}
+                      >
+                        {Locale.Chat.Actions.Copy}
                       </div>
                       </div>
-                    )}
+                    </div>
+                  )}
                   <Markdown
                   <Markdown
                     content={message.content}
                     content={message.content}
                     loading={
                     loading={
@@ -778,7 +789,7 @@ export function Chat(props: {
                     }
                     }
                     onContextMenu={(e) => onRightClick(e, message)}
                     onContextMenu={(e) => onRightClick(e, message)}
                     onDoubleClickCapture={() => {
                     onDoubleClickCapture={() => {
-                      if (!isMobileScreen()) return;
+                      if (!isMobileScreen) return;
                       setUserInput(message.content);
                       setUserInput(message.content);
                     }}
                     }}
                     fontSize={fontSize}
                     fontSize={fontSize}
@@ -819,7 +830,7 @@ export function Chat(props: {
               setAutoScroll(false);
               setAutoScroll(false);
               setTimeout(() => setPromptHints([]), 500);
               setTimeout(() => setPromptHints([]), 500);
             }}
             }}
-            autoFocus={!props?.sideBarShowing}
+            autoFocus
             rows={inputRows}
             rows={inputRows}
           />
           />
           <IconButton
           <IconButton

+ 68 - 1
app/components/home.module.scss

@@ -50,7 +50,7 @@
   flex-direction: column;
   flex-direction: column;
   box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
   box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
   position: relative;
   position: relative;
-  transition: width ease 0.1s;
+  transition: width ease 0.05s;
 }
 }
 
 
 .sidebar-drag {
 .sidebar-drag {
@@ -126,11 +126,13 @@
 .sidebar-title {
 .sidebar-title {
   font-size: 20px;
   font-size: 20px;
   font-weight: bold;
   font-weight: bold;
+  animation: slide-in ease 0.3s;
 }
 }
 
 
 .sidebar-sub-title {
 .sidebar-sub-title {
   font-size: 12px;
   font-size: 12px;
   font-weight: 400px;
   font-weight: 400px;
+  animation: slide-in ease 0.3s;
 }
 }
 
 
 .sidebar-body {
 .sidebar-body {
@@ -171,6 +173,7 @@
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
+  animation: slide-in ease 0.3s;
 }
 }
 
 
 .chat-item-delete {
 .chat-item-delete {
@@ -197,6 +200,7 @@
   color: rgb(166, 166, 166);
   color: rgb(166, 166, 166);
   font-size: 12px;
   font-size: 12px;
   margin-top: 8px;
   margin-top: 8px;
+  animation: slide-in ease 0.3s;
 }
 }
 
 
 .chat-item-count,
 .chat-item-count,
@@ -206,6 +210,69 @@
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
+.narrow-sidebar {
+  .sidebar-title,
+  .sidebar-sub-title {
+    display: none;
+  }
+  .sidebar-logo {
+    position: relative;
+    display: flex;
+    justify-content: center;
+  }
+
+  .chat-item {
+    padding: 0;
+    min-height: 50px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    transition: all ease 0.3s;
+
+    &:hover {
+      .chat-item-narrow {
+        transform: scale(0.7) translateX(-50%);
+      }
+    }
+  }
+
+  .chat-item-narrow {
+    font-weight: bolder;
+    font-size: 24px;
+    line-height: 0;
+    font-weight: lighter;
+    color: var(--black);
+    transform: translateX(0);
+    transition: all ease 0.3s;
+    opacity: 0.1;
+    padding: 4px;
+  }
+
+  .chat-item-delete {
+    top: 15px;
+  }
+
+  .chat-item:hover > .chat-item-delete {
+    opacity: 0.5;
+    right: 5px;
+  }
+
+  .sidebar-tail {
+    flex-direction: column;
+    align-items: center;
+
+    .sidebar-actions {
+      flex-direction: column;
+      align-items: center;
+
+      .sidebar-action {
+        margin-right: 0;
+        margin-bottom: 15px;
+      }
+    }
+  }
+}
+
 .sidebar-tail {
 .sidebar-tail {
   display: flex;
   display: flex;
   justify-content: space-between;
   justify-content: space-between;

+ 45 - 164
app/components/home.tsx

@@ -2,32 +2,31 @@
 
 
 require("../polyfill");
 require("../polyfill");
 
 
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect } from "react";
 
 
-import { IconButton } from "./button";
 import styles from "./home.module.scss";
 import styles from "./home.module.scss";
 
 
-import SettingsIcon from "../icons/settings.svg";
-import GithubIcon from "../icons/github.svg";
-import ChatGptIcon from "../icons/chatgpt.svg";
-
 import BotIcon from "../icons/bot.svg";
 import BotIcon from "../icons/bot.svg";
-import AddIcon from "../icons/add.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import LoadingIcon from "../icons/three-dots.svg";
-import CloseIcon from "../icons/close.svg";
 
 
 import { useChatStore } from "../store";
 import { useChatStore } from "../store";
-import { getCSSVar, isMobileScreen } from "../utils";
-import Locale from "../locales";
+import { getCSSVar, useMobileScreen } from "../utils";
 import { Chat } from "./chat";
 import { Chat } from "./chat";
 
 
 import dynamic from "next/dynamic";
 import dynamic from "next/dynamic";
-import { REPO_URL } from "../constant";
+import { Path } from "../constant";
 import { ErrorBoundary } from "./error";
 import { ErrorBoundary } from "./error";
 
 
+import {
+  HashRouter as Router,
+  Routes,
+  Route,
+  useLocation,
+} from "react-router-dom";
+
 export function Loading(props: { noLogo?: boolean }) {
 export function Loading(props: { noLogo?: boolean }) {
   return (
   return (
-    <div className={styles["loading-content"]}>
+    <div className={styles["loading-content"] + " no-dark"}>
       {!props.noLogo && <BotIcon />}
       {!props.noLogo && <BotIcon />}
       <LoadingIcon />
       <LoadingIcon />
     </div>
     </div>
@@ -38,11 +37,11 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
   loading: () => <Loading noLogo />,
   loading: () => <Loading noLogo />,
 });
 });
 
 
-const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
+const SideBar = dynamic(async () => (await import("./sidebar")).SideBar, {
   loading: () => <Loading noLogo />,
   loading: () => <Loading noLogo />,
 });
 });
 
 
-function useSwitchTheme() {
+export function useSwitchTheme() {
   const config = useChatStore((state) => state.config);
   const config = useChatStore((state) => state.config);
 
 
   useEffect(() => {
   useEffect(() => {
@@ -73,53 +72,6 @@ function useSwitchTheme() {
   }, [config.theme]);
   }, [config.theme]);
 }
 }
 
 
-function useDragSideBar() {
-  const limit = (x: number) => Math.min(500, Math.max(220, x));
-
-  const chatStore = useChatStore();
-  const startX = useRef(0);
-  const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
-  const lastUpdateTime = useRef(Date.now());
-
-  const handleMouseMove = useRef((e: MouseEvent) => {
-    if (Date.now() < lastUpdateTime.current + 100) {
-      return;
-    }
-    lastUpdateTime.current = Date.now();
-    const d = e.clientX - startX.current;
-    const nextWidth = limit(startDragWidth.current + d);
-    chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
-  });
-
-  const handleMouseUp = useRef(() => {
-    startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
-    window.removeEventListener("mousemove", handleMouseMove.current);
-    window.removeEventListener("mouseup", handleMouseUp.current);
-  });
-
-  const onDragMouseDown = (e: MouseEvent) => {
-    startX.current = e.clientX;
-
-    window.addEventListener("mousemove", handleMouseMove.current);
-    window.addEventListener("mouseup", handleMouseUp.current);
-  };
-
-  useEffect(() => {
-    if (isMobileScreen()) {
-      return;
-    }
-
-    document.documentElement.style.setProperty(
-      "--sidebar-width",
-      `${limit(chatStore.config.sidebarWidth ?? 300)}px`,
-    );
-  }, [chatStore.config.sidebarWidth]);
-
-  return {
-    onDragMouseDown,
-  };
-}
-
 const useHasHydrated = () => {
 const useHasHydrated = () => {
   const [hasHydrated, setHasHydrated] = useState<boolean>(false);
   const [hasHydrated, setHasHydrated] = useState<boolean>(false);
 
 
@@ -130,129 +82,58 @@ const useHasHydrated = () => {
   return hasHydrated;
   return hasHydrated;
 };
 };
 
 
-function _Home() {
-  const [createNewSession, currentIndex, removeSession] = useChatStore(
-    (state) => [
-      state.newSession,
-      state.currentSessionIndex,
-      state.removeSession,
-    ],
-  );
-  const chatStore = useChatStore();
-  const loading = !useHasHydrated();
-  const [showSideBar, setShowSideBar] = useState(true);
-
-  // setting
-  const [openSettings, setOpenSettings] = useState(false);
+function WideScreen() {
   const config = useChatStore((state) => state.config);
   const config = useChatStore((state) => state.config);
 
 
-  // drag side bar
-  const { onDragMouseDown } = useDragSideBar();
-
-  useSwitchTheme();
-
-  if (loading) {
-    return <Loading />;
-  }
-
   return (
   return (
     <div
     <div
       className={`${
       className={`${
-        config.tightBorder && !isMobileScreen()
-          ? styles["tight-container"]
-          : styles.container
+        config.tightBorder ? styles["tight-container"] : styles.container
       }`}
       }`}
     >
     >
-      <div
-        className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`}
-      >
-        <div className={styles["sidebar-header"]}>
-          <div className={styles["sidebar-title"]}>ChatGPT Next</div>
-          <div className={styles["sidebar-sub-title"]}>
-            Build your own AI assistant.
-          </div>
-          <div className={styles["sidebar-logo"]}>
-            <ChatGptIcon />
-          </div>
-        </div>
+      <SideBar />
 
 
-        <div
-          className={styles["sidebar-body"]}
-          onClick={() => {
-            setOpenSettings(false);
-            setShowSideBar(false);
-          }}
-        >
-          <ChatList />
-        </div>
+      <div className={styles["window-content"]}>
+        <Routes>
+          <Route path={Path.Home} element={<Chat />} />
+          <Route path={Path.Chat} element={<Chat />} />
+          <Route path={Path.Settings} element={<Settings />} />
+        </Routes>
+      </div>
+    </div>
+  );
+}
 
 
-        <div className={styles["sidebar-tail"]}>
-          <div className={styles["sidebar-actions"]}>
-            <div className={styles["sidebar-action"] + " " + styles.mobile}>
-              <IconButton
-                icon={<CloseIcon />}
-                onClick={chatStore.deleteSession}
-              />
-            </div>
-            <div className={styles["sidebar-action"]}>
-              <IconButton
-                icon={<SettingsIcon />}
-                onClick={() => {
-                  setOpenSettings(true);
-                  setShowSideBar(false);
-                }}
-                shadow
-              />
-            </div>
-            <div className={styles["sidebar-action"]}>
-              <a href={REPO_URL} target="_blank">
-                <IconButton icon={<GithubIcon />} shadow />
-              </a>
-            </div>
-          </div>
-          <div>
-            <IconButton
-              icon={<AddIcon />}
-              text={Locale.Home.NewChat}
-              onClick={() => {
-                createNewSession();
-                setShowSideBar(false);
-              }}
-              shadow
-            />
-          </div>
-        </div>
+function MobileScreen() {
+  const location = useLocation();
+  const isHome = location.pathname === Path.Home;
 
 
-        <div
-          className={styles["sidebar-drag"]}
-          onMouseDown={(e) => onDragMouseDown(e as any)}
-        ></div>
-      </div>
+  return (
+    <div className={styles.container}>
+      <SideBar className={isHome ? styles["sidebar-show"] : ""} />
 
 
       <div className={styles["window-content"]}>
       <div className={styles["window-content"]}>
-        {openSettings ? (
-          <Settings
-            closeSettings={() => {
-              setOpenSettings(false);
-              setShowSideBar(true);
-            }}
-          />
-        ) : (
-          <Chat
-            key="chat"
-            showSideBar={() => setShowSideBar(true)}
-            sideBarShowing={showSideBar}
-          />
-        )}
+        <Routes>
+          <Route path={Path.Home} element={null} />
+          <Route path={Path.Chat} element={<Chat />} />
+          <Route path={Path.Settings} element={<Settings />} />
+        </Routes>
       </div>
       </div>
     </div>
     </div>
   );
   );
 }
 }
 
 
 export function Home() {
 export function Home() {
+  const isMobileScreen = useMobileScreen();
+  useSwitchTheme();
+
+  if (!useHasHydrated()) {
+    return <Loading />;
+  }
+
   return (
   return (
     <ErrorBoundary>
     <ErrorBoundary>
-      <_Home></_Home>
+      <Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router>
     </ErrorBoundary>
     </ErrorBoundary>
   );
   );
 }
 }

+ 6 - 4
app/components/settings.tsx

@@ -29,10 +29,11 @@ import { Avatar } from "./chat";
 import Locale, { AllLangs, changeLang, getLang } from "../locales";
 import Locale, { AllLangs, changeLang, getLang } from "../locales";
 import { copyToClipboard, getEmojiUrl } from "../utils";
 import { copyToClipboard, getEmojiUrl } from "../utils";
 import Link from "next/link";
 import Link from "next/link";
-import { UPDATE_URL } from "../constant";
+import { Path, UPDATE_URL } from "../constant";
 import { Prompt, SearchService, usePromptStore } from "../store/prompt";
 import { Prompt, SearchService, usePromptStore } from "../store/prompt";
 import { ErrorBoundary } from "./error";
 import { ErrorBoundary } from "./error";
 import { InputRange } from "./input-range";
 import { InputRange } from "./input-range";
+import { useNavigate } from "react-router-dom";
 
 
 function UserPromptModal(props: { onClose?: () => void }) {
 function UserPromptModal(props: { onClose?: () => void }) {
   const promptStore = usePromptStore();
   const promptStore = usePromptStore();
@@ -176,7 +177,8 @@ function PasswordInput(props: HTMLProps<HTMLInputElement>) {
   );
   );
 }
 }
 
 
-export function Settings(props: { closeSettings: () => void }) {
+export function Settings() {
+  const navigate = useNavigate();
   const [showEmojiPicker, setShowEmojiPicker] = useState(false);
   const [showEmojiPicker, setShowEmojiPicker] = useState(false);
   const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
   const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
     useChatStore((state) => [
     useChatStore((state) => [
@@ -235,7 +237,7 @@ export function Settings(props: { closeSettings: () => void }) {
   useEffect(() => {
   useEffect(() => {
     const keydownEvent = (e: KeyboardEvent) => {
     const keydownEvent = (e: KeyboardEvent) => {
       if (e.key === "Escape") {
       if (e.key === "Escape") {
-        props.closeSettings();
+        navigate(Path.Home);
       }
       }
     };
     };
     document.addEventListener("keydown", keydownEvent);
     document.addEventListener("keydown", keydownEvent);
@@ -290,7 +292,7 @@ export function Settings(props: { closeSettings: () => void }) {
           <div className={styles["window-action-button"]}>
           <div className={styles["window-action-button"]}>
             <IconButton
             <IconButton
               icon={<CloseIcon />}
               icon={<CloseIcon />}
-              onClick={props.closeSettings}
+              onClick={() => navigate(Path.Home)}
               bordered
               bordered
               title={Locale.Settings.Actions.Close}
               title={Locale.Settings.Actions.Close}
             />
             />

+ 146 - 0
app/components/sidebar.tsx

@@ -0,0 +1,146 @@
+import { useState, useEffect, useRef } from "react";
+
+import styles from "./home.module.scss";
+
+import { IconButton } from "./button";
+import SettingsIcon from "../icons/settings.svg";
+import GithubIcon from "../icons/github.svg";
+import ChatGptIcon from "../icons/chatgpt.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import Locale from "../locales";
+
+import { useChatStore } from "../store";
+
+import {
+  MAX_SIDEBAR_WIDTH,
+  MIN_SIDEBAR_WIDTH,
+  NARROW_SIDEBAR_WIDTH,
+  Path,
+  REPO_URL,
+} from "../constant";
+
+import { HashRouter as Router, Link, useNavigate } from "react-router-dom";
+import { useMobileScreen } from "../utils";
+import { ChatList } from "./chat-list";
+
+function useDragSideBar() {
+  const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
+
+  const chatStore = useChatStore();
+  const startX = useRef(0);
+  const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
+  const lastUpdateTime = useRef(Date.now());
+
+  const handleMouseMove = useRef((e: MouseEvent) => {
+    if (Date.now() < lastUpdateTime.current + 50) {
+      return;
+    }
+    lastUpdateTime.current = Date.now();
+    const d = e.clientX - startX.current;
+    const nextWidth = limit(startDragWidth.current + d);
+    chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
+  });
+
+  const handleMouseUp = useRef(() => {
+    startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
+    window.removeEventListener("mousemove", handleMouseMove.current);
+    window.removeEventListener("mouseup", handleMouseUp.current);
+  });
+
+  const onDragMouseDown = (e: MouseEvent) => {
+    startX.current = e.clientX;
+
+    window.addEventListener("mousemove", handleMouseMove.current);
+    window.addEventListener("mouseup", handleMouseUp.current);
+  };
+  const isMobileScreen = useMobileScreen();
+  const shouldNarrow =
+    !isMobileScreen && chatStore.config.sidebarWidth < MIN_SIDEBAR_WIDTH;
+
+  useEffect(() => {
+    const barWidth = shouldNarrow
+      ? NARROW_SIDEBAR_WIDTH
+      : limit(chatStore.config.sidebarWidth ?? 300);
+    const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
+    document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
+  }, [chatStore.config.sidebarWidth, isMobileScreen, shouldNarrow]);
+
+  return {
+    onDragMouseDown,
+    shouldNarrow,
+  };
+}
+
+export function SideBar(props: { className?: string }) {
+  const chatStore = useChatStore();
+
+  // drag side bar
+  const { onDragMouseDown, shouldNarrow } = useDragSideBar();
+  const navigate = useNavigate();
+
+  return (
+    <div
+      className={`${styles.sidebar} ${props.className} ${
+        shouldNarrow && styles["narrow-sidebar"]
+      }`}
+    >
+      <div className={styles["sidebar-header"]}>
+        <div className={styles["sidebar-title"]}>ChatGPT Next</div>
+        <div className={styles["sidebar-sub-title"]}>
+          Build your own AI assistant.
+        </div>
+        <div className={styles["sidebar-logo"]}>
+          <ChatGptIcon />
+        </div>
+      </div>
+
+      <div
+        className={styles["sidebar-body"]}
+        onClick={(e) => {
+          if (e.target === e.currentTarget) {
+            navigate(Path.Home);
+          }
+        }}
+      >
+        <ChatList narrow={shouldNarrow} />
+      </div>
+
+      <div className={styles["sidebar-tail"]}>
+        <div className={styles["sidebar-actions"]}>
+          <div className={styles["sidebar-action"] + " " + styles.mobile}>
+            <IconButton
+              icon={<CloseIcon />}
+              onClick={chatStore.deleteSession}
+            />
+          </div>
+          <div className={styles["sidebar-action"]}>
+            <Link to={Path.Settings}>
+              <IconButton icon={<SettingsIcon />} shadow />
+            </Link>
+          </div>
+          <div className={styles["sidebar-action"]}>
+            <a href={REPO_URL} target="_blank">
+              <IconButton icon={<GithubIcon />} shadow />
+            </a>
+          </div>
+        </div>
+        <div>
+          <IconButton
+            icon={<AddIcon />}
+            text={shouldNarrow ? undefined : Locale.Home.NewChat}
+            onClick={() => {
+              chatStore.newSession();
+            }}
+            shadow
+          />
+        </div>
+      </div>
+
+      <div
+        className={styles["sidebar-drag"]}
+        onMouseDown={(e) => onDragMouseDown(e as any)}
+      ></div>
+    </div>
+  );
+}

+ 10 - 0
app/constant.ts

@@ -6,3 +6,13 @@ export const UPDATE_URL = `${REPO_URL}#keep-updated`;
 export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
 export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
+
+export enum Path {
+  Home = "/",
+  Chat = "/chat",
+  Settings = "/settings",
+}
+
+export const MAX_SIDEBAR_WIDTH = 500;
+export const MIN_SIDEBAR_WIDTH = 230;
+export const NARROW_SIDEBAR_WIDTH = 100;

File diff suppressed because it is too large
+ 22 - 0
app/icons/black-bot.svg


+ 32 - 6
app/requests.ts

@@ -1,5 +1,11 @@
 import type { ChatRequest, ChatResponse } from "./api/openai/typing";
 import type { ChatRequest, ChatResponse } from "./api/openai/typing";
-import { Message, ModelConfig, useAccessStore, useChatStore } from "./store";
+import {
+  Message,
+  ModelConfig,
+  ModelType,
+  useAccessStore,
+  useChatStore,
+} from "./store";
 import { showToast } from "./components/ui-lib";
 import { showToast } from "./components/ui-lib";
 
 
 const TIME_OUT_MS = 60000;
 const TIME_OUT_MS = 60000;
@@ -9,6 +15,7 @@ const makeRequestParam = (
   options?: {
   options?: {
     filterBot?: boolean;
     filterBot?: boolean;
     stream?: boolean;
     stream?: boolean;
+    model?: ModelType;
   },
   },
 ): ChatRequest => {
 ): ChatRequest => {
   let sendMessages = messages.map((v) => ({
   let sendMessages = messages.map((v) => ({
@@ -26,6 +33,11 @@ const makeRequestParam = (
   // @ts-expect-error
   // @ts-expect-error
   delete modelConfig.max_tokens;
   delete modelConfig.max_tokens;
 
 
+  // override model config
+  if (options?.model) {
+    modelConfig.model = options.model;
+  }
+
   return {
   return {
     messages: sendMessages,
     messages: sendMessages,
     stream: options?.stream,
     stream: options?.stream,
@@ -50,7 +62,7 @@ function getHeaders() {
 
 
 export function requestOpenaiClient(path: string) {
 export function requestOpenaiClient(path: string) {
   return (body: any, method = "POST") =>
   return (body: any, method = "POST") =>
-    fetch("/api/openai?_vercel_no_cache=1", {
+    fetch("/api/openai", {
       method,
       method,
       headers: {
       headers: {
         "Content-Type": "application/json",
         "Content-Type": "application/json",
@@ -61,8 +73,16 @@ export function requestOpenaiClient(path: string) {
     });
     });
 }
 }
 
 
-export async function requestChat(messages: Message[]) {
-  const req: ChatRequest = makeRequestParam(messages, { filterBot: true });
+export async function requestChat(
+  messages: Message[],
+  options?: {
+    model?: ModelType;
+  },
+) {
+  const req: ChatRequest = makeRequestParam(messages, {
+    filterBot: true,
+    model: options?.model,
+  });
 
 
   const res = await requestOpenaiClient("v1/chat/completions")(req);
   const res = await requestOpenaiClient("v1/chat/completions")(req);
 
 
@@ -204,7 +224,13 @@ export async function requestChatStream(
   }
   }
 }
 }
 
 
-export async function requestWithPrompt(messages: Message[], prompt: string) {
+export async function requestWithPrompt(
+  messages: Message[],
+  prompt: string,
+  options?: {
+    model?: ModelType;
+  },
+) {
   messages = messages.concat([
   messages = messages.concat([
     {
     {
       role: "user",
       role: "user",
@@ -213,7 +239,7 @@ export async function requestWithPrompt(messages: Message[], prompt: string) {
     },
     },
   ]);
   ]);
 
 
-  const res = await requestChat(messages);
+  const res = await requestChat(messages, options);
 
 
   return res?.choices?.at(0)?.message?.content ?? "";
   return res?.choices?.at(0)?.message?.content ?? "";
 }
 }

+ 15 - 11
app/store/app.ts

@@ -17,6 +17,7 @@ export type Message = ChatCompletionResponseMessage & {
   streaming?: boolean;
   streaming?: boolean;
   isError?: boolean;
   isError?: boolean;
   id?: number;
   id?: number;
+  model?: ModelType;
 };
 };
 
 
 export function createMessage(override: Partial<Message>): Message {
 export function createMessage(override: Partial<Message>): Message {
@@ -58,7 +59,7 @@ export interface ChatConfig {
   disablePromptHint: boolean;
   disablePromptHint: boolean;
 
 
   modelConfig: {
   modelConfig: {
-    model: string;
+    model: ModelType;
     temperature: number;
     temperature: number;
     max_tokens: number;
     max_tokens: number;
     presence_penalty: number;
     presence_penalty: number;
@@ -96,7 +97,9 @@ export const ALL_MODELS = [
     name: "gpt-3.5-turbo-0301",
     name: "gpt-3.5-turbo-0301",
     available: true,
     available: true,
   },
   },
-];
+] as const;
+
+export type ModelType = (typeof ALL_MODELS)[number]["name"];
 
 
 export function limitNumber(
 export function limitNumber(
   x: number,
   x: number,
@@ -119,7 +122,7 @@ export function limitModel(name: string) {
 
 
 export const ModalConfigValidator = {
 export const ModalConfigValidator = {
   model(x: string) {
   model(x: string) {
-    return limitModel(x);
+    return limitModel(x) as ModelType;
   },
   },
   max_tokens(x: number) {
   max_tokens(x: number) {
     return limitNumber(x, 0, 32000, 2000);
     return limitNumber(x, 0, 32000, 2000);
@@ -387,6 +390,7 @@ export const useChatStore = create<ChatStore>()(
           role: "assistant",
           role: "assistant",
           streaming: true,
           streaming: true,
           id: userMessage.id! + 1,
           id: userMessage.id! + 1,
+          model: get().config.modelConfig.model,
         });
         });
 
 
         // get recent messages
         // get recent messages
@@ -531,14 +535,14 @@ export const useChatStore = create<ChatStore>()(
           session.topic === DEFAULT_TOPIC &&
           session.topic === DEFAULT_TOPIC &&
           countMessages(session.messages) >= SUMMARIZE_MIN_LEN
           countMessages(session.messages) >= SUMMARIZE_MIN_LEN
         ) {
         ) {
-          requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then(
-            (res) => {
-              get().updateCurrentSession(
-                (session) =>
-                  (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
-              );
-            },
-          );
+          requestWithPrompt(session.messages, Locale.Store.Prompt.Topic, {
+            model: "gpt-3.5-turbo",
+          }).then((res) => {
+            get().updateCurrentSession(
+              (session) =>
+                (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
+            );
+          });
         }
         }
 
 
         const config = get().config;
         const config = get().config;

+ 21 - 0
app/utils.ts

@@ -1,4 +1,5 @@
 import { EmojiStyle } from "emoji-picker-react";
 import { EmojiStyle } from "emoji-picker-react";
+import { useEffect, useState } from "react";
 import { showToast } from "./components/ui-lib";
 import { showToast } from "./components/ui-lib";
 import Locale from "./locales";
 import Locale from "./locales";
 
 
@@ -47,7 +48,27 @@ export function isIOS() {
   return /iphone|ipad|ipod/.test(userAgent);
   return /iphone|ipad|ipod/.test(userAgent);
 }
 }
 
 
+export function useMobileScreen() {
+  const [isMobileScreen_, setIsMobileScreen] = useState(isMobileScreen());
+  useEffect(() => {
+    const onResize = () => {
+      setIsMobileScreen(isMobileScreen());
+    };
+
+    window.addEventListener("resize", onResize);
+
+    return () => {
+      window.removeEventListener("resize", onResize);
+    };
+  }, []);
+
+  return isMobileScreen_;
+}
+
 export function isMobileScreen() {
 export function isMobileScreen() {
+  if (typeof window === "undefined") {
+    return false;
+  }
   return window.innerWidth <= 600;
   return window.innerWidth <= 600;
 }
 }
 
 

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-markdown": "^8.0.5",
     "react-markdown": "^8.0.5",
+    "react-router-dom": "^6.10.0",
     "rehype-highlight": "^6.0.0",
     "rehype-highlight": "^6.0.0",
     "rehype-katex": "^6.0.2",
     "rehype-katex": "^6.0.2",
     "remark-breaks": "^3.0.2",
     "remark-breaks": "^3.0.2",

+ 20 - 0
yarn.lock

@@ -1189,6 +1189,11 @@
     tiny-glob "^0.2.9"
     tiny-glob "^0.2.9"
     tslib "^2.4.0"
     tslib "^2.4.0"
 
 
+"@remix-run/router@1.5.0":
+  version "1.5.0"
+  resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.5.0.tgz#57618e57942a5f0131374a9fdb0167e25a117fdc"
+  integrity sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg==
+
 "@rushstack/eslint-patch@^1.1.3":
 "@rushstack/eslint-patch@^1.1.3":
   version "1.2.0"
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
   resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
@@ -4296,6 +4301,21 @@ react-redux@^8.0.4:
     react-is "^18.0.0"
     react-is "^18.0.0"
     use-sync-external-store "^1.0.0"
     use-sync-external-store "^1.0.0"
 
 
+react-router-dom@^6.10.0:
+  version "6.10.0"
+  resolved "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.10.0.tgz#090ddc5c84dc41b583ce08468c4007c84245f61f"
+  integrity sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg==
+  dependencies:
+    "@remix-run/router" "1.5.0"
+    react-router "6.10.0"
+
+react-router@6.10.0:
+  version "6.10.0"
+  resolved "https://registry.npmmirror.com/react-router/-/react-router-6.10.0.tgz#230f824fde9dd0270781b5cb497912de32c0a971"
+  integrity sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ==
+  dependencies:
+    "@remix-run/router" "1.5.0"
+
 react@^18.2.0:
 react@^18.2.0:
   version "18.2.0"
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
   resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"

Some files were not shown because too many files changed in this diff