merge upstream latest

This commit is contained in:
dakai
2023-04-06 10:47:00 +08:00
40 changed files with 1001 additions and 337 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

@@ -1,14 +1,14 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import DeleteIcon from "../icons/delete.svg";
import styles from "./home.module.scss";
import BotIcon from "../icons/bot.svg";
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,50 +20,69 @@ export function ChatItem(props: {
count: number;
time: string;
selected: boolean;
id: number;
index: number;
}) {
const [sidebarCollapse] = useChatStore((state) => [state.sidebarCollapse]);
return sidebarCollapse ? (
<div
className={`${styles["chat-item-collapse"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
>
<div className={styles["chat-item-info-collapse"]}>
{Locale.ChatItem.ChatItemCount(props.count).replace(/[^0-9]/g, "")
.length <= 3
? Locale.ChatItem.ChatItemCount(props.count).replace(/[^0-9]/g, "")
: ":)"}
</div>
<div
className={
sidebarCollapse
? styles["chat-item-delete-collapse"]
: styles["chat-item-delete"]
}
onClick={props.onDelete}
>
<DeleteIcon />
</div>
</div>
) : (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
>
<div>{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-collapse"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={styles["chat-item-info-collapse"]}>
{Locale.ChatItem.ChatItemCount(props.count).replace(/[^0-9]/g, "")
.length <= 3
? Locale.ChatItem.ChatItemCount(props.count).replace(
/[^0-9]/g,
"",
)
: ":)"}
</div>
<div
className={
sidebarCollapse
? styles["chat-item-delete-collapse"]
: 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>
) : (
<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>
)}
</Draggable>
);
}
@@ -74,32 +93,64 @@ export function ChatList() {
selectedIndex,
selectSession,
removeSession,
moveSession,
] = useChatStore((state) => [
state.sidebarCollapse,
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.removeSession,
state.moveSession,
]);
const onDragEnd: OnDragEndResponder = (result: any) => {
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["gpt-logo-collapse"]}>
{sidebarCollapse ? <BotIcon /> : null}
</div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="chat-list">
{(provided: any) => (
<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={() =>
(!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
removeSession(i)
}
/>
))}
<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={() => confirm(Locale.Home.DeleteChat) && removeSession(i)}
/>
))}
</div>
{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

@@ -1,5 +1,5 @@
import { useDebouncedCallback } from "use-debounce";
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
@@ -12,11 +12,19 @@ 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,
downloadAs,
getEmojiUrl,
isMobileScreen,
selectOrCopy,
} from "../utils";
@@ -31,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 () => (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 />,
@@ -50,7 +61,7 @@ export function Avatar(props: { role: Message["role"] }) {
return (
<div className={styles["user-avtar"]}>
<Emoji unified={config.avatar} size={18} />
<Emoji unified={config.avatar} size={18} getEmojiUrl={getEmojiUrl} />
</div>
);
}
@@ -140,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 />}
@@ -150,7 +171,6 @@ function PromptToast(props: {
]}
>
<>
{" "}
<div className={chatStyle["context-prompt"]}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
@@ -170,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"]}
@@ -208,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}
@@ -378,8 +415,8 @@ 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
@@ -409,6 +446,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 +473,10 @@ export function Chat(props: {}) {
isLoading
? [
{
role: "assistant",
content: "……",
date: new Date().toLocaleString(),
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
@@ -445,9 +486,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,
},
]
@@ -461,7 +503,6 @@ export function Chat(props: {}) {
if (sidebarCollapse && isMobileScreen()) return;
inputRef.current?.focus();
}, [sidebarCollapse]);
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
@@ -562,7 +603,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

@@ -191,7 +191,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;

View File

@@ -19,7 +19,6 @@ import RightIcon from "../icons/right.svg";
import { Message, SubmitKey, 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";
@@ -41,72 +40,10 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => <Loading noLogo />,
});
export function Avatar(props: { role: Message["role"] }) {
const config = useChatStore((state) => state.config);
if (props.role === "assistant") {
return <BotIcon className={styles["user-avtar"]} />;
}
return (
<div className={styles["user-avtar"]}>
<Emoji unified={config.avatar} size={18} />
</div>
);
}
function useSubmitHandler() {
const config = useChatStore((state) => state.config);
const submitKey = config.submitKey;
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return false;
if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
return (
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
(config.submitKey === SubmitKey.Enter &&
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey)
);
};
return {
submitKey,
shouldSubmit,
};
}
export function PromptHints(props: {
prompts: Prompt[];
onPromptSelect: (prompt: Prompt) => void;
}) {
if (props.prompts.length === 0) return null;
return (
<div className={styles["prompt-hints"]}>
{props.prompts.map((prompt, i) => (
<div
className={styles["prompt-hint"]}
key={prompt.title + i.toString()}
onClick={() => props.onPromptSelect(prompt)}
>
<div className={styles["hint-title"]}>{prompt.title}</div>
<div className={styles["hint-content"]}>{prompt.content}</div>
</div>
))}
</div>
);
}
function useSwitchTheme() {
const config = useChatStore((state) => state.config);

View File

@@ -26,7 +26,7 @@ import {
import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { getCurrentVersion } from "../utils";
import { getCurrentVersion, getEmojiUrl } from "../utils";
import Link from "next/link";
import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt";
@@ -96,26 +96,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 +119,13 @@ 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
}, []);
return (
<ErrorBoundary>
@@ -181,6 +174,7 @@ export function Settings(props: { closeSettings: () => void }) {
<EmojiPicker
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}
onEmojiClick={(e) => {
updateConfig((config) => (config.avatar = e.unified));
setShowEmojiPicker(false);
@@ -391,7 +385,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
}
>

View File

@@ -141,6 +141,16 @@
}
}
.input {
border: var(--border-in-light);
border-radius: 10px;
padding: 10px;
font-family: inherit;
background-color: var(--white);
color: var(--black);
resize: none;
}
@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;
@@ -140,3 +141,17 @@ export function showToast(content: string, delay = 3000) {
root.render(<Toast content={content} />);
}
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

@@ -10,6 +10,7 @@
.window-header-title {
max-width: calc(100% - 100px);
overflow: hidden;
.window-header-main-title {
font-size: 20px;

View File

@@ -2,6 +2,6 @@ export const OWNER = "Yidadaa";
export const REPO = "ChatGPT-Next-Web";
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`;

View File

@@ -3,7 +3,7 @@ import { SubmitKey } from "../store/app";
const cn = {
WIP: "该功能仍在开发中……",
Error: {
Unauthorized: "现在是未授权状态,请在设置页输入授权码。",
Unauthorized: "现在是未授权状态,请在设置页输入访问密码。",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 条对话`,
@@ -39,7 +39,10 @@ const cn = {
Memory: {
Title: "历史记忆",
EmptyContent: "尚未记忆",
Copy: "全部复制",
Send: "发送记忆",
Copy: "复制记忆",
Reset: "重置对话",
ResetConfirm: "重置后将清空当前对话记录以及历史记忆,确认重置?",
},
Home: {
NewChat: "新的聊天",
@@ -101,22 +104,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,7 +41,11 @@ 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",
@@ -108,8 +112,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,6 +42,10 @@ 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",
@@ -108,8 +112,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,6 +42,10 @@ 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",
@@ -109,8 +113,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,6 +41,9 @@ const tw: LocaleType = {
Title: "上下文記憶 Prompt",
EmptyContent: "尚未記憶",
Copy: "複製全部",
Send: "發送記憶",
Reset: "重置對話",
ResetConfirm: "重置後將清空當前對話記錄以及歷史記憶,確認重置?",
},
Home: {
NewChat: "新的對話",
@@ -106,8 +109,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";
if (!Array.prototype.at) {
@@ -25,10 +24,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,
};
};
@@ -85,31 +86,39 @@ export async function requestUsage() {
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(
@@ -208,23 +217,22 @@ export const ControllerPool = {
addController(
sessionIndex: number,
messageIndex: number,
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

@@ -19,8 +19,19 @@ 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",
@@ -153,6 +164,7 @@ export interface ChatStat {
export interface ChatSession {
id: number;
topic: string;
sendMemory: boolean;
memoryPrompt: string;
context: Message[];
messages: Message[];
@@ -162,11 +174,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();
@@ -174,6 +185,7 @@ function createEmptySession(): ChatSession {
return {
id: Date.now(),
topic: DEFAULT_TOPIC,
sendMemory: true,
memoryPrompt: "",
context: [],
messages: [],
@@ -193,6 +205,7 @@ interface ChatStore {
currentSessionIndex: number;
clearSessions: () => void;
removeSession: (index: number) => void;
moveSession: (from: number, to: number) => void;
selectSession: (index: number) => void;
newSession: () => void;
currentSession: () => ChatSession;
@@ -206,6 +219,7 @@ interface ChatStore {
messageIndex: number,
updater: (message?: Message) => void,
) => void;
resetSession: () => void;
getMessagesWithMemory: () => Message[];
getMemoryPrompt: () => Message;
@@ -283,6 +297,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,
@@ -313,18 +352,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();
@@ -347,7 +383,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(() => ({}));
@@ -363,13 +402,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,
);
},
@@ -396,7 +435,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);
}
@@ -420,6 +463,13 @@ export const useChatStore = create<ChatStore>()(
set(() => ({ sessions }));
},
resetSession() {
get().updateCurrentSession((session) => {
session.messages = [];
session.memoryPrompt = "";
});
},
summarizeSession() {
const session = get().currentSession();
@@ -432,7 +482,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),
);
},
);
@@ -517,7 +568,7 @@ export const useChatStore = create<ChatStore>()(
}),
{
name: LOCAL_KEY,
version: 1.1,
version: 1.2,
migrate(persistedState, version) {
const state = persistedState as ChatStore;
@@ -525,6 +576,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

@@ -128,6 +128,10 @@ select {
text-align: center;
}
label {
cursor: pointer;
}
input {
text-align: center;
}

View File

@@ -1,22 +1,29 @@
import { EmojiStyle } from "emoji-picker-react";
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);
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch((err) => {
console.error("Failed to copy: ", err);
});
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
console.log("Text copied to clipboard");
} catch (err) {
console.error("Failed to copy: ", err);
}
document.body.removeChild(textArea);
}
}
@@ -81,3 +88,7 @@ export function getCurrentVersion() {
return currentId;
}
export function getEmojiUrl(unified: string, style: EmojiStyle) {
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
}