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

This commit is contained in:
Duang Cheng 2023-04-25 12:40:27 +08:00
commit 3e904f3fcf
31 changed files with 749 additions and 477 deletions

View File

@ -102,7 +102,7 @@ We recommend that you follow the steps below to re-deploy:
### Enable Automatic Updates
After forking the project, due to the limitations imposed by Github, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour:
![Automatic Updates](./docs/images/enable-actions.jpg)
@ -110,7 +110,7 @@ After forking the project, due to the limitations imposed by Github, you need to
### Manually Updating Code
If you want to update instantly, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
You can star or watch this project or follow author to get release notifictions in time.
@ -146,6 +146,10 @@ Access passsword, separated by comma.
Override openai api request base url.
### `OPENAI_ORG_ID` (optional)
Specify OpenAI organization ID.
## Development
> [简体中文 > 如何进行二次开发](./README_CN.md#开发)

View File

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

View File

@ -59,6 +59,4 @@ export async function POST(req: NextRequest) {
}
}
export const config = {
runtime: "edge",
};
export const runtime = "experimental-edge";

View File

@ -24,11 +24,18 @@ export async function requestOpenai(req: NextRequest) {
console.log("[Proxy] ", openaiPath);
console.log("[Base Url]", baseUrl);
if (process.env.OPENAI_ORG_ID) {
console.log("[Org ID]", process.env.OPENAI_ORG_ID);
}
return fetch(`${baseUrl}/${openaiPath}`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"api-key": apiKey || "",
...(process.env.OPENAI_ORG_ID && {
"OpenAI-Organization": process.env.OPENAI_ORG_ID,
}),
},
method: req.method,
body: req.body,

View File

@ -17,7 +17,7 @@ async function makeRequest(req: NextRequest) {
},
{
status: 500,
}
},
);
}
}
@ -30,6 +30,4 @@ export async function GET(req: NextRequest) {
return makeRequest(req);
}
export const config = {
runtime: "edge",
};
export const runtime = "experimental-edge";

View File

@ -10,7 +10,8 @@ import {
import { useChatStore } from "../store";
import Locale from "../locales";
import { isMobileScreen } from "../utils";
import { Link, useNavigate } from "react-router-dom";
import { Path } from "../constant";
export function ChatItem(props: {
onClick?: () => void;
@ -21,6 +22,7 @@ export function ChatItem(props: {
selected: boolean;
id: number;
index: number;
narrow?: boolean;
}) {
return (
<Draggable draggableId={`${props.id}`} index={props.index}>
@ -34,13 +36,20 @@ export function ChatItem(props: {
{...provided.draggableProps}
{...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}>
<DeleteIcon />
</div>
@ -50,7 +59,7 @@ export function ChatItem(props: {
);
}
export function ChatList() {
export function ChatList(props: { narrow?: boolean }) {
const [sessions, selectedIndex, selectSession, removeSession, moveSession] =
useChatStore((state) => [
state.sessions,
@ -60,6 +69,7 @@ export function ChatList() {
state.moveSession,
]);
const chatStore = useChatStore();
const navigate = useNavigate();
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
@ -95,8 +105,16 @@ export function ChatList() {
id={item.id}
index={i}
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}

View File

@ -10,6 +10,7 @@ import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import LoadingIcon from "../icons/three-dots.svg";
import BotIcon from "../icons/bot.svg";
import BlackBotIcon from "../icons/black-bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import MaxIcon from "../icons/max.svg";
@ -30,15 +31,17 @@ import {
createMessage,
useAccessStore,
Theme,
ModelType,
useAppConfig,
} from "../store";
import {
copyToClipboard,
downloadAs,
getEmojiUrl,
isMobileScreen,
selectOrCopy,
autoGrowTextArea,
useMobileScreen,
} from "../utils";
import dynamic from "next/dynamic";
@ -52,6 +55,8 @@ import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { Input, Modal, showModal } from "./ui-lib";
import { useNavigate } from "react-router-dom";
import { Path } from "../constant";
const Markdown = dynamic(
async () => memo((await import("./markdown")).Markdown),
@ -64,13 +69,17 @@ const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
});
export function Avatar(props: { role: Message["role"] }) {
const config = useChatStore((state) => state.config);
export function Avatar(props: { role: Message["role"]; model?: ModelType }) {
const config = useAppConfig();
if (props.role !== "user") {
return (
<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>
);
}
@ -277,7 +286,7 @@ function PromptToast(props: {
}
function useSubmitHandler() {
const config = useChatStore((state) => state.config);
const config = useAppConfig();
const submitKey = config.submitKey;
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -353,16 +362,16 @@ export function ChatActions(props: {
scrollToBottom: () => void;
hitBottom: boolean;
}) {
const chatStore = useChatStore();
const config = useAppConfig();
// switch themes
const theme = chatStore.config.theme;
const theme = config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
chatStore.updateConfig((config) => (config.theme = nextTheme));
config.update((config) => (config.theme = nextTheme));
}
// stop all responses
@ -412,10 +421,7 @@ export function ChatActions(props: {
);
}
export function Chat(props: {
showSideBar?: () => void;
sideBarShowing?: boolean;
}) {
export function Chat() {
type RenderMessage = Message & { preview?: boolean };
const chatStore = useChatStore();
@ -423,7 +429,8 @@ export function Chat(props: {
state.currentSession(),
state.currentSessionIndex,
]);
const fontSize = useChatStore((state) => state.config.fontSize);
const config = useAppConfig();
const fontSize = config.fontSize;
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
@ -432,6 +439,8 @@ export function Chat(props: {
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
const [hitBottom, setHitBottom] = useState(false);
const isMobileScreen = useMobileScreen();
const navigate = useNavigate();
const onChatBodyScroll = (e: HTMLElement) => {
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
@ -462,7 +471,7 @@ export function Chat(props: {
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
const inputRows = Math.min(
5,
Math.max(2 + Number(!isMobileScreen()), rows),
Math.max(2 + Number(!isMobileScreen), rows),
);
setInputRows(inputRows);
},
@ -485,7 +494,7 @@ export function Chat(props: {
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
@ -502,7 +511,7 @@ export function Chat(props: {
setBeforeInput(userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen()) inputRef.current?.focus();
if (!isMobileScreen) inputRef.current?.focus();
setAutoScroll(true);
};
@ -536,7 +545,7 @@ export function Chat(props: {
}
};
const findLastUesrIndex = (messageId: number) => {
const findLastUserIndex = (messageId: number) => {
// find last user input message and resend
let lastUserMessageIndex: number | null = null;
for (let i = 0; i < session.messages.length; i += 1) {
@ -559,14 +568,14 @@ export function Chat(props: {
};
const onDelete = (botMessageId: number) => {
const userIndex = findLastUesrIndex(botMessageId);
const userIndex = findLastUserIndex(botMessageId);
if (userIndex === null) return;
deleteMessage(userIndex);
};
const onResend = (botMessageId: number) => {
// find last user input message and resend
const userIndex = findLastUesrIndex(botMessageId);
const userIndex = findLastUserIndex(botMessageId);
if (userIndex === null) return;
setIsLoading(true);
@ -576,8 +585,6 @@ export function Chat(props: {
inputRef.current?.focus();
};
const config = useChatStore((state) => state.config);
const context: RenderMessage[] = session.context.slice();
const accessStore = useAccessStore();
@ -634,7 +641,7 @@ export function Chat(props: {
// Auto focus
useEffect(() => {
if (props.sideBarShowing && isMobileScreen()) return;
if (isMobileScreen) return;
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -659,7 +666,7 @@ export function Chat(props: {
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar}
onClick={() => navigate(Path.Home)}
/>
</div>
<div className={styles["window-action-button"]}>
@ -682,13 +689,13 @@ export function Chat(props: {
}}
/>
</div>
{!isMobileScreen() && (
{!isMobileScreen && (
<div className={styles["window-action-button"]}>
<IconButton
icon={chatStore.config.tightBorder ? <MinIcon /> : <MaxIcon />}
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
chatStore.updateConfig(
config.update(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
@ -717,6 +724,11 @@ export function Chat(props: {
>
{messages.map((message, i) => {
const isUser = message.role === "user";
const showActions =
!isUser &&
i > 0 &&
!(message.preview || message.content.length === 0);
const showTyping = message.preview || message.streaming;
return (
<div
@ -727,49 +739,48 @@ export function Chat(props: {
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-avatar"]}>
<Avatar role={message.role} />
<Avatar role={message.role} model={message.model} />
</div>
{(message.preview || message.streaming) && (
{showTyping && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
<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
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
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>
)}
<Markdown
content={message.content}
loading={
@ -778,7 +789,7 @@ export function Chat(props: {
}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen()) return;
if (!isMobileScreen) return;
setUserInput(message.content);
}}
fontSize={fontSize}
@ -819,7 +830,7 @@ export function Chat(props: {
setAutoScroll(false);
setTimeout(() => setPromptHints([]), 500);
}}
autoFocus={!props?.sideBarShowing}
autoFocus
rows={inputRows}
/>
<IconButton

View File

@ -50,7 +50,7 @@
flex-direction: column;
box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
position: relative;
transition: width ease 0.1s;
transition: width ease 0.05s;
}
.sidebar-drag {
@ -126,11 +126,13 @@
.sidebar-title {
font-size: 20px;
font-weight: bold;
animation: slide-in ease 0.3s;
}
.sidebar-sub-title {
font-size: 12px;
font-weight: 400px;
animation: slide-in ease 0.3s;
}
.sidebar-body {
@ -171,6 +173,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
animation: slide-in ease 0.3s;
}
.chat-item-delete {
@ -197,6 +200,7 @@
color: rgb(166, 166, 166);
font-size: 12px;
margin-top: 8px;
animation: slide-in ease 0.3s;
}
.chat-item-count,
@ -206,6 +210,69 @@
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 {
display: flex;
justify-content: space-between;
@ -246,6 +313,10 @@
.chat-message {
display: flex;
flex-direction: row;
&:last-child {
animation: slide-in ease 0.3s;
}
}
.chat-message-user {
@ -258,7 +329,6 @@
display: flex;
flex-direction: column;
align-items: flex-start;
animation: slide-in ease 0.3s;
&:hover {
.chat-message-top-actions {

View File

@ -2,32 +2,32 @@
require("../polyfill");
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, StyleHTMLAttributes } from "react";
import { IconButton } from "./button";
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 AddIcon from "../icons/add.svg";
import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store";
import { getCSSVar, isMobileScreen } from "../utils";
import Locale from "../locales";
import { getCSSVar, useMobileScreen } from "../utils";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
import { REPO_URL } from "../constant";
import { Path } from "../constant";
import { ErrorBoundary } from "./error";
import {
HashRouter as Router,
Routes,
Route,
useLocation,
} from "react-router-dom";
import { SideBar } from "./sidebar";
import { useAppConfig } from "../store/config";
export function Loading(props: { noLogo?: boolean }) {
return (
<div className={styles["loading-content"]}>
<div className={styles["loading-content"] + " no-dark"}>
{!props.noLogo && <BotIcon />}
<LoadingIcon />
</div>
@ -38,12 +38,8 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => <Loading noLogo />,
});
function useSwitchTheme() {
const config = useChatStore((state) => state.config);
export function useSwitchTheme() {
const config = useAppConfig();
useEffect(() => {
document.body.classList.remove("light");
@ -73,53 +69,6 @@ function useSwitchTheme() {
}, [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 [hasHydrated, setHasHydrated] = useState<boolean>(false);
@ -130,129 +79,58 @@ const useHasHydrated = () => {
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);
const config = useChatStore((state) => state.config);
// drag side bar
const { onDragMouseDown } = useDragSideBar();
useSwitchTheme();
if (loading) {
return <Loading />;
}
function WideScreen() {
const config = useAppConfig();
return (
<div
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>
<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>
<SideBar />
<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={<Chat />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
</div>
);
}
function MobileScreen() {
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>
);
}
export function Home() {
const isMobileScreen = useMobileScreen();
useSwitchTheme();
if (!useHasHydrated()) {
return <Loading />;
}
return (
<ErrorBoundary>
<_Home></_Home>
<Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router>
</ErrorBoundary>
);
}

View File

@ -23,16 +23,18 @@ import {
useUpdateStore,
useAccessStore,
ModalConfigValidator,
useAppConfig,
} from "../store";
import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { copyToClipboard, getEmojiUrl } from "../utils";
import Link from "next/link";
import { UPDATE_URL } from "../constant";
import { Path, UPDATE_URL } from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range";
import { useNavigate } from "react-router-dom";
function UserPromptModal(props: { onClose?: () => void }) {
const promptStore = usePromptStore();
@ -176,16 +178,16 @@ function PasswordInput(props: HTMLProps<HTMLInputElement>) {
);
}
export function Settings(props: { closeSettings: () => void }) {
export function Settings() {
const navigate = useNavigate();
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
useChatStore((state) => [
state.config,
state.updateConfig,
state.resetConfig,
state.clearAllData,
state.clearSessions,
]);
const config = useAppConfig();
const updateConfig = config.update;
const resetConfig = config.reset;
const [clearAllData, clearSessions] = useChatStore((state) => [
state.clearAllData,
state.clearSessions,
]);
const updateStore = useUpdateStore();
const [checkingUpdate, setCheckingUpdate] = useState(false);
@ -235,7 +237,7 @@ export function Settings(props: { closeSettings: () => void }) {
useEffect(() => {
const keydownEvent = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.closeSettings();
navigate(Path.Home);
}
};
document.addEventListener("keydown", keydownEvent);
@ -290,7 +292,7 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}>
<IconButton
icon={<CloseIcon />}
onClick={props.closeSettings}
onClick={() => navigate(Path.Home)}
bordered
title={Locale.Settings.Actions.Close}
/>
@ -671,7 +673,7 @@ export function Settings(props: { closeSettings: () => void }) {
value={config.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.5"
step="0.1"
onChange={(e) => {
updateConfig(
(config) =>

150
app/components/sidebar.tsx Normal file
View File

@ -0,0 +1,150 @@
import { 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 { useAppConfig, useChatStore } from "../store";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
NARROW_SIDEBAR_WIDTH,
Path,
REPO_URL,
} from "../constant";
import { Link, useNavigate } from "react-router-dom";
import { useMobileScreen } from "../utils";
import dynamic from "next/dynamic";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null,
});
function useDragSideBar() {
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
const config = useAppConfig();
const startX = useRef(0);
const startDragWidth = useRef(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);
config.update((config) => (config.sidebarWidth = nextWidth));
});
const handleMouseUp = useRef(() => {
startDragWidth.current = 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 && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
useEffect(() => {
const barWidth = shouldNarrow
? NARROW_SIDEBAR_WIDTH
: limit(config.sidebarWidth ?? 300);
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
}, [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

@ -2,7 +2,7 @@ import styles from "./ui-lib.module.scss";
import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { createRoot } from "react-dom/client";
import React from "react";
import React, { useEffect } from "react";
export function Popover(props: {
children: JSX.Element;
@ -64,6 +64,21 @@ interface ModalProps {
onClose?: () => void;
}
export function Modal(props: ModalProps) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose?.();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles["modal-container"]}>
<div className={styles["modal-header"]}>

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_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
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,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
const cn = {
WIP: "该功能仍在开发中……",

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const de: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const en: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const es: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const it: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
const jp = {
WIP: "この機能は開発中です……",

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const tr: LocaleType = {

View File

@ -1,4 +1,4 @@
import { SubmitKey } from "../store/app";
import { SubmitKey } from "../store/config";
import type { LocaleType } from "./index";
const tw: LocaleType = {
@ -12,7 +12,7 @@ const tw: LocaleType = {
Chat: {
SubTitle: (count: number) => `您已經與 ChatGPT 進行了 ${count} 條對話`,
Actions: {
ChatList: "查看息列表",
ChatList: "查看息列表",
CompressedHistory: "查看壓縮後的歷史 Prompt",
Export: "匯出聊天紀錄",
Copy: "複製",
@ -32,10 +32,10 @@ const tw: LocaleType = {
Send: "發送",
},
Export: {
Title: "匯出聊天記錄為 Markdown",
Title: "將聊天記錄匯出為 Markdown",
Copy: "複製全部",
Download: "下載檔案",
MessageFromYou: "來自的訊息",
MessageFromYou: "來自的訊息",
MessageFromChatGPT: "來自 ChatGPT 的訊息",
},
Memory: {
@ -43,8 +43,8 @@ const tw: LocaleType = {
EmptyContent: "尚未記憶",
Copy: "複製全部",
Send: "發送記憶",
Reset: "重對話",
ResetConfirm: "重置後將清空當前對話記錄以及歷史記憶,確認重置",
Reset: "重對話",
ResetConfirm: "重設後將清除目前對話記錄以及歷史記憶,確認重設",
},
Home: {
NewChat: "新的對話",
@ -56,18 +56,18 @@ const tw: LocaleType = {
Title: "設定",
SubTitle: "設定選項",
Actions: {
ClearAll: "清除所有數據",
ResetAll: "重所有設定",
ClearAll: "清除所有資料",
ResetAll: "重所有設定",
Close: "關閉",
ConfirmResetAll: {
Confirm: "Are you sure you want to reset all configurations?",
Confirm: "您確定要重設所有設定嗎?",
},
ConfirmClearAll: {
Confirm: "Are you sure you want to reset all chat?",
Confirm: "您確定要清除所有聊天嗎?",
},
},
Lang: {
Name: "Language",
Name: "語言",
Options: {
cn: "简体中文",
en: "English",
@ -98,16 +98,16 @@ const tw: LocaleType = {
SendPreviewBubble: "發送預覽氣泡",
Prompt: {
Disable: {
Title: "停用提示詞自動補",
SubTitle: "在輸入框開頭輸入 / 即可觸發自動補",
Title: "停用提示詞自動補",
SubTitle: "在輸入框開頭輸入 / 即可觸發自動補",
},
List: "自定義提示詞列表",
ListCount: (builtin: number, custom: number) =>
` ${builtin} 條,用戶定義 ${custom}`,
` ${builtin} 條,用戶定義 ${custom}`,
Edit: "編輯",
Modal: {
Title: "提示詞列表",
Add: "一條",
Add: "增一條",
Search: "搜尋提示詞",
},
},
@ -121,7 +121,7 @@ const tw: LocaleType = {
},
Token: {
Title: "API Key",
SubTitle: "使用自己的 Key 可規避授權訪問限制",
SubTitle: "使用自己的 Key 可規避授權存取限制",
Placeholder: "OpenAI API Key",
},
EnableAOAI: "使用 Azure OpenAI",
@ -133,7 +133,7 @@ const tw: LocaleType = {
Usage: {
Title: "帳戶餘額",
SubTitle(used: any, total: any) {
return `本月已使用 $${used}订阅总额 $${total}`;
return `本月已使用 $${used}訂閱總額 $${total}`;
},
IsChecking: "正在檢查…",
Check: "重新檢查",
@ -141,17 +141,17 @@ const tw: LocaleType = {
},
AccessCode: {
Title: "授權碼",
SubTitle: "現在是未授權訪問狀態",
SubTitle: "目前是未授權存取狀態",
Placeholder: "請輸入授權碼",
},
Model: "模型 (model)",
Temperature: {
Title: "隨機性 (temperature)",
SubTitle: "值越大,回越隨機",
SubTitle: "值越大,回越隨機",
},
MaxTokens: {
Title: "單次回限制 (max_tokens)",
SubTitle: "單次互所用的最大 Token 數",
Title: "單次回限制 (max_tokens)",
SubTitle: "單次所用的最大 Token 數",
},
PresencePenlty: {
Title: "話題新穎度 (presence_penalty)",
@ -170,16 +170,16 @@ const tw: LocaleType = {
Summarize:
"Use the language used by the user (e.g. en-us for english conversation, zh-hant for chinese conversation, etc.) to summarise the conversation in at most 200 words. The summary will be used as prompt for you to continue the conversation in the future.",
},
ConfirmClearAll: "確認清除所有對話、設定數據",
ConfirmClearAll: "確認清除所有對話、設定",
},
Copy: {
Success: "已複製到剪貼簿中",
Failed: "複製失敗,請賦予剪貼簿權限",
},
Context: {
Toast: (x: any) => `已設 ${x} 條前置上下文`,
Toast: (x: any) => `已設 ${x} 條前置上下文`,
Edit: "前置上下文和歷史記憶",
Add: "新增條",
Add: "新增條",
},
};

View File

@ -1,5 +1,12 @@
import type { ChatRequest, ChatResponse } from "./api/openai/typing";
import { Message, ModelConfig, useAccessStore, useChatStore } from "./store";
import {
Message,
ModelConfig,
ModelType,
useAccessStore,
useAppConfig,
useChatStore,
} from "./store";
import { showToast } from "./components/ui-lib";
const TIME_OUT_MS = 60000;
@ -9,6 +16,7 @@ const makeRequestParam = (
options?: {
filterBot?: boolean;
stream?: boolean;
model?: ModelType;
},
): ChatRequest => {
let sendMessages = messages.map((v) => ({
@ -20,12 +28,17 @@ const makeRequestParam = (
sendMessages = sendMessages.filter((m) => m.role !== "assistant");
}
const modelConfig = { ...useChatStore.getState().config.modelConfig };
const modelConfig = { ...useAppConfig.getState().modelConfig };
// @yidadaa: wont send max_tokens, because it is nonsense for Muggles
// @ts-expect-error
delete modelConfig.max_tokens;
// override model config
if (options?.model) {
modelConfig.model = options.model;
}
return {
messages: sendMessages,
stream: options?.stream,
@ -60,7 +73,7 @@ function getRequestPath() {
export function requestOpenaiClient(path: string) {
return (body: any, method = "POST") =>
fetch("/api/openai?_vercel_no_cache=1", {
fetch("/api/openai", {
method,
headers: {
"Content-Type": "application/json",
@ -71,8 +84,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(getRequestPath())(req);
@ -139,6 +160,7 @@ export async function requestChatStream(
options?: {
filterBot?: boolean;
modelConfig?: ModelConfig;
model?: ModelType;
onMessage: (message: string, done: boolean) => void;
onError: (error: Error, statusCode?: number) => void;
onController?: (controller: AbortController) => void;
@ -147,6 +169,7 @@ export async function requestChatStream(
const req = makeRequestParam(messages, {
stream: true,
filterBot: options?.filterBot,
model: options?.model,
});
console.log("[Request] ", req);
@ -214,7 +237,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([
{
role: "user",
@ -223,7 +252,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 ?? "";
}

View File

@ -11,12 +11,14 @@ import { isMobileScreen, trimTopic } from "../utils";
import Locale from "../locales";
import { showToast } from "../components/ui-lib";
import { ModelType, useAppConfig } from "./config";
export type Message = ChatCompletionResponseMessage & {
date: string;
streaming?: boolean;
isError?: boolean;
id?: number;
model?: ModelType;
};
export function createMessage(override: Partial<Message>): Message {
@ -29,138 +31,8 @@ export function createMessage(override: Partial<Message>): Message {
};
}
export enum SubmitKey {
Enter = "Enter",
CtrlEnter = "Ctrl + Enter",
ShiftEnter = "Shift + Enter",
AltEnter = "Alt + Enter",
MetaEnter = "Meta + Enter",
}
export enum Theme {
Auto = "auto",
Dark = "dark",
Light = "light",
}
export interface ChatConfig {
historyMessageCount: number; // -1 means all
compressMessageLengthThreshold: number;
sendBotMessages: boolean; // send bot's message or not
submitKey: SubmitKey;
avatar: string;
fontSize: number;
theme: Theme;
tightBorder: boolean;
sendPreviewBubble: boolean;
sidebarWidth: number;
disablePromptHint: boolean;
modelConfig: {
model: string;
temperature: number;
max_tokens: number;
presence_penalty: number;
};
}
export type ModelConfig = ChatConfig["modelConfig"];
export const ROLES: Message["role"][] = ["system", "user", "assistant"];
const ENABLE_GPT4 = true;
export const AZURE_API_VERSION = [
{
name: "2023-03-15-preview",
available: true,
},
];
export const ALL_MODELS = [
{
name: "gpt-4",
available: ENABLE_GPT4,
},
{
name: "gpt-4-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-3.5-turbo",
available: true,
},
{
name: "gpt-3.5-turbo-0301",
available: true,
},
];
export function limitNumber(
x: number,
min: number,
max: number,
defaultValue: number,
) {
if (typeof x !== "number" || isNaN(x)) {
return defaultValue;
}
return Math.min(max, Math.max(min, x));
}
export function limitModel(name: string) {
return ALL_MODELS.some((m) => m.name === name && m.available)
? name
: ALL_MODELS[4].name;
}
export const ModalConfigValidator = {
model(x: string) {
return limitModel(x);
},
max_tokens(x: number) {
return limitNumber(x, 0, 32000, 2000);
},
presence_penalty(x: number) {
return limitNumber(x, -2, 2, 0);
},
temperature(x: number) {
return limitNumber(x, 0, 2, 1);
},
};
const DEFAULT_CONFIG: ChatConfig = {
historyMessageCount: 4,
compressMessageLengthThreshold: 1000,
sendBotMessages: true as boolean,
submitKey: SubmitKey.CtrlEnter as SubmitKey,
avatar: "1f603",
fontSize: 14,
theme: Theme.Auto as Theme,
tightBorder: false,
sendPreviewBubble: true,
sidebarWidth: 300,
disablePromptHint: false,
modelConfig: {
model: "gpt-3.5-turbo",
temperature: 1,
max_tokens: 2000,
presence_penalty: 0,
},
};
export interface ChatStat {
tokenCount: number;
wordCount: number;
@ -206,7 +78,6 @@ function createEmptySession(): ChatSession {
}
interface ChatStore {
config: ChatConfig;
sessions: ChatSession[];
currentSessionIndex: number;
clearSessions: () => void;
@ -230,9 +101,6 @@ interface ChatStore {
getMessagesWithMemory: () => Message[];
getMemoryPrompt: () => Message;
getConfig: () => ChatConfig;
resetConfig: () => void;
updateConfig: (updater: (config: ChatConfig) => void) => void;
clearAllData: () => void;
}
@ -247,9 +115,6 @@ export const useChatStore = create<ChatStore>()(
(set, get) => ({
sessions: [createEmptySession()],
currentSessionIndex: 0,
config: {
...DEFAULT_CONFIG,
},
clearSessions() {
set(() => ({
@ -258,20 +123,6 @@ export const useChatStore = create<ChatStore>()(
}));
},
resetConfig() {
set(() => ({ config: { ...DEFAULT_CONFIG } }));
},
getConfig() {
return get().config;
},
updateConfig(updater) {
const config = get().config;
updater(config);
set(() => ({ config }));
},
selectSession(index: number) {
set({
currentSessionIndex: index,
@ -394,6 +245,7 @@ export const useChatStore = create<ChatStore>()(
role: "assistant",
streaming: true,
id: userMessage.id! + 1,
model: useAppConfig.getState().modelConfig.model,
});
// get recent messages
@ -446,8 +298,8 @@ export const useChatStore = create<ChatStore>()(
controller,
);
},
filterBot: !get().config.sendBotMessages,
modelConfig: get().config.modelConfig,
filterBot: !useAppConfig.getState().sendBotMessages,
modelConfig: useAppConfig.getState().modelConfig,
});
},
@ -463,7 +315,7 @@ export const useChatStore = create<ChatStore>()(
getMessagesWithMemory() {
const session = get().currentSession();
const config = get().config;
const config = useAppConfig.getState();
const messages = session.messages.filter((msg) => !msg.isError);
const n = messages.length;
@ -538,24 +390,24 @@ export const useChatStore = create<ChatStore>()(
session.topic === DEFAULT_TOPIC &&
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 = useAppConfig.getState();
let toBeSummarizedMsgs = session.messages.slice(
session.lastSummarizeIndex,
);
const historyMsgLength = countMessages(toBeSummarizedMsgs);
if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
if (historyMsgLength > config?.modelConfig?.max_tokens ?? 4000) {
const n = toBeSummarizedMsgs.length;
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
Math.max(0, n - config.historyMessageCount),
@ -586,6 +438,7 @@ export const useChatStore = create<ChatStore>()(
}),
{
filterBot: false,
model: "gpt-3.5-turbo",
onMessage(message, done) {
session.memoryPrompt = message;
if (done) {

142
app/store/config.ts Normal file
View File

@ -0,0 +1,142 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export enum SubmitKey {
Enter = "Enter",
CtrlEnter = "Ctrl + Enter",
ShiftEnter = "Shift + Enter",
AltEnter = "Alt + Enter",
MetaEnter = "Meta + Enter",
}
export enum Theme {
Auto = "auto",
Dark = "dark",
Light = "light",
}
const DEFAULT_CONFIG = {
historyMessageCount: 4,
compressMessageLengthThreshold: 1000,
sendBotMessages: true as boolean,
submitKey: SubmitKey.CtrlEnter as SubmitKey,
avatar: "1f603",
fontSize: 14,
theme: Theme.Auto as Theme,
tightBorder: false,
sendPreviewBubble: true,
sidebarWidth: 300,
disablePromptHint: false,
modelConfig: {
model: "gpt-3.5-turbo" as ModelType,
temperature: 1,
max_tokens: 2000,
presence_penalty: 0,
},
};
export type ChatConfig = typeof DEFAULT_CONFIG;
export type ChatConfigStore = ChatConfig & {
reset: () => void;
update: (updater: (config: ChatConfig) => void) => void;
};
export type ModelConfig = ChatConfig["modelConfig"];
const ENABLE_GPT4 = true;
export const AZURE_API_VERSION = [
{
name: "2023-03-15-preview",
available: true,
},
];
export const ALL_MODELS = [
{
name: "gpt-4",
available: ENABLE_GPT4,
},
{
name: "gpt-4-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k",
available: ENABLE_GPT4,
},
{
name: "gpt-4-32k-0314",
available: ENABLE_GPT4,
},
{
name: "gpt-3.5-turbo",
available: true,
},
{
name: "gpt-3.5-turbo-0301",
available: true,
},
] as const;
export type ModelType = (typeof ALL_MODELS)[number]["name"];
export function limitNumber(
x: number,
min: number,
max: number,
defaultValue: number,
) {
if (typeof x !== "number" || isNaN(x)) {
return defaultValue;
}
return Math.min(max, Math.max(min, x));
}
export function limitModel(name: string) {
return ALL_MODELS.some((m) => m.name === name && m.available)
? name
: ALL_MODELS[4].name;
}
export const ModalConfigValidator = {
model(x: string) {
return limitModel(x) as ModelType;
},
max_tokens(x: number) {
return limitNumber(x, 0, 32000, 2000);
},
presence_penalty(x: number) {
return limitNumber(x, -2, 2, 0);
},
temperature(x: number) {
return limitNumber(x, 0, 2, 1);
},
};
const CONFIG_KEY = "app-config";
export const useAppConfig = create<ChatConfigStore>()(
persist(
(set, get) => ({
...DEFAULT_CONFIG,
reset() {
set(() => ({ ...DEFAULT_CONFIG }));
},
update(updater) {
const config = { ...get() };
updater(config);
set(() => config);
},
}),
{
name: CONFIG_KEY,
},
),
);

View File

@ -1,3 +1,4 @@
export * from "./app";
export * from "./update";
export * from "./access";
export * from "./config";

View File

@ -1,4 +1,5 @@
import { EmojiStyle } from "emoji-picker-react";
import { useEffect, useState } from "react";
import { showToast } from "./components/ui-lib";
import Locale from "./locales";
@ -47,7 +48,27 @@ export function isIOS() {
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() {
if (typeof window === "undefined") {
return false;
}
return window.innerWidth <= 600;
}

View File

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

View File

@ -10,10 +10,20 @@ const RAW_EN_URL = "f/awesome-chatgpt-prompts/main/prompts.csv";
const EN_URL = MIRRORF_FILE_URL + RAW_EN_URL;
const FILE = "./public/prompts.json";
const timeoutPromise = (timeout) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Request timeout'));
}, timeout);
});
};
async function fetchCN() {
console.log("[Fetch] fetching cn prompts...");
try {
const raw = await (await fetch(CN_URL)).json();
// const raw = await (await fetch(CN_URL)).json();
const response = await Promise.race([fetch(CN_URL), timeoutPromise(5000)]);
const raw = await response.json();
return raw.map((v) => [v.act, v.prompt]);
} catch (error) {
console.error("[Fetch] failed to fetch cn prompts", error);
@ -24,13 +34,15 @@ async function fetchCN() {
async function fetchEN() {
console.log("[Fetch] fetching en prompts...");
try {
const raw = await (await fetch(EN_URL)).text();
// const raw = await (await fetch(EN_URL)).text();
const response = await Promise.race([fetch(EN_URL), timeoutPromise(5000)]);
const raw = await response.text();
return raw
.split("\n")
.slice(1)
.map((v) => v.split('","').map((v) => v.replace('"', "")));
} catch (error) {
console.error("[Fetch] failed to fetch cn prompts", error);
console.error("[Fetch] failed to fetch en prompts", error);
return [];
}
}

View File

@ -29,13 +29,13 @@ esac
if ! command -v node >/dev/null || ! command -v git >/dev/null || ! command -v yarn >/dev/null; then
case "$(uname -s)" in
Linux)
if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"ubuntu\"" ]]; then
if [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=ubuntu" ]]; then
sudo apt-get update
sudo apt-get -y install nodejs git yarn
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"centos\"" ]]; then
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=centos" ]]; then
sudo yum -y install epel-release
sudo yum -y install nodejs git yarn
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=\"arch\"" ]]; then
elif [[ "$(cat /etc/*-release | grep '^ID=')" = "ID=arch" ]]; then
sudo pacman -Syu -y
sudo pacman -S -y nodejs git yarn
else

View File

@ -1189,6 +1189,11 @@
tiny-glob "^0.2.9"
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":
version "1.2.0"
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"
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:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"