Merge branch 'Yidadaa:main' into main

This commit is contained in:
EricYangXD
2023-04-07 13:56:54 +08:00
committed by GitHub
46 changed files with 1392 additions and 301 deletions

View File

@@ -53,6 +53,9 @@ export async function POST(req: NextRequest) {
return new Response(stream);
} catch (error) {
console.error("[Chat Stream]", error);
return new Response(
["```json\n", JSON.stringify(error, null, " "), "\n```"].join(""),
);
}
}

View File

@@ -6,6 +6,7 @@ async function makeRequest(req: NextRequest) {
const api = await requestOpenai(req);
const res = new NextResponse(api.body);
res.headers.set("Content-Type", "application/json");
res.headers.set("Cache-Control", "no-cache");
return res;
} catch (e) {
console.error("[OpenAI] ", req.body, e);
@@ -16,7 +17,7 @@ async function makeRequest(req: NextRequest) {
},
{
status: 500,
},
}
);
}
}

View File

@@ -10,6 +10,14 @@
transition: all 0.3s ease;
overflow: hidden;
user-select: none;
outline: none;
border: none;
color: var(--black);
&[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
}
.shadow {

View File

@@ -11,9 +11,10 @@ export function IconButton(props: {
noDark?: boolean;
className?: string;
title?: string;
disabled?: boolean;
}) {
return (
<div
<button
className={
styles["icon-button"] +
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
@@ -22,6 +23,7 @@ export function IconButton(props: {
}
onClick={props.onClick}
title={props.title}
disabled={props.disabled}
role="button"
>
<div
@@ -32,6 +34,6 @@ export function IconButton(props: {
{props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div>
)}
</div>
</button>
);
}

View File

@@ -1,14 +1,13 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import DeleteIcon from "../icons/delete.svg";
import styles from "./home.module.scss";
import {
Message,
SubmitKey,
useChatStore,
ChatSession,
BOT_HELLO,
} from "../store";
DragDropContext,
Droppable,
Draggable,
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { useChatStore } from "../store";
import Locale from "../locales";
import { isMobileScreen } from "../utils";
@@ -20,54 +19,90 @@ export function ChatItem(props: {
count: number;
time: string;
selected: boolean;
id: number;
index: number;
}) {
return (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
>
<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)}
<Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
ref={provided.innerRef}
{...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>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
</div>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
)}
</Draggable>
);
}
export function ChatList() {
const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
(state) => [
const [sessions, selectedIndex, selectSession, removeSession, moveSession] =
useChatStore((state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.removeSession,
],
);
state.moveSession,
]);
const chatStore = useChatStore();
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
moveSession(source.index, destination.index);
};
return (
<div className={styles["chat-list"]}>
{sessions.map((item, i) => (
<ChatItem
title={item.topic}
time={item.lastUpdate}
count={item.messages.length}
key={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={() =>
(!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
removeSession(i)
}
/>
))}
</div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="chat-list">
{(provided) => (
<div
className={styles["chat-list"]}
ref={provided.innerRef}
{...provided.droppableProps}
>
{sessions.map((item, i) => (
<ChatItem
title={item.topic}
time={item.lastUpdate}
count={item.messages.length}
key={item.id}
id={item.id}
index={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={chatStore.deleteSession}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}

View File

@@ -63,6 +63,14 @@
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.memory-prompt-action {
display: flex;
align-items: center;
}
}
.memory-prompt-content {

View File

@@ -4,7 +4,7 @@ import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import ExportIcon from "../icons/export.svg";
import MenuIcon from "../icons/menu.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import LoadingIcon from "../icons/three-dots.svg";
@@ -12,7 +12,14 @@ import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import { Message, SubmitKey, useChatStore, BOT_HELLO, ROLES } from "../store";
import {
Message,
SubmitKey,
useChatStore,
BOT_HELLO,
ROLES,
createMessage,
} from "../store";
import {
copyToClipboard,
@@ -32,11 +39,14 @@ import { IconButton } from "./button";
import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { Modal, showModal, showToast } from "./ui-lib";
import { Input, Modal, showModal, showToast } from "./ui-lib";
const Markdown = dynamic(async () => memo((await import("./markdown")).Markdown), {
loading: () => <LoadingIcon />,
});
const Markdown = dynamic(
async () => memo((await import("./markdown")).Markdown),
{
loading: () => <LoadingIcon />,
},
);
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
@@ -141,6 +151,16 @@ function PromptToast(props: {
title={Locale.Context.Edit}
onClose={() => props.setShowModal(false)}
actions={[
<IconButton
key="reset"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Reset}
onClick={() =>
confirm(Locale.Memory.ResetConfirm) &&
chatStore.resetSession()
}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
@@ -151,7 +171,6 @@ function PromptToast(props: {
]}
>
<>
{" "}
<div className={chatStyle["context-prompt"]}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
@@ -171,17 +190,18 @@ function PromptToast(props: {
</option>
))}
</select>
<input
<Input
value={c.content}
type="text"
className={chatStyle["context-content"]}
onChange={(e) =>
rows={1}
onInput={(e) =>
updateContextPrompt(i, {
...c,
content: e.target.value as any,
content: e.currentTarget.value as any,
})
}
></input>
/>
<IconButton
icon={<DeleteIcon />}
className={chatStyle["context-delete-button"]}
@@ -209,8 +229,24 @@ function PromptToast(props: {
</div>
<div className={chatStyle["memory-prompt"]}>
<div className={chatStyle["memory-prompt-title"]}>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
<span>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
</span>
<label className={chatStyle["memory-prompt-action"]}>
{Locale.Memory.Send}
<input
type="checkbox"
checked={session.sendMemory}
onChange={() =>
chatStore.updateCurrentSession(
(session) =>
(session.sendMemory = !session.sendMemory),
)
}
></input>
</label>
</div>
<div className={chatStyle["memory-prompt-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
@@ -307,6 +343,7 @@ export function Chat(props: {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [beforeInput, setBeforeInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll } = useScrollToBottom();
@@ -371,6 +408,7 @@ export function Chat(props: {
if (userInput.length <= 0) return;
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setBeforeInput(userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen()) inputRef.current?.focus();
@@ -378,12 +416,18 @@ export function Chat(props: {
};
// stop response
const onUserStop = (messageIndex: number) => {
ControllerPool.stop(sessionIndex, messageIndex);
const onUserStop = (messageId: number) => {
ControllerPool.stop(sessionIndex, messageId);
};
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// if ArrowUp and no userInput
if (e.key === "ArrowUp" && userInput.length <= 0) {
setUserInput(beforeInput);
e.preventDefault();
return;
}
if (shouldSubmit(e)) {
onUserSubmit();
e.preventDefault();
@@ -409,6 +453,9 @@ export function Chat(props: {
chatStore
.onUserInput(messages[i].content)
.then(() => setIsLoading(false));
chatStore.updateCurrentSession((session) =>
session.messages.splice(i, 2),
);
inputRef.current?.focus();
return;
}
@@ -433,9 +480,10 @@ export function Chat(props: {
isLoading
? [
{
role: "assistant",
content: "……",
date: new Date().toLocaleString(),
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
@@ -445,9 +493,10 @@ export function Chat(props: {
userInput.length > 0 && config.sendPreviewBubble
? [
{
role: "user",
content: userInput,
date: new Date().toLocaleString(),
...createMessage({
role: "user",
content: userInput,
}),
preview: true,
},
]
@@ -460,18 +509,16 @@ export function Chat(props: {
useEffect(() => {
if (props.sideBarShowing && isMobileScreen()) return;
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
<div
className={styles["window-header-title"]}
onClick={props?.showSideBar}
>
<div className={styles["window-header-title"]}>
<div
className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
onClick={() => {
onClickCapture={() => {
const newTopic = prompt(Locale.Chat.Rename, session.topic);
if (newTopic && newTopic !== session.topic) {
chatStore.updateCurrentSession(
@@ -489,7 +536,7 @@ export function Chat(props: {
<div className={styles["window-actions"]}>
<div className={styles["window-action-button"] + " " + styles.mobile}>
<IconButton
icon={<MenuIcon />}
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar}
@@ -563,7 +610,7 @@ export function Chat(props: {
{message.streaming ? (
<div
className={styles["chat-message-top-action"]}
onClick={() => onUserStop(i)}
onClick={() => onUserStop(message.id ?? i)}
>
{Locale.Chat.Actions.Stop}
</div>

View File

@@ -125,7 +125,7 @@
border-radius: 10px;
margin-bottom: 10px;
box-shadow: var(--card-shadow);
transition: all 0.3s ease;
transition: background-color 0.3s ease;
cursor: pointer;
user-select: none;
border: 2px solid transparent;
@@ -157,6 +157,7 @@
right: -20px;
transition: all ease 0.3s;
opacity: 0;
cursor: pointer;
}
.chat-item:hover > .chat-item-delete {

View File

@@ -19,7 +19,6 @@ import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store";
import { isMobileScreen } from "../utils";
import Locale from "../locales";
import { ChatList } from "./chat-list";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
@@ -39,6 +38,10 @@ 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);
@@ -90,6 +93,7 @@ function _Home() {
state.removeSession,
],
);
const chatStore = useChatStore();
const loading = !useHasHydrated();
const [showSideBar, setShowSideBar] = useState(true);
@@ -139,11 +143,7 @@ function _Home() {
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<CloseIcon />}
onClick={() => {
if (confirm(Locale.Home.DeleteChat)) {
removeSession(currentIndex);
}
}}
onClick={chatStore.deleteSession}
/>
</div>
<div className={styles["sidebar-action"]}>

View File

@@ -0,0 +1,7 @@
.input-range {
border: var(--border-in-light);
border-radius: 10px;
padding: 5px 15px 5px 10px;
font-size: 12px;
display: flex;
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import styles from "./input-range.module.scss";
interface InputRangeProps {
onChange: React.ChangeEventHandler<HTMLInputElement>;
title?: string;
value: number | string;
className?: string;
min: string;
max: string;
step: string;
}
export function InputRange({
onChange,
title,
value,
className,
min,
max,
step,
}: InputRangeProps) {
return (
<div className={styles["input-range"] + ` ${className ?? ""}`}>
{title || value}
<input
type="range"
title={title}
value={value}
min={min}
max={max}
step={step}
onChange={onChange}
></input>
</div>
);
}

View File

@@ -32,6 +32,7 @@ import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt";
import { requestUsage } from "../requests";
import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range";
function SettingItem(props: {
title: string;
@@ -96,26 +97,18 @@ export function Settings(props: { closeSettings: () => void }) {
const [usage, setUsage] = useState<{
used?: number;
subscription?: number;
}>();
const [loadingUsage, setLoadingUsage] = useState(false);
function checkUsage() {
setLoadingUsage(true);
requestUsage()
.then((res) =>
setUsage({
used: res,
}),
)
.then((res) => setUsage(res))
.finally(() => {
setLoadingUsage(false);
});
}
useEffect(() => {
checkUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const accessStore = useAccessStore();
const enabledAccessControl = useMemo(
() => accessStore.enabledAccessControl(),
@@ -127,12 +120,26 @@ export function Settings(props: { closeSettings: () => void }) {
const builtinCount = SearchService.count.builtin;
const customCount = promptStore.prompts.size ?? 0;
const showUsage = accessStore.token !== "";
const showUsage = !!accessStore.token || !!accessStore.accessCode;
useEffect(() => {
if (showUsage) {
checkUsage();
}
}, [showUsage]);
checkUpdate();
showUsage && checkUsage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const keydownEvent = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.closeSettings();
}
};
document.addEventListener("keydown", keydownEvent);
return () => {
document.removeEventListener("keydown", keydownEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<ErrorBoundary>
@@ -281,8 +288,7 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.FontSize.Title}
subTitle={Locale.Settings.FontSize.SubTitle}
>
<input
type="range"
<InputRange
title={`${config.fontSize ?? 14}px`}
value={config.fontSize}
min="12"
@@ -294,7 +300,7 @@ export function Settings(props: { closeSettings: () => void }) {
(config.fontSize = Number.parseInt(e.currentTarget.value)),
)
}
></input>
></InputRange>
</SettingItem>
<SettingItem title={Locale.Settings.TightBorder}>
@@ -392,7 +398,10 @@ export function Settings(props: { closeSettings: () => void }) {
showUsage
? loadingUsage
? Locale.Settings.Usage.IsChecking
: Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]")
: Locale.Settings.Usage.SubTitle(
usage?.used ?? "[?]",
usage?.subscription ?? "[?]",
)
: Locale.Settings.Usage.NoAccess
}
>
@@ -411,8 +420,7 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<input
type="range"
<InputRange
title={config.historyMessageCount.toString()}
value={config.historyMessageCount}
min="0"
@@ -424,7 +432,7 @@ export function Settings(props: { closeSettings: () => void }) {
(config.historyMessageCount = e.target.valueAsNumber),
)
}
></input>
></InputRange>
</SettingItem>
<SettingItem
@@ -471,8 +479,7 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.Temperature.Title}
subTitle={Locale.Settings.Temperature.SubTitle}
>
<input
type="range"
<InputRange
value={config.modelConfig.temperature?.toFixed(1)}
min="0"
max="2"
@@ -486,7 +493,7 @@ export function Settings(props: { closeSettings: () => void }) {
)),
);
}}
></input>
></InputRange>
</SettingItem>
<SettingItem
title={Locale.Settings.MaxTokens.Title}
@@ -512,8 +519,7 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.PresencePenlty.Title}
subTitle={Locale.Settings.PresencePenlty.SubTitle}
>
<input
type="range"
<InputRange
value={config.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
@@ -527,7 +533,7 @@ export function Settings(props: { closeSettings: () => void }) {
)),
);
}}
></input>
></InputRange>
</SettingItem>
</List>
</div>

View File

@@ -135,12 +135,39 @@
box-shadow: var(--card-shadow);
border: var(--border-in-light);
color: var(--black);
padding: 10px 30px;
padding: 10px 20px;
border-radius: 50px;
margin-bottom: 20px;
display: flex;
align-items: center;
.toast-action {
padding-left: 20px;
color: var(--primary);
opacity: 0.8;
border: 0;
background: none;
cursor: pointer;
font-family: inherit;
&:hover {
opacity: 1;
}
}
}
}
.input {
border: var(--border-in-light);
border-radius: 10px;
padding: 10px;
font-family: inherit;
background-color: var(--white);
color: var(--black);
resize: none;
min-width: 50px;
}
@media only screen and (max-width: 600px) {
.modal-container {
width: 90vw;

View File

@@ -2,6 +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";
export function Popover(props: {
children: JSX.Element;
@@ -109,17 +110,37 @@ export function showModal(props: ModalProps) {
root.render(<Modal {...props} onClose={closeModal}></Modal>);
}
export type ToastProps = { content: string };
export type ToastProps = {
content: string;
action?: {
text: string;
onClick: () => void;
};
};
export function Toast(props: ToastProps) {
return (
<div className={styles["toast-container"]}>
<div className={styles["toast-content"]}>{props.content}</div>
<div className={styles["toast-content"]}>
<span>{props.content}</span>
{props.action && (
<button
onClick={props.action.onClick}
className={styles["toast-action"]}
>
{props.action.text}
</button>
)}
</div>
</div>
);
}
export function showToast(content: string, delay = 3000) {
export function showToast(
content: string,
action?: ToastProps["action"],
delay = 3000,
) {
const div = document.createElement("div");
div.className = styles.show;
document.body.appendChild(div);
@@ -138,5 +159,19 @@ export function showToast(content: string, delay = 3000) {
close();
}, delay);
root.render(<Toast content={content} />);
root.render(<Toast content={content} action={action} />);
}
export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
autoHeight?: boolean;
rows?: number;
};
export function Input(props: InputProps) {
return (
<textarea
{...props}
className={`${styles["input"]} ${props.className}`}
></textarea>
);
}

View File

@@ -2,6 +2,6 @@ export const OWNER = "EricYangXD";
export const REPO = "ChatGPT-Next";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
export const UPDATE_URL = `${REPO_URL}#%E4%BF%9D%E6%8C%81%E6%9B%B4%E6%96%B0-keep-updated`;
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`;

21
app/icons/return.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
height="16" viewBox="0 0 16 16" fill="none">
<defs>
<rect id="path_0" x="0" y="0" width="16" height="16" />
</defs>
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
<mask id="bg-mask-0" fill="white">
<use xlink:href="#path_0"></use>
</mask>
<g mask="url(#bg-mask-0)">
<path id="路径 1"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 2.6666666666666665) rotate(0 1.1666333333333334 2.1666666666666665)"
d="M2.33,0L0,2L2.33,4.33 " />
<path id="路径 2"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 4.666666666666666) rotate(0 6.000006859869576 4.333333333333333)"
d="M0,0L7.66,0C9.96,0 11.91,1.87 12,4.17C12.09,6.59 10.09,8.67 7.66,8.67L2,8.67 " />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -3,7 +3,7 @@ import { SubmitKey } from "../store/app";
const cn = {
WIP: "该功能仍在开发中……",
Error: {
Unauthorized: "现在是未授权状态,请在设置页输入授权码。",
Unauthorized: "现在是未授权状态,请在设置页输入访问密码。",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 条对话`,
@@ -39,11 +39,16 @@ const cn = {
Memory: {
Title: "历史记忆",
EmptyContent: "尚未记忆",
Copy: "全部复制",
Send: "发送记忆",
Copy: "复制记忆",
Reset: "重置对话",
ResetConfirm: "重置后将清空当前对话记录以及历史记忆,确认重置?",
},
Home: {
NewChat: "新的聊天",
DeleteChat: "确认删除选中的对话?",
DeleteToast: "已删除会话",
Revert: "撤销",
},
Settings: {
Title: "设置",
@@ -101,22 +106,22 @@ const cn = {
},
Token: {
Title: "API Key",
SubTitle: "使用自己的 Key 可绕过授权访问限制",
SubTitle: "使用自己的 Key 可绕过密码访问限制",
Placeholder: "OpenAI API Key",
},
Usage: {
Title: "账户余额",
SubTitle(used: any) {
return `本月已使用 $${used}`;
Title: "余额查询",
SubTitle(used: any, total: any) {
return `本月已使用 $${used},订阅总额 $${total}`;
},
IsChecking: "正在检查…",
Check: "重新检查",
NoAccess: "输入API Key查看余额",
NoAccess: "输入 API Key 或访问密码查看余额",
},
AccessCode: {
Title: "授权码",
Title: "访问密码",
SubTitle: "现在是未授权访问状态",
Placeholder: "请输入授权码",
Placeholder: "请输入访问密码",
},
Model: "模型 (model)",
Temperature: {

View File

@@ -41,11 +41,17 @@ const en: LocaleType = {
Memory: {
Title: "Memory Prompt",
EmptyContent: "Nothing yet.",
Copy: "Copy All",
Send: "Send Memory",
Copy: "Copy Memory",
Reset: "Reset Session",
ResetConfirm:
"Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
},
Home: {
NewChat: "New Chat",
DeleteChat: "Confirm to delete the selected conversation?",
DeleteToast: "Chat Deleted",
Revert: "Revert",
},
Settings: {
Title: "Settings",
@@ -108,8 +114,8 @@ const en: LocaleType = {
},
Usage: {
Title: "Account Balance",
SubTitle(used: any) {
return `Used this month $${used}`;
SubTitle(used: any, total: any) {
return `Used this month $${used}, subscription $${total}`;
},
IsChecking: "Checking...",
Check: "Check Again",

View File

@@ -42,10 +42,16 @@ const es: LocaleType = {
Title: "Historial de memoria",
EmptyContent: "Aún no hay nada.",
Copy: "Copiar todo",
Send: "Send Memory",
Reset: "Reset Session",
ResetConfirm:
"Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
},
Home: {
NewChat: "Nuevo chat",
DeleteChat: "¿Confirmar eliminación de la conversación seleccionada?",
DeleteToast: "Chat Deleted",
Revert: "Revert",
},
Settings: {
Title: "Configuración",
@@ -108,8 +114,8 @@ const es: LocaleType = {
},
Usage: {
Title: "Saldo de la cuenta",
SubTitle(used: any) {
return `Usado $${used}`;
SubTitle(used: any, total: any) {
return `Usado $${used}, subscription $${total}`;
},
IsChecking: "Comprobando...",
Check: "Comprobar de nuevo",

View File

@@ -42,10 +42,16 @@ const it: LocaleType = {
Title: "Prompt di memoria",
EmptyContent: "Vuoto.",
Copy: "Copia tutto",
Send: "Send Memory",
Reset: "Reset Session",
ResetConfirm:
"Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
},
Home: {
NewChat: "Nuova Chat",
DeleteChat: "Confermare la cancellazione della conversazione selezionata?",
DeleteToast: "Chat Deleted",
Revert: "Revert",
},
Settings: {
Title: "Impostazioni",
@@ -109,8 +115,8 @@ const it: LocaleType = {
},
Usage: {
Title: "Bilancio Account",
SubTitle(used: any) {
return `Usato in questo mese $${used}`;
SubTitle(used: any, total: any) {
return `Usato in questo mese $${used}, subscription $${total}`;
},
IsChecking: "Controllando...",
Check: "Controlla ancora",

View File

@@ -41,10 +41,15 @@ const tw: LocaleType = {
Title: "上下文記憶 Prompt",
EmptyContent: "尚未記憶",
Copy: "複製全部",
Send: "發送記憶",
Reset: "重置對話",
ResetConfirm: "重置後將清空當前對話記錄以及歷史記憶,確認重置?",
},
Home: {
NewChat: "新的對話",
DeleteChat: "確定要刪除選取的對話嗎?",
DeleteToast: "已刪除對話",
Revert: "撤銷",
},
Settings: {
Title: "設定",
@@ -106,8 +111,8 @@ const tw: LocaleType = {
},
Usage: {
Title: "帳戶餘額",
SubTitle(used: any) {
return `本月已使用 $${used}`;
SubTitle(used: any, total: any) {
return `本月已使用 $${used},订阅总额 $${total}`;
},
IsChecking: "正在檢查…",
Check: "重新檢查",

View File

@@ -1,6 +1,5 @@
import type { ChatRequest, ChatReponse } from "./api/openai/typing";
import { Message, ModelConfig, useAccessStore } from "./store";
import Locale from "./locales";
import { Message, ModelConfig, useAccessStore, useChatStore } from "./store";
import { showToast } from "./components/ui-lib";
const TIME_OUT_MS = 30000;
@@ -10,7 +9,7 @@ const makeRequestParam = (
options?: {
filterBot?: boolean;
stream?: boolean;
},
}
): ChatRequest => {
let sendMessages = messages.map((v) => ({
role: v.role,
@@ -21,10 +20,12 @@ const makeRequestParam = (
sendMessages = sendMessages.filter((m) => m.role !== "assistant");
}
const modelConfig = useChatStore.getState().config.modelConfig;
return {
model: "gpt-3.5-turbo",
messages: sendMessages,
stream: options?.stream,
...modelConfig,
};
};
@@ -45,11 +46,10 @@ function getHeaders() {
export function requestOpenaiClient(path: string) {
return (body: any, method = "POST") =>
fetch("/api/openai", {
fetch("/api/openai?_vercel_no_cache=1", {
method,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
path,
...getHeaders(),
},
@@ -76,36 +76,44 @@ export async function requestUsage() {
.getDate()
.toString()
.padStart(2, "0")}`;
const ONE_DAY = 24 * 60 * 60 * 1000;
const ONE_DAY = 2 * 24 * 60 * 60 * 1000;
const now = new Date(Date.now() + ONE_DAY);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startDate = formatDate(startOfMonth);
const endDate = formatDate(now);
const res = await requestOpenaiClient(
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
)(null, "GET");
try {
const response = (await res.json()) as {
total_usage: number;
error?: {
type: string;
message: string;
};
const [used, subs] = await Promise.all([
requestOpenaiClient(
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`
)(null, "GET"),
requestOpenaiClient("dashboard/billing/subscription")(null, "GET"),
]);
const response = (await used.json()) as {
total_usage?: number;
error?: {
type: string;
message: string;
};
};
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
const total = (await subs.json()) as {
hard_limit_usd?: number;
};
if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100;
}
return response.total_usage;
} catch (error) {
console.error("[Request usage] ", error, res.body);
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100;
}
return {
used: response.total_usage,
subscription: total.hard_limit_usd,
};
}
export async function requestChatStream(
@@ -116,7 +124,7 @@ export async function requestChatStream(
onMessage: (message: string, done: boolean) => void;
onError: (error: Error, statusCode?: number) => void;
onController?: (controller: AbortController) => void;
},
}
) {
const req = makeRequestParam(messages, {
stream: true,
@@ -204,23 +212,22 @@ export const ControllerPool = {
addController(
sessionIndex: number,
messageIndex: number,
controller: AbortController,
messageId: number,
controller: AbortController
) {
const key = this.key(sessionIndex, messageIndex);
const key = this.key(sessionIndex, messageId);
this.controllers[key] = controller;
return key;
},
stop(sessionIndex: number, messageIndex: number) {
const key = this.key(sessionIndex, messageIndex);
stop(sessionIndex: number, messageId: number) {
const key = this.key(sessionIndex, messageId);
const controller = this.controllers[key];
console.log(controller);
controller?.abort();
},
remove(sessionIndex: number, messageIndex: number) {
const key = this.key(sessionIndex, messageIndex);
remove(sessionIndex: number, messageId: number) {
const key = this.key(sessionIndex, messageId);
delete this.controllers[key];
},

View File

@@ -7,16 +7,28 @@ import {
requestChatStream,
requestWithPrompt,
} from "../requests";
import { trimTopic } from "../utils";
import { isMobileScreen, trimTopic } from "../utils";
import Locale from "../locales";
import { showToast } from "../components/ui-lib";
export type Message = ChatCompletionResponseMessage & {
date: string;
streaming?: boolean;
isError?: boolean;
id?: number;
};
export function createMessage(override: Partial<Message>): Message {
return {
id: Date.now(),
date: new Date().toLocaleString(),
role: "user",
content: "",
...override,
};
}
export enum SubmitKey {
Enter = "Enter",
CtrlEnter = "Ctrl + Enter",
@@ -149,6 +161,7 @@ export interface ChatStat {
export interface ChatSession {
id: number;
topic: string;
sendMemory: boolean;
memoryPrompt: string;
context: Message[];
messages: Message[];
@@ -158,11 +171,10 @@ export interface ChatSession {
}
const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
export const BOT_HELLO: Message = {
export const BOT_HELLO: Message = createMessage({
role: "assistant",
content: Locale.Store.BotHello,
date: "",
};
});
function createEmptySession(): ChatSession {
const createDate = new Date().toLocaleString();
@@ -170,6 +182,7 @@ function createEmptySession(): ChatSession {
return {
id: Date.now(),
topic: DEFAULT_TOPIC,
sendMemory: true,
memoryPrompt: "",
context: [],
messages: [],
@@ -189,8 +202,10 @@ interface ChatStore {
currentSessionIndex: number;
clearSessions: () => void;
removeSession: (index: number) => void;
moveSession: (from: number, to: number) => void;
selectSession: (index: number) => void;
newSession: () => void;
deleteSession: () => void;
currentSession: () => ChatSession;
onNewMessage: (message: Message) => void;
onUserInput: (content: string) => Promise<void>;
@@ -202,6 +217,7 @@ interface ChatStore {
messageIndex: number,
updater: (message?: Message) => void,
) => void;
resetSession: () => void;
getMessagesWithMemory: () => Message[];
getMemoryPrompt: () => Message;
@@ -278,6 +294,31 @@ export const useChatStore = create<ChatStore>()(
});
},
moveSession(from: number, to: number) {
set((state) => {
const { sessions, currentSessionIndex: oldIndex } = state;
// move the session
const newSessions = [...sessions];
const session = newSessions[from];
newSessions.splice(from, 1);
newSessions.splice(to, 0, session);
// modify current session id
let newIndex = oldIndex === from ? to : oldIndex;
if (oldIndex > from && oldIndex <= to) {
newIndex -= 1;
} else if (oldIndex < from && oldIndex >= to) {
newIndex += 1;
}
return {
currentSessionIndex: newIndex,
sessions: newSessions,
};
});
},
newSession() {
set((state) => ({
currentSessionIndex: 0,
@@ -285,6 +326,26 @@ export const useChatStore = create<ChatStore>()(
}));
},
deleteSession() {
const deletedSession = get().currentSession();
const index = get().currentSessionIndex;
const isLastSession = get().sessions.length === 1;
if (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) {
get().removeSession(index);
}
showToast(Locale.Home.DeleteToast, {
text: Locale.Home.Revert,
onClick() {
set((state) => ({
sessions: state.sessions
.slice(0, index)
.concat([deletedSession])
.concat(state.sessions.slice(index + Number(isLastSession))),
}));
},
});
},
currentSession() {
let index = get().currentSessionIndex;
const sessions = get().sessions;
@@ -308,18 +369,15 @@ export const useChatStore = create<ChatStore>()(
},
async onUserInput(content) {
const userMessage: Message = {
const userMessage: Message = createMessage({
role: "user",
content,
date: new Date().toLocaleString(),
};
});
const botMessage: Message = {
content: "",
const botMessage: Message = createMessage({
role: "assistant",
date: new Date().toLocaleString(),
streaming: true,
};
});
// get recent messages
const recentMessages = get().getMessagesWithMemory();
@@ -342,7 +400,10 @@ export const useChatStore = create<ChatStore>()(
botMessage.streaming = false;
botMessage.content = content;
get().onNewMessage(botMessage);
ControllerPool.remove(sessionIndex, messageIndex);
ControllerPool.remove(
sessionIndex,
botMessage.id ?? messageIndex,
);
} else {
botMessage.content = content;
set(() => ({}));
@@ -358,13 +419,13 @@ export const useChatStore = create<ChatStore>()(
userMessage.isError = true;
botMessage.isError = true;
set(() => ({}));
ControllerPool.remove(sessionIndex, messageIndex);
ControllerPool.remove(sessionIndex, botMessage.id ?? messageIndex);
},
onController(controller) {
// collect controller for stop/retry
ControllerPool.addController(
sessionIndex,
messageIndex,
botMessage.id ?? messageIndex,
controller,
);
},
@@ -391,7 +452,11 @@ export const useChatStore = create<ChatStore>()(
const context = session.context.slice();
if (session.memoryPrompt && session.memoryPrompt.length > 0) {
if (
session.sendMemory &&
session.memoryPrompt &&
session.memoryPrompt.length > 0
) {
const memoryPrompt = get().getMemoryPrompt();
context.push(memoryPrompt);
}
@@ -415,6 +480,13 @@ export const useChatStore = create<ChatStore>()(
set(() => ({ sessions }));
},
resetSession() {
get().updateCurrentSession((session) => {
session.messages = [];
session.memoryPrompt = "";
});
},
summarizeSession() {
const session = get().currentSession();
@@ -427,7 +499,8 @@ export const useChatStore = create<ChatStore>()(
requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then(
(res) => {
get().updateCurrentSession(
(session) => (session.topic = trimTopic(res)),
(session) =>
(session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
);
},
);
@@ -506,7 +579,7 @@ export const useChatStore = create<ChatStore>()(
}),
{
name: LOCAL_KEY,
version: 1.1,
version: 1.2,
migrate(persistedState, version) {
const state = persistedState as ChatStore;
@@ -514,6 +587,10 @@ export const useChatStore = create<ChatStore>()(
state.sessions.forEach((s) => (s.context = []));
}
if (version < 1.2) {
state.sessions.forEach((s) => (s.sendMemory = true));
}
return state;
},
},

View File

@@ -126,6 +126,10 @@ select {
text-align: center;
}
label {
cursor: pointer;
}
input {
text-align: center;
}
@@ -155,19 +159,11 @@ input[type="checkbox"]:checked::after {
input[type="range"] {
appearance: none;
border: var(--border-in-light);
border-radius: 10px;
padding: 5px 15px 5px 10px;
background-color: var(--white);
color: var(--black);
&::before {
content: attr(value);
font-size: 12px;
}
}
input[type="range"]::-webkit-slider-thumb {
@mixin thumb() {
appearance: none;
height: 8px;
width: 20px;
@@ -176,11 +172,36 @@ input[type="range"]::-webkit-slider-thumb {
cursor: pointer;
transition: all ease 0.3s;
margin-left: 5px;
border: none;
}
input[type="range"]::-webkit-slider-thumb {
@include thumb();
}
input[type="range"]::-moz-range-thumb {
@include thumb();
}
input[type="range"]::-ms-thumb {
@include thumb();
}
@mixin thumbHover() {
transform: scaleY(1.2);
width: 24px;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scaleY(1.2);
width: 24px;
@include thumbHover();
}
input[type="range"]::-moz-range-thumb:hover {
@include thumbHover();
}
input[type="range"]::-ms-thumb:hover {
@include thumbHover();
}
input[type="number"],

View File

@@ -3,21 +3,26 @@ import { showToast } from "./components/ui-lib";
import Locale from "./locales";
export function trimTopic(topic: string) {
return topic.replace(/[,。!?、,.!?]*$/, "");
return topic.replace(/[,。!?”“"、,.!?]*$/, "");
}
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
const textarea = document.createElement("textarea");
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
} finally {
showToast(Locale.Copy.Success);
} catch (error) {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
showToast(Locale.Copy.Success);
} catch (error) {
showToast(Locale.Copy.Failed);
}
document.body.removeChild(textArea);
}
}