This commit is contained in:
GH Action - Upstream Sync 2023-04-21 00:57:30 +00:00
commit 8a92904287
15 changed files with 489 additions and 246 deletions

View File

@ -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#开发)

View File

@ -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 服务器。

View File

@ -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> {props.narrow ? (
<div className={styles["chat-item-info"]}> <div className={styles["chat-item-narrow"]}>{props.count}</div>
<div className={styles["chat-item-count"]}> ) : (
{Locale.ChatItem.ChatItemCount(props.count)} <>
</div> <div className={styles["chat-item-title"]}>{props.title}</div>
<div className={styles["chat-item-date"]}>{props.time}</div> <div className={styles["chat-item-info"]}>
</div> <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)} onClick={() => {
onDelete={() => chatStore.deleteSession(i)} navigate(Path.Chat);
selectSession(i);
}}
onDelete={() => {
if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
chatStore.deleteSession(i);
}
}}
narrow={props.narrow}
/> />
))} ))}
{provided.placeholder} {provided.placeholder}

View File

@ -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: { export function Chat() {
showSideBar?: () => void;
sideBarShowing?: boolean;
}) {
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 && {showActions && (
!(message.preview || message.content.length === 0) && ( <div className={styles["chat-message-top-actions"]}>
<div className={styles["chat-message-top-actions"]}> {message.streaming ? (
{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>
</>
)}
<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

View File

@ -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;

View File

@ -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 { getCSSVar, useMobileScreen } from "../utils";
import Locale from "../locales";
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() { function WideScreen() {
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);
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() config.tightBorder ? styles["tight-container"] : styles.container
? styles["tight-container"]
: styles.container
}`} }`}
> >
<div <SideBar />
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>
<div
className={styles["sidebar-body"]}
onClick={() => {
setOpenSettings(false);
setShowSideBar(false);
}}
>
<ChatList />
</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>
<div
className={styles["sidebar-drag"]}
onMouseDown={(e) => onDragMouseDown(e as any)}
></div>
</div>
<div className={styles["window-content"]}> <div className={styles["window-content"]}>
{openSettings ? ( <Routes>
<Settings <Route path={Path.Home} element={<Chat />} />
closeSettings={() => { <Route path={Path.Chat} element={<Chat />} />
setOpenSettings(false); <Route path={Path.Settings} element={<Settings />} />
setShowSideBar(true); </Routes>
}} </div>
/> </div>
) : ( );
<Chat }
key="chat"
showSideBar={() => setShowSideBar(true)} function MobileScreen() {
sideBarShowing={showSideBar} const location = useLocation();
/> const isHome = location.pathname === Path.Home;
)}
return (
<div className={styles.container}>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]}>
<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>
); );
} }

View File

@ -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
app/components/sidebar.tsx Normal file
View File

@ -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>
);
}

View File

@ -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;

28
app/icons/black-bot.svg Normal file
View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="30"
height="30" viewBox="0 0 30 30" fill="none">
<defs>
<rect id="path_0" x="0" y="0" width="29.999999999999996" height="29.999999999999996" />
<rect id="path_1" x="0" y="0" width="20.45454545454545" height="20.45454545454545" />
</defs>
<g opacity="1" transform="translate(0 0) rotate(0 14.999999999999998 14.999999999999998)">
<rect fill="#E7F8FF" opacity="1"
transform="translate(0 0) rotate(0 14.999999999999998 14.999999999999998)" x="0" y="0"
width="29.999999999999996" height="29.999999999999996" rx="10" />
<mask id="bg-mask-0" fill="white">
<use xlink:href="#path_0"></use>
</mask>
<g mask="url(#bg-mask-0)">
<g opacity="1"
transform="translate(4.772727272727272 4.772727272727273) rotate(0 10.227272727272725 10.227272727272725)">
<mask id="bg-mask-1" fill="white">
<use xlink:href="#path_1"></use>
</mask>
<g mask="url(#bg-mask-1)">
<path id="分组 1" fill-rule="evenodd" style="fill:#000000"
transform="translate(0 0) rotate(0 10.227272727272725 10.227272727272725)" opacity="1"
d="M19.11 8.37L19.11 8.37C19.28 7.85 19.37 7.31 19.37 6.76C19.37 5.86 19.13 4.97 18.66 4.19C17.73 2.59 16 1.6 14.13 1.6C13.76 1.6 13.4 1.64 13.04 1.71C12.06 0.62 10.65 0 9.17 0L9.14 0L9.13 0C6.86 0 4.86 1.44 4.16 3.57C2.7 3.86 1.44 4.76 0.71 6.04C0.24 6.83 0 7.72 0 8.63C0 9.9 0.48 11.14 1.35 12.08C1.17 12.6 1.08 13.15 1.08 13.69C1.08 14.6 1.33 15.49 1.79 16.27C2.92 18.21 5.2 19.21 7.42 18.74C8.4 19.83 9.8 20.45 11.28 20.45L11.31 20.45L11.33 20.45C13.59 20.45 15.6 19.01 16.3 16.88C17.76 16.59 19.01 15.69 19.75 14.41C20.21 13.63 20.45 12.74 20.45 11.83C20.45 10.55 19.97 9.32 19.11 8.37Z M8.94734 18.1579C8.90734 18.1879 8.86734 18.2079 8.82734 18.2279C9.52734 18.8079 10.3973 19.1179 11.3073 19.1179L11.3173 19.1179C13.4573 19.1179 15.1973 17.3979 15.1973 15.2879L15.1973 10.5279C15.1973 10.5079 15.1773 10.4879 15.1573 10.4779L13.4173 9.48792L13.4173 15.2379C13.4173 15.4679 13.2873 15.6879 13.0773 15.8079L8.94734 18.1579Z M8.27654 17.0048L12.4465 14.6248C12.4665 14.6148 12.4765 14.5948 12.4765 14.5748L12.4765 14.5748L12.4765 12.5848L7.43654 15.4548C7.22654 15.5748 6.96654 15.5748 6.75654 15.4548L2.62654 13.1048C2.58654 13.0848 2.53654 13.0448 2.50654 13.0348C2.46654 13.2448 2.44654 13.4648 2.44654 13.6848C2.44654 14.3548 2.62654 15.0148 2.96654 15.6048L2.96654 15.5948C3.66654 16.7848 4.94654 17.5148 6.33654 17.5148C7.01654 17.5148 7.68654 17.3348 8.27654 17.0048Z M3.90324 5.16818C3.90324 5.12818 3.90324 5.06818 3.90324 5.02818C3.05324 5.33818 2.33324 5.92818 1.88324 6.70818L1.88324 6.70818C1.54324 7.28818 1.36324 7.94818 1.36324 8.61818C1.36324 9.98818 2.10324 11.2582 3.30324 11.9482L7.47324 14.3182C7.49324 14.3282 7.51324 14.3282 7.53324 14.3182L9.28324 13.3182L4.24324 10.4482C4.03324 10.3382 3.90324 10.1182 3.90324 9.87818L3.90324 9.87818L3.90324 5.16818Z M17.1561 8.50521L12.9761 6.1252C12.9561 6.1252 12.9361 6.1252 12.9161 6.1352L11.1761 7.1252L16.2161 9.9952C16.4261 10.1152 16.5561 10.3352 16.5561 10.5752C16.5561 10.5752 16.5561 10.5752 16.5561 10.5752L16.5561 15.4252C18.0761 14.8652 19.0961 13.4352 19.0961 11.8252C19.0961 10.4552 18.3561 9.1952 17.1561 8.50521Z M8.01418 5.82927C7.99418 5.83927 7.98418 5.85927 7.98418 5.87927L7.98418 5.87927L7.98418 7.86927L13.0242 4.99927C13.1242 4.93927 13.2442 4.90927 13.3642 4.90927C13.4842 4.90927 13.5942 4.93927 13.7042 4.99927L17.8342 7.34927C17.8742 7.36927 17.9142 7.39927 17.9542 7.41927L17.9542 7.41927C17.9842 7.20927 18.0042 6.98927 18.0042 6.76927C18.0042 4.65927 16.2642 2.93927 14.1242 2.93927C13.4442 2.93927 12.7742 3.11927 12.1842 3.44927L8.01418 5.82927Z M9.14676 1.33731C6.99676 1.33731 5.25676 3.05731 5.25676 5.16731L5.25676 9.92731C5.25676 9.94731 5.27676 9.95731 5.28676 9.96731L7.03676 10.9673L7.03676 5.22731L7.03676 5.21731C7.03676 4.98731 7.16676 4.76731 7.37676 4.64731L11.5068 2.29731C11.5468 2.26731 11.5968 2.23731 11.6268 2.22731C10.9268 1.64731 10.0468 1.33731 9.14676 1.33731Z M7.98345 11.5093L10.2235 12.7793L12.4735 11.5093L12.4735 8.9493L10.2235 7.6693L7.98345 8.9493L7.98345 11.5093Z " />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -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[]) { export async function requestChat(
const req: ChatRequest = makeRequestParam(messages, { filterBot: true }); 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 ?? "";
} }

View File

@ -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( requestWithPrompt(session.messages, Locale.Store.Prompt.Topic, {
(res) => { model: "gpt-3.5-turbo",
get().updateCurrentSession( }).then((res) => {
(session) => get().updateCurrentSession(
(session.topic = res ? trimTopic(res) : DEFAULT_TOPIC), (session) =>
); (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
}, );
); });
} }
const config = get().config; const config = get().config;

View File

@ -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;
} }

View File

@ -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",

View File

@ -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"