Browse Source

Merge pull request #2 from Yidadaa/main

Upstream Sync
SergeWilfried 1 year ago
parent
commit
da294038a8

+ 4 - 0
README.md

@@ -263,6 +263,10 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s
 
 ![More](./docs/images/more.png)
 
+## Translation
+
+If you want to add a new translation, read this [document](./docs/translation.md).
+
 ## Donation
 
 [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)

+ 9 - 9
app/client/controller.ts

@@ -3,17 +3,17 @@ export const ChatControllerPool = {
   controllers: {} as Record<string, AbortController>,
 
   addController(
-    sessionIndex: number,
-    messageId: number,
+    sessionId: string,
+    messageId: string,
     controller: AbortController,
   ) {
-    const key = this.key(sessionIndex, messageId);
+    const key = this.key(sessionId, messageId);
     this.controllers[key] = controller;
     return key;
   },
 
-  stop(sessionIndex: number, messageId: number) {
-    const key = this.key(sessionIndex, messageId);
+  stop(sessionId: string, messageId: string) {
+    const key = this.key(sessionId, messageId);
     const controller = this.controllers[key];
     controller?.abort();
   },
@@ -26,12 +26,12 @@ export const ChatControllerPool = {
     return Object.values(this.controllers).length > 0;
   },
 
-  remove(sessionIndex: number, messageId: number) {
-    const key = this.key(sessionIndex, messageId);
+  remove(sessionId: string, messageId: string) {
+    const key = this.key(sessionId, messageId);
     delete this.controllers[key];
   },
 
-  key(sessionIndex: number, messageIndex: number) {
-    return `${sessionIndex},${messageIndex}`;
+  key(sessionId: string, messageIndex: string) {
+    return `${sessionId},${messageIndex}`;
   },
 };

+ 8 - 6
app/client/platforms/openai.ts

@@ -257,12 +257,14 @@ export class ChatGPTApi implements LLMApi {
     const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
     console.log("[Models]", chatModels);
 
-    return (
-      chatModels?.map((m) => ({
-        name: m.id,
-        available: true,
-      })) || []
-    );
+    if (!chatModels) {
+      return [];
+    }
+
+    return chatModels.map((m) => ({
+      name: m.id,
+      available: true,
+    }));
   }
 }
 export { OpenaiPath };

+ 1 - 1
app/components/chat-list.tsx

@@ -26,7 +26,7 @@ export function ChatItem(props: {
   count: number;
   time: string;
   selected: boolean;
-  id: number;
+  id: string;
   index: number;
   narrow?: boolean;
   mask: Mask;

+ 33 - 34
app/components/chat.module.scss

@@ -240,24 +240,39 @@
   &:last-child {
     animation: slide-in ease 0.3s;
   }
+}
 
-  &:hover {
-    .chat-message-actions {
-      opacity: 1;
-      transform: translateY(0px);
-      max-width: 100%;
-      height: 40px;
-    }
+.chat-message-user {
+  display: flex;
+  flex-direction: row-reverse;
 
-    .chat-message-action-date {
-      opacity: 0.2;
-    }
+  .chat-message-header {
+    flex-direction: row-reverse;
   }
 }
 
-.chat-message-user {
+.chat-message-header {
+  margin-top: 20px;
   display: flex;
-  flex-direction: row-reverse;
+  align-items: center;
+
+  .chat-message-actions {
+    display: flex;
+    box-sizing: border-box;
+    font-size: 12px;
+    align-items: flex-end;
+    justify-content: space-between;
+    transition: all ease 0.3s;
+    transform: scale(0.9) translateY(5px);
+    margin: 0 10px;
+    opacity: 0;
+    pointer-events: none;
+
+    .chat-input-actions {
+      display: flex;
+      flex-wrap: nowrap;
+    }
+  }
 }
 
 .chat-message-container {
@@ -270,6 +285,12 @@
     .chat-message-edit {
       opacity: 0.9;
     }
+
+    .chat-message-actions {
+      opacity: 1;
+      pointer-events: all;
+      transform: scale(1) translateY(0);
+    }
   }
 }
 
@@ -278,7 +299,6 @@
 }
 
 .chat-message-avatar {
-  margin-top: 20px;
   position: relative;
 
   .chat-message-edit {
@@ -318,27 +338,6 @@
   border: var(--border-in-light);
   position: relative;
   transition: all ease 0.3s;
-
-  .chat-message-actions {
-    display: flex;
-    box-sizing: border-box;
-    font-size: 12px;
-    align-items: flex-end;
-    justify-content: space-between;
-    transition: all ease 0.3s 0.15s;
-    transform: translateX(-5px) scale(0.9) translateY(30px);
-    opacity: 0;
-    height: 0;
-    max-width: 0;
-    position: absolute;
-    left: 0;
-    z-index: 2;
-
-    .chat-input-actions {
-      display: flex;
-      flex-wrap: nowrap;
-    }
-  }
 }
 
 .chat-message-action-date {

+ 85 - 90
app/components/chat.tsx

@@ -221,9 +221,11 @@ function useSubmitHandler() {
   };
 }
 
+export type RenderPompt = Pick<Prompt, "title" | "content">;
+
 export function PromptHints(props: {
-  prompts: Prompt[];
-  onPromptSelect: (prompt: Prompt) => void;
+  prompts: RenderPompt[];
+  onPromptSelect: (prompt: RenderPompt) => void;
 }) {
   const noPrompts = props.prompts.length === 0;
   const [selectIndex, setSelectIndex] = useState(0);
@@ -542,7 +544,7 @@ export function Chat() {
 
   // prompt hints
   const promptStore = usePromptStore();
-  const [promptHints, setPromptHints] = useState<Prompt[]>([]);
+  const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
   const onSearch = useDebouncedCallback(
     (text: string) => {
       const matchedPrompts = promptStore.search(text);
@@ -624,7 +626,7 @@ export function Chat() {
     setAutoScroll(true);
   };
 
-  const onPromptSelect = (prompt: Prompt) => {
+  const onPromptSelect = (prompt: RenderPompt) => {
     setTimeout(() => {
       setPromptHints([]);
 
@@ -642,8 +644,8 @@ export function Chat() {
   };
 
   // stop response
-  const onUserStop = (messageId: number) => {
-    ChatControllerPool.stop(sessionIndex, messageId);
+  const onUserStop = (messageId: string) => {
+    ChatControllerPool.stop(session.id, messageId);
   };
 
   useEffect(() => {
@@ -703,54 +705,51 @@ export function Chat() {
     }
   };
 
-  const findLastUserIndex = (messageId: number) => {
+  const findLastUserIndex = (messageId: string) => {
     // find last user input message and resend
     let lastUserMessageIndex: number | null = null;
     for (let i = 0; i < session.messages.length; i += 1) {
       const message = session.messages[i];
-      if (message.id === messageId) {
-        break;
-      }
       if (message.role === "user") {
         lastUserMessageIndex = i;
       }
+      if (message.id === messageId) {
+        break;
+      }
     }
 
     return lastUserMessageIndex;
   };
 
-  const deleteMessage = (userIndex: number) => {
-    chatStore.updateCurrentSession((session) =>
-      session.messages.splice(userIndex, 2),
+  const deleteMessage = (msgId?: string) => {
+    chatStore.updateCurrentSession(
+      (session) =>
+        (session.messages = session.messages.filter((m) => m.id !== msgId)),
     );
   };
 
-  const onDelete = (botMessageId: number) => {
-    const userIndex = findLastUserIndex(botMessageId);
-    if (userIndex === null) return;
-    deleteMessage(userIndex);
+  const onDelete = (msgId: string) => {
+    deleteMessage(msgId);
   };
 
-  const onResend = (botMessageId: number) => {
-    // find last user input message and resend
-    const userIndex = findLastUserIndex(botMessageId);
-    if (userIndex === null) return;
+  const onResend = (message: ChatMessage) => {
+    let content = message.content;
+
+    if (message.role === "assistant" && message.id) {
+      const userIndex = findLastUserIndex(message.id);
+      if (userIndex) {
+        content = session.messages.at(userIndex)?.content ?? content;
+      }
+    }
 
     setIsLoading(true);
-    const content = session.messages[userIndex].content;
-    deleteMessage(userIndex);
     chatStore.onUserInput(content).then(() => setIsLoading(false));
     inputRef.current?.focus();
   };
 
-  const onPinMessage = (botMessage: ChatMessage) => {
-    if (!botMessage.id) return;
-    const userMessageIndex = findLastUserIndex(botMessage.id);
-    if (userMessageIndex === null) return;
-
-    const userMessage = session.messages[userMessageIndex];
+  const onPinMessage = (message: ChatMessage) => {
     chatStore.updateCurrentSession((session) =>
-      session.mask.context.push(userMessage, botMessage),
+      session.mask.context.push(message),
     );
 
     showToast(Locale.Chat.Actions.PinToastContent, {
@@ -923,11 +922,11 @@ export function Chat() {
       >
         {messages.map((message, i) => {
           const isUser = message.role === "user";
+          const isContext = i < context.length;
           const showActions =
-            !isUser &&
             i > 0 &&
             !(message.preview || message.content.length === 0) &&
-            i >= context.length; // do not show actions for context prompts
+            !isContext;
           const showTyping = message.preview || message.streaming;
 
           const shouldShowClearContextDivider = i === clearContextIndex - 1;
@@ -941,64 +940,38 @@ export function Chat() {
                 }
               >
                 <div className={styles["chat-message-container"]}>
-                  <div className={styles["chat-message-avatar"]}>
-                    <div className={styles["chat-message-edit"]}>
-                      <IconButton
-                        icon={<EditIcon />}
-                        onClick={async () => {
-                          const newMessage = await showPrompt(
-                            Locale.Chat.Actions.Edit,
-                            message.content,
-                            10,
-                          );
-                          chatStore.updateCurrentSession((session) => {
-                            const m = session.messages.find(
-                              (m) => m.id === message.id,
+                  <div className={styles["chat-message-header"]}>
+                    <div className={styles["chat-message-avatar"]}>
+                      <div className={styles["chat-message-edit"]}>
+                        <IconButton
+                          icon={<EditIcon />}
+                          onClick={async () => {
+                            const newMessage = await showPrompt(
+                              Locale.Chat.Actions.Edit,
+                              message.content,
+                              10,
                             );
-                            if (m) {
-                              m.content = newMessage;
-                            }
-                          });
-                        }}
-                      ></IconButton>
-                    </div>
-                    {isUser ? (
-                      <Avatar avatar={config.avatar} />
-                    ) : (
-                      <MaskAvatar mask={session.mask} />
-                    )}
-                  </div>
-                  {showTyping && (
-                    <div className={styles["chat-message-status"]}>
-                      {Locale.Chat.Typing}
+                            chatStore.updateCurrentSession((session) => {
+                              const m = session.messages.find(
+                                (m) => m.id === message.id,
+                              );
+                              if (m) {
+                                m.content = newMessage;
+                              }
+                            });
+                          }}
+                        ></IconButton>
+                      </div>
+                      {isUser ? (
+                        <Avatar avatar={config.avatar} />
+                      ) : (
+                        <MaskAvatar mask={session.mask} />
+                      )}
                     </div>
-                  )}
-                  <div className={styles["chat-message-item"]}>
-                    <Markdown
-                      content={message.content}
-                      loading={
-                        (message.preview || message.content.length === 0) &&
-                        !isUser
-                      }
-                      onContextMenu={(e) => onRightClick(e, message)}
-                      onDoubleClickCapture={() => {
-                        if (!isMobileScreen) return;
-                        setUserInput(message.content);
-                      }}
-                      fontSize={fontSize}
-                      parentRef={scrollRef}
-                      defaultShow={i >= messages.length - 10}
-                    />
 
                     {showActions && (
                       <div className={styles["chat-message-actions"]}>
-                        <div
-                          className={styles["chat-input-actions"]}
-                          style={{
-                            marginTop: 10,
-                            marginBottom: 0,
-                          }}
-                        >
+                        <div className={styles["chat-input-actions"]}>
                           {message.streaming ? (
                             <ChatAction
                               text={Locale.Chat.Actions.Stop}
@@ -1010,7 +983,7 @@ export function Chat() {
                               <ChatAction
                                 text={Locale.Chat.Actions.Retry}
                                 icon={<ResetIcon />}
-                                onClick={() => onResend(message.id ?? i)}
+                                onClick={() => onResend(message)}
                               />
 
                               <ChatAction
@@ -1035,12 +1008,34 @@ export function Chat() {
                       </div>
                     )}
                   </div>
-
-                  {showActions && (
-                    <div className={styles["chat-message-action-date"]}>
-                      {message.date.toLocaleString()}
+                  {showTyping && (
+                    <div className={styles["chat-message-status"]}>
+                      {Locale.Chat.Typing}
                     </div>
                   )}
+                  <div className={styles["chat-message-item"]}>
+                    <Markdown
+                      content={message.content}
+                      loading={
+                        (message.preview || message.content.length === 0) &&
+                        !isUser
+                      }
+                      onContextMenu={(e) => onRightClick(e, message)}
+                      onDoubleClickCapture={() => {
+                        if (!isMobileScreen) return;
+                        setUserInput(message.content);
+                      }}
+                      fontSize={fontSize}
+                      parentRef={scrollRef}
+                      defaultShow={i >= messages.length - 10}
+                    />
+                  </div>
+
+                  <div className={styles["chat-message-action-date"]}>
+                    {isContext
+                      ? Locale.Chat.IsContext
+                      : message.date.toLocaleString()}
+                  </div>
                 </div>
               </div>
               {shouldShowClearContextDivider && <ClearContextDivider />}

+ 3 - 3
app/components/exporter.tsx

@@ -8,7 +8,6 @@ import {
   Modal,
   Select,
   showImageModal,
-  showModal,
   showToast,
 } from "./ui-lib";
 import { IconButton } from "./button";
@@ -149,7 +148,7 @@ export function MessageExporter() {
     if (exportConfig.includeContext) {
       ret.push(...session.mask.context);
     }
-    ret.push(...session.messages.filter((m, i) => selection.has(m.id ?? i)));
+    ret.push(...session.messages.filter((m, i) => selection.has(m.id)));
     return ret;
   }, [
     exportConfig.includeContext,
@@ -244,9 +243,10 @@ export function RenderExport(props: {
       return;
     }
 
-    const renderMsgs = messages.map((v) => {
+    const renderMsgs = messages.map((v, i) => {
       const [_, role] = v.id.split(":");
       return {
+        id: i.toString(),
         role: role as any,
         content: v.innerHTML,
         date: "",

+ 16 - 7
app/components/mask.tsx

@@ -13,7 +13,13 @@ import EyeIcon from "../icons/eye.svg";
 import CopyIcon from "../icons/copy.svg";
 
 import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
-import { ChatMessage, ModelConfig, useAppConfig, useChatStore } from "../store";
+import {
+  ChatMessage,
+  createMessage,
+  ModelConfig,
+  useAppConfig,
+  useChatStore,
+} from "../store";
 import { ROLES } from "../client/api";
 import {
   Input,
@@ -35,6 +41,7 @@ import { Updater } from "../typing";
 import { ModelConfigList } from "./model-config";
 import { FileName, Path } from "../constant";
 import { BUILTIN_MASK_STORE } from "../masks";
+import { nanoid } from "nanoid";
 
 export function MaskAvatar(props: { mask: Mask }) {
   return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
@@ -279,11 +286,13 @@ export function ContextPrompts(props: {
             bordered
             className={chatStyle["context-prompt-button"]}
             onClick={() =>
-              addContextPrompt({
-                role: "user",
-                content: "",
-                date: "",
-              })
+              addContextPrompt(
+                createMessage({
+                  role: "user",
+                  content: "",
+                  date: "",
+                }),
+              )
             }
           />
         </div>
@@ -319,7 +328,7 @@ export function MaskPage() {
     }
   };
 
-  const [editingMaskId, setEditingMaskId] = useState<number | undefined>();
+  const [editingMaskId, setEditingMaskId] = useState<string | undefined>();
   const editingMask =
     maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
   const closeMaskModal = () => setEditingMaskId(undefined);

+ 8 - 8
app/components/message-selector.tsx

@@ -51,9 +51,9 @@ function useShiftRange() {
 }
 
 export function useMessageSelector() {
-  const [selection, setSelection] = useState(new Set<number>());
-  const updateSelection: Updater<Set<number>> = (updater) => {
-    const newSelection = new Set<number>(selection);
+  const [selection, setSelection] = useState(new Set<string>());
+  const updateSelection: Updater<Set<string>> = (updater) => {
+    const newSelection = new Set<string>(selection);
     updater(newSelection);
     setSelection(newSelection);
   };
@@ -65,8 +65,8 @@ export function useMessageSelector() {
 }
 
 export function MessageSelector(props: {
-  selection: Set<number>;
-  updateSelection: Updater<Set<number>>;
+  selection: Set<string>;
+  updateSelection: Updater<Set<string>>;
   defaultSelectAll?: boolean;
   onSelected?: (messages: ChatMessage[]) => void;
 }) {
@@ -83,12 +83,12 @@ export function MessageSelector(props: {
   const config = useAppConfig();
 
   const [searchInput, setSearchInput] = useState("");
-  const [searchIds, setSearchIds] = useState(new Set<number>());
-  const isInSearchResult = (id: number) => {
+  const [searchIds, setSearchIds] = useState(new Set<string>());
+  const isInSearchResult = (id: string) => {
     return searchInput.length === 0 || searchIds.has(id);
   };
   const doSearch = (text: string) => {
-    const searchResults = new Set<number>();
+    const searchResults = new Set<string>();
     if (text.length > 0) {
       messages.forEach((m) =>
         m.content.includes(text) ? searchResults.add(m.id!) : null,

+ 6 - 2
app/components/model-config.tsx

@@ -9,6 +9,10 @@ export function ModelConfigList(props: {
   updateConfig: (updater: (config: ModelConfig) => void) => void;
 }) {
   const config = useAppConfig();
+  const customModels = config.customModels
+    .split(",")
+    .map((m) => ({ name: m, available: true }));
+  const models = config.models.concat(customModels);
 
   return (
     <>
@@ -24,8 +28,8 @@ export function ModelConfigList(props: {
             );
           }}
         >
-          {config.models.map((v) => (
-            <option value={v.name} key={v.name} disabled={!v.available}>
+          {models.map((v, i) => (
+            <option value={v.name} key={i} disabled={!v.available}>
               {v.name}
             </option>
           ))}

+ 1 - 2
app/components/new-chat.tsx

@@ -103,8 +103,7 @@ export function NewChat() {
   useCommand({
     mask: (id) => {
       try {
-        const intId = parseInt(id);
-        const mask = maskStore.get(intId) ?? BUILTIN_MASK_STORE.get(intId);
+        const mask = maskStore.get(id) ?? BUILTIN_MASK_STORE.get(id);
         startChat(mask ?? undefined);
       } catch {
         console.error("[New Chat] failed to create chat from mask id=", id);

+ 44 - 26
app/components/settings.tsx

@@ -48,8 +48,9 @@ import { useNavigate } from "react-router-dom";
 import { Avatar, AvatarPicker } from "./emoji";
 import { getClientConfig } from "../config/client";
 import { useSyncStore } from "../store/sync";
+import { nanoid } from "nanoid";
 
-function EditPromptModal(props: { id: number; onClose: () => void }) {
+function EditPromptModal(props: { id: string; onClose: () => void }) {
   const promptStore = usePromptStore();
   const prompt = promptStore.get(props.id);
 
@@ -107,7 +108,7 @@ function UserPromptModal(props: { onClose?: () => void }) {
   const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
   const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
 
-  const [editingPromptId, setEditingPromptId] = useState<number>();
+  const [editingPromptId, setEditingPromptId] = useState<string>();
 
   useEffect(() => {
     if (searchInput.length > 0) {
@@ -128,6 +129,8 @@ function UserPromptModal(props: { onClose?: () => void }) {
             key="add"
             onClick={() =>
               promptStore.add({
+                id: nanoid(),
+                createdAt: Date.now(),
                 title: "Empty Prompt",
                 content: "Empty Prompt Content",
               })
@@ -315,7 +318,6 @@ export function Settings() {
   const [showEmojiPicker, setShowEmojiPicker] = useState(false);
   const config = useAppConfig();
   const updateConfig = config.update;
-  const chatStore = useChatStore();
 
   const updateStore = useUpdateStore();
   const [checkingUpdate, setCheckingUpdate] = useState(false);
@@ -579,6 +581,38 @@ export function Settings() {
           </ListItem>
         </List>
 
+        <List>
+          <ListItem
+            title={Locale.Settings.Prompt.Disable.Title}
+            subTitle={Locale.Settings.Prompt.Disable.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={config.disablePromptHint}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.disablePromptHint = e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Prompt.List}
+            subTitle={Locale.Settings.Prompt.ListCount(
+              builtinCount,
+              customCount,
+            )}
+          >
+            <IconButton
+              icon={<EditIcon />}
+              text={Locale.Settings.Prompt.Edit}
+              onClick={() => setShowPromptModal(true)}
+            />
+          </ListItem>
+        </List>
+
         <List>
           {showAccessCode ? (
             <ListItem
@@ -654,38 +688,22 @@ export function Settings() {
               )}
             </ListItem>
           ) : null}
-        </List>
 
-        <List>
           <ListItem
-            title={Locale.Settings.Prompt.Disable.Title}
-            subTitle={Locale.Settings.Prompt.Disable.SubTitle}
+            title={Locale.Settings.CustomModel.Title}
+            subTitle={Locale.Settings.CustomModel.SubTitle}
           >
             <input
-              type="checkbox"
-              checked={config.disablePromptHint}
+              type="text"
+              value={config.customModels}
+              placeholder="model1,model2,model3"
               onChange={(e) =>
-                updateConfig(
-                  (config) =>
-                    (config.disablePromptHint = e.currentTarget.checked),
+                config.update(
+                  (config) => (config.customModels = e.currentTarget.value),
                 )
               }
             ></input>
           </ListItem>
-
-          <ListItem
-            title={Locale.Settings.Prompt.List}
-            subTitle={Locale.Settings.Prompt.ListCount(
-              builtinCount,
-              customCount,
-            )}
-          >
-            <IconButton
-              icon={<EditIcon />}
-              text={Locale.Settings.Prompt.Edit}
-              onClick={() => setShowPromptModal(true)}
-            />
-          </ListItem>
         </List>
 
         <SyncItems />

+ 6 - 1
app/locales/cn.ts

@@ -26,7 +26,7 @@ const cn = {
       Stop: "停止",
       Retry: "重试",
       Pin: "固定",
-      PinToastContent: "已将 2 条对话固定至预设提示词",
+      PinToastContent: "已将 1 条对话固定至预设提示词",
       PinToastAction: "查看",
       Delete: "删除",
       Edit: "编辑",
@@ -66,6 +66,7 @@ const cn = {
       Reset: "清除记忆",
       SaveAs: "存为面具",
     },
+    IsContext: "预设提示词",
   },
   Export: {
     Title: "分享聊天记录",
@@ -219,6 +220,10 @@ const cn = {
       Title: "接口地址",
       SubTitle: "除默认地址外,必须包含 http(s)://",
     },
+    CustomModel: {
+      Title: "自定义模型名",
+      SubTitle: "增加自定义模型可选项,使用英文逗号隔开",
+    },
     Model: "模型 (model)",
     Temperature: {
       Title: "随机性 (temperature)",

+ 6 - 1
app/locales/en.ts

@@ -28,7 +28,7 @@ const en: LocaleType = {
       Stop: "Stop",
       Retry: "Retry",
       Pin: "Pin",
-      PinToastContent: "Pinned 2 messages to contextual prompts",
+      PinToastContent: "Pinned 1 messages to contextual prompts",
       PinToastAction: "View",
       Delete: "Delete",
       Edit: "Edit",
@@ -68,6 +68,7 @@ const en: LocaleType = {
       Reset: "Reset to Default",
       SaveAs: "Save as Mask",
     },
+    IsContext: "Contextual Prompt",
   },
   Export: {
     Title: "Export Messages",
@@ -221,6 +222,10 @@ const en: LocaleType = {
       Title: "Endpoint",
       SubTitle: "Custom endpoint must start with http(s)://",
     },
+    CustomModel: {
+      Title: "Custom Models",
+      SubTitle: "Add extra model options, separate by comma",
+    },
     Model: "Model",
     Temperature: {
       Title: "Temperature",

File diff suppressed because it is too large
+ 43 - 1
app/masks/cn.ts


File diff suppressed because it is too large
+ 1 - 0
app/masks/en.ts


+ 2 - 2
app/masks/index.ts

@@ -9,8 +9,8 @@ export const BUILTIN_MASK_ID = 100000;
 
 export const BUILTIN_MASK_STORE = {
   buildinId: BUILTIN_MASK_ID,
-  masks: {} as Record<number, BuiltinMask>,
-  get(id?: number) {
+  masks: {} as Record<string, BuiltinMask>,
+  get(id?: string) {
     if (!id) return undefined;
     return this.masks[id] as Mask | undefined;
   },

+ 24 - 23
app/store/chat.ts

@@ -16,18 +16,19 @@ import { api, RequestMessage } from "../client/api";
 import { ChatControllerPool } from "../client/controller";
 import { prettyObject } from "../utils/format";
 import { estimateTokenLength } from "../utils/token";
+import { nanoid } from "nanoid";
 
 export type ChatMessage = RequestMessage & {
   date: string;
   streaming?: boolean;
   isError?: boolean;
-  id?: number;
+  id: string;
   model?: ModelType;
 };
 
 export function createMessage(override: Partial<ChatMessage>): ChatMessage {
   return {
-    id: Date.now(),
+    id: nanoid(),
     date: new Date().toLocaleString(),
     role: "user",
     content: "",
@@ -42,7 +43,7 @@ export interface ChatStat {
 }
 
 export interface ChatSession {
-  id: number;
+  id: string;
   topic: string;
 
   memoryPrompt: string;
@@ -63,7 +64,7 @@ export const BOT_HELLO: ChatMessage = createMessage({
 
 function createEmptySession(): ChatSession {
   return {
-    id: Date.now() + Math.random(),
+    id: nanoid(),
     topic: DEFAULT_TOPIC,
     memoryPrompt: "",
     messages: [],
@@ -82,7 +83,6 @@ function createEmptySession(): ChatSession {
 interface ChatStore {
   sessions: ChatSession[];
   currentSessionIndex: number;
-  globalId: number;
   clearSessions: () => void;
   moveSession: (from: number, to: number) => void;
   selectSession: (index: number) => void;
@@ -139,7 +139,6 @@ export const useChatStore = create<ChatStore>()(
     (set, get) => ({
       sessions: [createEmptySession()],
       currentSessionIndex: 0,
-      globalId: 0,
 
       clearSessions() {
         set(() => ({
@@ -182,9 +181,6 @@ export const useChatStore = create<ChatStore>()(
       newSession(mask) {
         const session = createEmptySession();
 
-        set(() => ({ globalId: get().globalId + 1 }));
-        session.id = get().globalId;
-
         if (mask) {
           const config = useAppConfig.getState();
           const globalModelConfig = config.modelConfig;
@@ -300,7 +296,6 @@ export const useChatStore = create<ChatStore>()(
         // get recent messages
         const recentMessages = get().getMessagesWithMemory();
         const sendMessages = recentMessages.concat(userMessage);
-        const sessionIndex = get().currentSessionIndex;
         const messageIndex = get().currentSession().messages.length + 1;
 
         // save user's and bot's message
@@ -334,10 +329,7 @@ export const useChatStore = create<ChatStore>()(
               botMessage.content = message;
               get().onNewMessage(botMessage);
             }
-            ChatControllerPool.remove(
-              sessionIndex,
-              botMessage.id ?? messageIndex,
-            );
+            ChatControllerPool.remove(session.id, botMessage.id);
           },
           onError(error) {
             const isAborted = error.message.includes("aborted");
@@ -354,7 +346,7 @@ export const useChatStore = create<ChatStore>()(
               session.messages = session.messages.concat();
             });
             ChatControllerPool.remove(
-              sessionIndex,
+              session.id,
               botMessage.id ?? messageIndex,
             );
 
@@ -363,7 +355,7 @@ export const useChatStore = create<ChatStore>()(
           onController(controller) {
             // collect controller for stop/retry
             ChatControllerPool.addController(
-              sessionIndex,
+              session.id,
               botMessage.id ?? messageIndex,
               controller,
             );
@@ -556,11 +548,13 @@ export const useChatStore = create<ChatStore>()(
           modelConfig.sendMemory
         ) {
           api.llm.chat({
-            messages: toBeSummarizedMsgs.concat({
-              role: "system",
-              content: Locale.Store.Prompt.Summarize,
-              date: "",
-            }),
+            messages: toBeSummarizedMsgs.concat(
+              createMessage({
+                role: "system",
+                content: Locale.Store.Prompt.Summarize,
+                date: "",
+              }),
+            ),
             config: { ...modelConfig, stream: true },
             onUpdate(message) {
               session.memoryPrompt = message;
@@ -597,13 +591,12 @@ export const useChatStore = create<ChatStore>()(
     }),
     {
       name: StoreKey.Chat,
-      version: 2,
+      version: 3,
       migrate(persistedState, version) {
         const state = persistedState as any;
         const newState = JSON.parse(JSON.stringify(state)) as ChatStore;
 
         if (version < 2) {
-          newState.globalId = 0;
           newState.sessions = [];
 
           const oldSessions = state.sessions;
@@ -618,6 +611,14 @@ export const useChatStore = create<ChatStore>()(
           }
         }
 
+        if (version < 3) {
+          // migrate id to nanoid
+          newState.sessions.forEach((s) => {
+            s.id = nanoid();
+            s.messages.forEach((m) => (m.id = nanoid()));
+          });
+        }
+
         return newState;
       },
     },

+ 23 - 13
app/store/config.ts

@@ -34,6 +34,7 @@ export const DEFAULT_CONFIG = {
   dontShowMaskSplashScreen: false, // dont show splash screen when create chat
   hideBuiltinMasks: false, // dont add builtin masks
 
+  customModels: "",
   models: DEFAULT_MODELS as any as LLMModel[],
 
   modelConfig: {
@@ -117,6 +118,10 @@ export const useAppConfig = create<ChatConfigStore>()(
       },
 
       mergeModels(newModels) {
+        if (!newModels || newModels.length === 0) {
+          return;
+        }
+
         const oldModels = get().models;
         const modelMap: Record<string, LLMModel> = {};
 
@@ -137,21 +142,26 @@ export const useAppConfig = create<ChatConfigStore>()(
     }),
     {
       name: StoreKey.Config,
-      version: 3.4,
+      version: 3.5,
       migrate(persistedState, version) {
-        if (version === 3.4) return persistedState as any;
-
         const state = persistedState as ChatConfig;
-        state.modelConfig.sendMemory = true;
-        state.modelConfig.historyMessageCount = 4;
-        state.modelConfig.compressMessageLengthThreshold = 1000;
-        state.modelConfig.frequency_penalty = 0;
-        state.modelConfig.top_p = 1;
-        state.modelConfig.template = DEFAULT_INPUT_TEMPLATE;
-        state.dontShowMaskSplashScreen = false;
-        state.hideBuiltinMasks = false;
-
-        return state;
+
+        if (version < 3.4) {
+          state.modelConfig.sendMemory = true;
+          state.modelConfig.historyMessageCount = 4;
+          state.modelConfig.compressMessageLengthThreshold = 1000;
+          state.modelConfig.frequency_penalty = 0;
+          state.modelConfig.top_p = 1;
+          state.modelConfig.template = DEFAULT_INPUT_TEMPLATE;
+          state.dontShowMaskSplashScreen = false;
+          state.hideBuiltinMasks = false;
+        }
+
+        if (version < 3.5) {
+          state.customModels = "claude,claude-100k";
+        }
+
+        return state as any;
       },
     },
   ),

+ 23 - 12
app/store/mask.ts

@@ -5,9 +5,11 @@ import { getLang, Lang } from "../locales";
 import { DEFAULT_TOPIC, ChatMessage } from "./chat";
 import { ModelConfig, useAppConfig } from "./config";
 import { StoreKey } from "../constant";
+import { nanoid } from "nanoid";
 
 export type Mask = {
-  id: number;
+  id: string;
+  createdAt: number;
   avatar: string;
   name: string;
   hideContext?: boolean;
@@ -19,25 +21,23 @@ export type Mask = {
 };
 
 export const DEFAULT_MASK_STATE = {
-  masks: {} as Record<number, Mask>,
-  globalMaskId: 0,
+  masks: {} as Record<string, Mask>,
 };
 
 export type MaskState = typeof DEFAULT_MASK_STATE;
 type MaskStore = MaskState & {
   create: (mask?: Partial<Mask>) => Mask;
-  update: (id: number, updater: (mask: Mask) => void) => void;
-  delete: (id: number) => void;
+  update: (id: string, updater: (mask: Mask) => void) => void;
+  delete: (id: string) => void;
   search: (text: string) => Mask[];
-  get: (id?: number) => Mask | null;
+  get: (id?: string) => Mask | null;
   getAll: () => Mask[];
 };
 
-export const DEFAULT_MASK_ID = 1145141919810;
 export const DEFAULT_MASK_AVATAR = "gpt-bot";
 export const createEmptyMask = () =>
   ({
-    id: DEFAULT_MASK_ID,
+    id: nanoid(),
     avatar: DEFAULT_MASK_AVATAR,
     name: DEFAULT_TOPIC,
     context: [],
@@ -45,6 +45,7 @@ export const createEmptyMask = () =>
     modelConfig: { ...useAppConfig.getState().modelConfig },
     lang: getLang(),
     builtin: false,
+    createdAt: Date.now(),
   } as Mask);
 
 export const useMaskStore = create<MaskStore>()(
@@ -53,9 +54,8 @@ export const useMaskStore = create<MaskStore>()(
       ...DEFAULT_MASK_STATE,
 
       create(mask) {
-        set(() => ({ globalMaskId: get().globalMaskId + 1 }));
-        const id = get().globalMaskId;
         const masks = get().masks;
+        const id = nanoid();
         masks[id] = {
           ...createEmptyMask(),
           ...mask,
@@ -87,7 +87,7 @@ export const useMaskStore = create<MaskStore>()(
       },
       getAll() {
         const userMasks = Object.values(get().masks).sort(
-          (a, b) => b.id - a.id,
+          (a, b) => b.createdAt - a.createdAt,
         );
         const config = useAppConfig.getState();
         if (config.hideBuiltinMasks) return userMasks;
@@ -109,7 +109,18 @@ export const useMaskStore = create<MaskStore>()(
     }),
     {
       name: StoreKey.Mask,
-      version: 2,
+      version: 3,
+
+      migrate(state, version) {
+        const newState = JSON.parse(JSON.stringify(state)) as MaskState;
+
+        // migrate mask id to nanoid
+        if (version < 3) {
+          Object.values(newState.masks).forEach((m) => (m.id = nanoid()));
+        }
+
+        return newState as any;
+      },
     },
   ),
 );

+ 29 - 13
app/store/prompt.ts

@@ -3,24 +3,25 @@ import { persist } from "zustand/middleware";
 import Fuse from "fuse.js";
 import { getLang } from "../locales";
 import { StoreKey } from "../constant";
+import { nanoid } from "nanoid";
 
 export interface Prompt {
-  id?: number;
+  id: string;
   isUser?: boolean;
   title: string;
   content: string;
+  createdAt: number;
 }
 
 export interface PromptStore {
   counter: number;
-  latestId: number;
-  prompts: Record<number, Prompt>;
+  prompts: Record<string, Prompt>;
 
-  add: (prompt: Prompt) => number;
-  get: (id: number) => Prompt | undefined;
-  remove: (id: number) => void;
+  add: (prompt: Prompt) => string;
+  get: (id: string) => Prompt | undefined;
+  remove: (id: string) => void;
   search: (text: string) => Prompt[];
-  update: (id: number, updater: (prompt: Prompt) => void) => void;
+  update: (id: string, updater: (prompt: Prompt) => void) => void;
 
   getUserPrompts: () => Prompt[];
 }
@@ -46,7 +47,7 @@ export const SearchService = {
     this.ready = true;
   },
 
-  remove(id: number) {
+  remove(id: string) {
     this.userEngine.remove((doc) => doc.id === id);
   },
 
@@ -70,8 +71,9 @@ export const usePromptStore = create<PromptStore>()(
 
       add(prompt) {
         const prompts = get().prompts;
-        prompt.id = get().latestId + 1;
+        prompt.id = nanoid();
         prompt.isUser = true;
+        prompt.createdAt = Date.now();
         prompts[prompt.id] = prompt;
 
         set(() => ({
@@ -105,11 +107,13 @@ export const usePromptStore = create<PromptStore>()(
 
       getUserPrompts() {
         const userPrompts = Object.values(get().prompts ?? {});
-        userPrompts.sort((a, b) => (b.id && a.id ? b.id - a.id : 0));
+        userPrompts.sort((a, b) =>
+          b.id && a.id ? b.createdAt - a.createdAt : 0,
+        );
         return userPrompts;
       },
 
-      update(id: number, updater) {
+      update(id, updater) {
         const prompt = get().prompts[id] ?? {
           title: "",
           content: "",
@@ -134,7 +138,18 @@ export const usePromptStore = create<PromptStore>()(
     }),
     {
       name: StoreKey.Prompt,
-      version: 1,
+      version: 3,
+
+      migrate(state, version) {
+        const newState = JSON.parse(JSON.stringify(state)) as PromptStore;
+
+        if (version < 3) {
+          Object.values(newState.prompts).forEach((p) => (p.id = nanoid()));
+        }
+
+        return newState;
+      },
+
       onRehydrateStorage(state) {
         const PROMPT_URL = "./prompts.json";
 
@@ -152,9 +167,10 @@ export const usePromptStore = create<PromptStore>()(
                 return promptList.map(
                   ([title, content]) =>
                     ({
-                      id: Math.random(),
+                      id: nanoid(),
                       title,
                       content,
+                      createdAt: Date.now(),
                     } as Prompt),
                 );
               },

+ 12 - 0
docs/translation.md

@@ -0,0 +1,12 @@
+# How to add a new translation?
+
+Assume that we are adding a new translation for `new`.
+
+1. copy `app/locales/en.ts` to `app/locales/new.ts`;
+2. edit `new.ts`, change `const en: LocaleType = ` to `const new: PartialLocaleType`, and `export default new;`;
+3. edit `app/locales/index.ts`:
+4. `import new from './new.ts'`;
+5. add `new` to `ALL_LANGS`;
+6. add `new: "new lang"` to `ALL_LANG_OPTIONS`;
+7. translate the strings in `new.ts`;
+8. submit a pull request, and the author will merge it.

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
     "fuse.js": "^6.6.2",
     "html-to-image": "^1.11.11",
     "mermaid": "^10.2.3",
+    "nanoid": "^4.0.2",
     "next": "^13.4.6",
     "node-fetch": "^3.3.1",
     "react": "^18.2.0",

+ 5 - 0
yarn.lock

@@ -4639,6 +4639,11 @@ nanoid@^3.3.4:
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
   integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
 
+nanoid@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.npmmirror.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
+  integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
+
 natural-compare@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"

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