Yidadaa пре 2 година
родитељ
комит
ffa7302571

+ 2 - 0
app/api/openai/typing.ts

@@ -5,3 +5,5 @@ import type {
 
 export type ChatRequest = CreateChatCompletionRequest;
 export type ChatResponse = CreateChatCompletionResponse;
+
+export type Updater<T> = (updater: (value: T) => void) => void;

+ 11 - 6
app/components/button.tsx

@@ -4,7 +4,7 @@ import styles from "./button.module.scss";
 
 export function IconButton(props: {
   onClick?: () => void;
-  icon: JSX.Element;
+  icon?: JSX.Element;
   text?: string;
   bordered?: boolean;
   shadow?: boolean;
@@ -26,11 +26,16 @@ export function IconButton(props: {
       disabled={props.disabled}
       role="button"
     >
-      <div
-        className={styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`}
-      >
-        {props.icon}
-      </div>
+      {props.icon && (
+        <div
+          className={
+            styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`
+          }
+        >
+          {props.icon}
+        </div>
+      )}
+
       {props.text && (
         <div className={styles["icon-button-text"]}>{props.text}</div>
       )}

+ 36 - 167
app/components/chat.tsx

@@ -57,6 +57,8 @@ import { useNavigate } from "react-router-dom";
 import { Path } from "../constant";
 import { ModelConfigList } from "./model-config";
 import { Avatar, AvatarPicker } from "./emoji";
+import { MaskConfig } from "./mask";
+import { DEFAULT_MASK_ID } from "../store/mask";
 
 const Markdown = dynamic(
   async () => memo((await import("./markdown")).Markdown),
@@ -103,103 +105,10 @@ function exportMessages(messages: Message[], topic: string) {
   });
 }
 
-function ContextPrompts() {
-  const chatStore = useChatStore();
-  const session = chatStore.currentSession();
-  const context = session.context;
-
-  const addContextPrompt = (prompt: Message) => {
-    chatStore.updateCurrentSession((session) => {
-      session.context.push(prompt);
-    });
-  };
-
-  const removeContextPrompt = (i: number) => {
-    chatStore.updateCurrentSession((session) => {
-      session.context.splice(i, 1);
-    });
-  };
-
-  const updateContextPrompt = (i: number, prompt: Message) => {
-    chatStore.updateCurrentSession((session) => {
-      session.context[i] = prompt;
-    });
-  };
-
-  return (
-    <>
-      <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
-        {context.map((c, i) => (
-          <div className={chatStyle["context-prompt-row"]} key={i}>
-            <select
-              value={c.role}
-              className={chatStyle["context-role"]}
-              onChange={(e) =>
-                updateContextPrompt(i, {
-                  ...c,
-                  role: e.target.value as any,
-                })
-              }
-            >
-              {ROLES.map((r) => (
-                <option key={r} value={r}>
-                  {r}
-                </option>
-              ))}
-            </select>
-            <Input
-              value={c.content}
-              type="text"
-              className={chatStyle["context-content"]}
-              rows={1}
-              onInput={(e) =>
-                updateContextPrompt(i, {
-                  ...c,
-                  content: e.currentTarget.value as any,
-                })
-              }
-            />
-            <IconButton
-              icon={<DeleteIcon />}
-              className={chatStyle["context-delete-button"]}
-              onClick={() => removeContextPrompt(i)}
-              bordered
-            />
-          </div>
-        ))}
-
-        <div className={chatStyle["context-prompt-row"]}>
-          <IconButton
-            icon={<AddIcon />}
-            text={Locale.Context.Add}
-            bordered
-            className={chatStyle["context-prompt-button"]}
-            onClick={() =>
-              addContextPrompt({
-                role: "system",
-                content: "",
-                date: "",
-              })
-            }
-          />
-        </div>
-      </div>
-    </>
-  );
-}
-
 export function SessionConfigModel(props: { onClose: () => void }) {
   const chatStore = useChatStore();
   const session = chatStore.currentSession();
 
-  const [showPicker, setShowPicker] = useState(false);
-
-  const updateConfig = (updater: (config: ModelConfig) => void) => {
-    const config = { ...session.modelConfig };
-    updater(config);
-    chatStore.updateCurrentSession((session) => (session.modelConfig = config));
-  };
-
   return (
     <div className="modal-mask">
       <Modal
@@ -210,7 +119,7 @@ export function SessionConfigModel(props: { onClose: () => void }) {
             key="reset"
             icon={<CopyIcon />}
             bordered
-            text="重置预设"
+            text="重置"
             onClick={() =>
               confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
             }
@@ -219,69 +128,29 @@ export function SessionConfigModel(props: { onClose: () => void }) {
             key="copy"
             icon={<CopyIcon />}
             bordered
-            text="保存预设"
+            text="保存为面具"
             onClick={() => copyToClipboard(session.memoryPrompt)}
           />,
         ]}
       >
-        <ContextPrompts />
-
-        <List>
-          <ListItem title={"角色头像"}>
-            <Popover
-              content={
-                <AvatarPicker
-                  onEmojiClick={(emoji) =>
-                    chatStore.updateCurrentSession(
-                      (session) => (session.avatar = emoji),
-                    )
-                  }
-                ></AvatarPicker>
-              }
-              open={showPicker}
-              onClose={() => setShowPicker(false)}
-            >
-              <div
-                onClick={() => setShowPicker(true)}
-                style={{ cursor: "pointer" }}
-              >
-                {session.avatar ? (
-                  <Avatar avatar={session.avatar} />
-                ) : (
-                  <Avatar model={session.modelConfig.model} />
-                )}
-              </div>
-            </Popover>
-          </ListItem>
-          <ListItem title={"对话标题"}>
-            <input
-              type="text"
-              value={session.topic}
-              onInput={(e) =>
-                chatStore.updateCurrentSession(
-                  (session) => (session.topic = e.currentTarget.value),
-                )
-              }
-            ></input>
-          </ListItem>
-        </List>
-
-        <List>
-          <ModelConfigList
-            modelConfig={session.modelConfig}
-            updateConfig={updateConfig}
-          />
-
-          {session.modelConfig.sendMemory ? (
-            <ListItem
-              title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of 
-          ${session.messages.length})`}
-              subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
-            ></ListItem>
-          ) : (
-            <></>
-          )}
-        </List>
+        <MaskConfig
+          mask={session.mask}
+          updateMask={(updater) => {
+            const mask = { ...session.mask };
+            updater(mask);
+            chatStore.updateCurrentSession((session) => (session.mask = mask));
+          }}
+          extraListItems={
+            session.mask.modelConfig.sendMemory ? (
+              <ListItem
+                title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
+                subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
+              ></ListItem>
+            ) : (
+              <></>
+            )
+          }
+        ></MaskConfig>
       </Modal>
     </div>
   );
@@ -294,7 +163,7 @@ function PromptToast(props: {
 }) {
   const chatStore = useChatStore();
   const session = chatStore.currentSession();
-  const context = session.context;
+  const context = session.mask.context;
 
   return (
     <div className={chatStyle["prompt-toast"]} key="prompt-toast">
@@ -617,7 +486,7 @@ export function Chat() {
     inputRef.current?.focus();
   };
 
-  const context: RenderMessage[] = session.context.slice();
+  const context: RenderMessage[] = session.mask.context.slice();
 
   const accessStore = useAccessStore();
 
@@ -680,20 +549,20 @@ export function Chat() {
 
   return (
     <div className={styles.chat} key={session.id}>
-      <div className={styles["window-header"]}>
-        <div className={styles["window-header-title"]}>
+      <div className="window-header">
+        <div className="window-header-title">
           <div
-            className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
+            className={`window-header-main-title " ${styles["chat-body-title"]}`}
             onClickCapture={renameSession}
           >
             {!session.topic ? DEFAULT_TOPIC : session.topic}
           </div>
-          <div className={styles["window-header-sub-title"]}>
+          <div className="window-header-sub-title">
             {Locale.Chat.SubTitle(session.messages.length)}
           </div>
         </div>
-        <div className={styles["window-actions"]}>
-          <div className={styles["window-action-button"] + " " + styles.mobile}>
+        <div className="window-actions">
+          <div className={"window-action-button" + " " + styles.mobile}>
             <IconButton
               icon={<ReturnIcon />}
               bordered
@@ -701,14 +570,14 @@ export function Chat() {
               onClick={() => navigate(Path.Home)}
             />
           </div>
-          <div className={styles["window-action-button"]}>
+          <div className="window-action-button">
             <IconButton
               icon={<RenameIcon />}
               bordered
               onClick={renameSession}
             />
           </div>
-          <div className={styles["window-action-button"]}>
+          <div className="window-action-button">
             <IconButton
               icon={<ExportIcon />}
               bordered
@@ -722,7 +591,7 @@ export function Chat() {
             />
           </div>
           {!isMobileScreen && (
-            <div className={styles["window-action-button"]}>
+            <div className="window-action-button">
               <IconButton
                 icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
                 bordered
@@ -773,10 +642,10 @@ export function Chat() {
                 <div className={styles["chat-message-avatar"]}>
                   {message.role === "user" ? (
                     <Avatar avatar={config.avatar} />
-                  ) : session.avatar ? (
-                    <Avatar avatar={session.avatar} />
-                  ) : (
+                  ) : session.mask.id === DEFAULT_MASK_ID ? (
                     <Avatar model={message.model ?? "gpt-3.5-turbo"} />
+                  ) : (
+                    <Avatar avatar={session.mask.avatar} />
                   )}
                 </div>
                 {showTyping && (

+ 0 - 3
app/components/home.module.scss

@@ -1,6 +1,3 @@
-@import "./window.scss";
-@import "../styles/animation.scss";
-
 @mixin container {
   background-color: var(--white);
   border: var(--border-in-light);

+ 5 - 0
app/components/home.tsx

@@ -45,6 +45,10 @@ const NewChat = dynamic(async () => (await import("./new-chat")).NewChat, {
   loading: () => <Loading noLogo />,
 });
 
+const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
+  loading: () => <Loading noLogo />,
+});
+
 export function useSwitchTheme() {
   const config = useAppConfig();
 
@@ -109,6 +113,7 @@ function Screen() {
         <Routes>
           <Route path={Path.Home} element={<Chat />} />
           <Route path={Path.NewChat} element={<NewChat />} />
+          <Route path={Path.Masks} element={<MaskPage />} />
           <Route path={Path.Chat} element={<Chat />} />
           <Route path={Path.Settings} element={<Settings />} />
         </Routes>

+ 33 - 0
app/components/mask.module.scss

@@ -0,0 +1,33 @@
+.mask-page {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .mask-page-body {
+    padding: 20px;
+    overflow-y: auto;
+
+    .search-bar {
+      width: 100%;
+      max-width: 100%;
+      margin-bottom: 20px;
+    }
+
+    .mask-item {
+      .mask-icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border: var(--border-in-light);
+        border-radius: 10px;
+        padding: 6px;
+      }
+
+      .mask-actions {
+        display: flex;
+        flex-wrap: nowrap;
+        transition: all ease 0.3s;
+      }
+    }
+  }
+}

+ 258 - 0
app/components/mask.tsx

@@ -0,0 +1,258 @@
+import { IconButton } from "./button";
+import { ErrorBoundary } from "./error";
+
+import styles from "./mask.module.scss";
+
+import DownloadIcon from "../icons/download.svg";
+import EditIcon from "../icons/edit.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import DeleteIcon from "../icons/delete.svg";
+import CopyIcon from "../icons/copy.svg";
+
+import { DEFAULT_MASK_AVATAR, DEFAULT_MASK_ID, Mask } from "../store/mask";
+import {
+  Message,
+  ModelConfig,
+  ROLES,
+  useAppConfig,
+  useChatStore,
+} from "../store";
+import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
+import { Avatar, AvatarPicker, EmojiAvatar } from "./emoji";
+import Locale from "../locales";
+import { useNavigate } from "react-router-dom";
+
+import chatStyle from "./chat.module.scss";
+import { useState } from "react";
+import { copyToClipboard } from "../utils";
+import { Updater } from "../api/openai/typing";
+import { ModelConfigList } from "./model-config";
+
+export function MaskConfig(props: {
+  mask: Mask;
+  updateMask: Updater<Mask>;
+  extraListItems?: JSX.Element;
+}) {
+  const [showPicker, setShowPicker] = useState(false);
+
+  const updateConfig = (updater: (config: ModelConfig) => void) => {
+    const config = { ...props.mask.modelConfig };
+    updater(config);
+    props.updateMask((mask) => (mask.modelConfig = config));
+  };
+
+  return (
+    <>
+      <ContextPrompts
+        context={props.mask.context}
+        updateContext={(updater) => {
+          const context = props.mask.context.slice();
+          updater(context);
+          props.updateMask((mask) => (mask.context = context));
+        }}
+      />
+
+      <List>
+        <ListItem title={"角色头像"}>
+          <Popover
+            content={
+              <AvatarPicker
+                onEmojiClick={(emoji) => {
+                  props.updateMask((mask) => (mask.avatar = emoji));
+                  setShowPicker(false);
+                }}
+              ></AvatarPicker>
+            }
+            open={showPicker}
+            onClose={() => setShowPicker(false)}
+          >
+            <div
+              onClick={() => setShowPicker(true)}
+              style={{ cursor: "pointer" }}
+            >
+              {props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
+                <Avatar avatar={props.mask.avatar} />
+              ) : (
+                <Avatar model={props.mask.modelConfig.model} />
+              )}
+            </div>
+          </Popover>
+        </ListItem>
+        <ListItem title={"角色名称"}>
+          <input
+            type="text"
+            value={props.mask.name}
+            onInput={(e) =>
+              props.updateMask((mask) => (mask.name = e.currentTarget.value))
+            }
+          ></input>
+        </ListItem>
+      </List>
+
+      <List>
+        <ModelConfigList
+          modelConfig={{ ...props.mask.modelConfig }}
+          updateConfig={updateConfig}
+        />
+        {props.extraListItems}
+      </List>
+    </>
+  );
+}
+
+export function ContextPrompts(props: {
+  context: Message[];
+  updateContext: (updater: (context: Message[]) => void) => void;
+}) {
+  const context = props.context;
+
+  const addContextPrompt = (prompt: Message) => {
+    props.updateContext((context) => context.push(prompt));
+  };
+
+  const removeContextPrompt = (i: number) => {
+    props.updateContext((context) => context.splice(i, 1));
+  };
+
+  const updateContextPrompt = (i: number, prompt: Message) => {
+    props.updateContext((context) => (context[i] = prompt));
+  };
+
+  return (
+    <>
+      <div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
+        {context.map((c, i) => (
+          <div className={chatStyle["context-prompt-row"]} key={i}>
+            <select
+              value={c.role}
+              className={chatStyle["context-role"]}
+              onChange={(e) =>
+                updateContextPrompt(i, {
+                  ...c,
+                  role: e.target.value as any,
+                })
+              }
+            >
+              {ROLES.map((r) => (
+                <option key={r} value={r}>
+                  {r}
+                </option>
+              ))}
+            </select>
+            <Input
+              value={c.content}
+              type="text"
+              className={chatStyle["context-content"]}
+              rows={1}
+              onInput={(e) =>
+                updateContextPrompt(i, {
+                  ...c,
+                  content: e.currentTarget.value as any,
+                })
+              }
+            />
+            <IconButton
+              icon={<DeleteIcon />}
+              className={chatStyle["context-delete-button"]}
+              onClick={() => removeContextPrompt(i)}
+              bordered
+            />
+          </div>
+        ))}
+
+        <div className={chatStyle["context-prompt-row"]}>
+          <IconButton
+            icon={<AddIcon />}
+            text={Locale.Context.Add}
+            bordered
+            className={chatStyle["context-prompt-button"]}
+            onClick={() =>
+              addContextPrompt({
+                role: "system",
+                content: "",
+                date: "",
+              })
+            }
+          />
+        </div>
+      </div>
+    </>
+  );
+}
+
+export function MaskPage() {
+  const config = useAppConfig();
+  const navigate = useNavigate();
+  const masks: Mask[] = new Array(10).fill(0).map((m, i) => ({
+    id: i,
+    avatar: "1f606",
+    name: "预设角色 " + i.toString(),
+    context: [
+      { role: "assistant", content: "你好,有什么可以帮忙的吗", date: "" },
+    ],
+    modelConfig: config.modelConfig,
+    lang: "cn",
+  }));
+
+  return (
+    <ErrorBoundary>
+      <div className={styles["mask-page"]}>
+        <div className="window-header">
+          <div className="window-header-title">
+            <div className="window-header-main-title">预设角色面具</div>
+            <div className="window-header-submai-title">编辑预设角色定义</div>
+          </div>
+
+          <div className="window-actions">
+            <div className="window-action-button">
+              <IconButton icon={<AddIcon />} bordered />
+            </div>
+            <div className="window-action-button">
+              <IconButton icon={<DownloadIcon />} bordered />
+            </div>
+            <div className="window-action-button">
+              <IconButton
+                icon={<CloseIcon />}
+                bordered
+                onClick={() => navigate(-1)}
+              />
+            </div>
+          </div>
+        </div>
+
+        <div className={styles["mask-page-body"]}>
+          <input
+            type="text"
+            className={styles["search-bar"]}
+            placeholder="搜索面具"
+          />
+
+          <List>
+            {masks.map((m) => (
+              <ListItem
+                title={m.name}
+                key={m.id}
+                subTitle={`包含 ${m.context.length} 条预设对话 / ${
+                  Locale.Settings.Lang.Options[m.lang]
+                } / ${m.modelConfig.model}`}
+                icon={
+                  <div className={styles["mask-icon"]}>
+                    <EmojiAvatar avatar={m.avatar} size={20} />
+                  </div>
+                }
+                className={styles["mask-item"]}
+              >
+                <div className={styles["mask-actions"]}>
+                  <IconButton icon={<AddIcon />} text="对话" />
+                  <IconButton icon={<EditIcon />} text="编辑" />
+                  <IconButton icon={<DeleteIcon />} text="删除" />
+                </div>
+              </ListItem>
+            ))}
+          </List>
+        </div>
+      </div>
+    </ErrorBoundary>
+  );
+}

+ 1 - 1
app/components/model-config.tsx

@@ -97,7 +97,7 @@ export function ModelConfigList(props: {
           title={props.modelConfig.historyMessageCount.toString()}
           value={props.modelConfig.historyMessageCount}
           min="0"
-          max="25"
+          max="32"
           step="1"
           onChange={(e) =>
             props.updateConfig(

+ 20 - 5
app/components/new-chat.module.scss

@@ -1,3 +1,5 @@
+@import "../styles/animation.scss";
+
 .new-chat {
   height: 100%;
   width: 100%;
@@ -5,11 +7,21 @@
   align-items: center;
   justify-content: center;
   flex-direction: column;
-  padding-top: 80px;
+
+  .mask-header {
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+    padding: 10px;
+    box-sizing: border-box;
+    animation: slide-in-from-top ease 0.3s;
+  }
 
   .mask-cards {
     display: flex;
+    margin-top: 5vh;
     margin-bottom: 20px;
+    animation: slide-in ease 0.3s;
 
     .mask-card {
       padding: 20px 10px;
@@ -32,15 +44,18 @@
   .title {
     font-size: 32px;
     font-weight: bolder;
-    animation: slide-in ease 0.3s;
+    margin-bottom: 1vh;
+    animation: slide-in ease 0.35s;
   }
 
   .sub-title {
-    animation: slide-in ease 0.3s;
+    animation: slide-in ease 0.4s;
   }
 
   .search-bar {
-    margin-top: 20px;
+    margin-top: 5vh;
+    margin-bottom: 5vh;
+    animation: slide-in ease 0.45s;
   }
 
   .masks {
@@ -50,7 +65,7 @@
     align-items: center;
     padding-top: 20px;
 
-    animation: slide-in ease 0.3s;
+    animation: slide-in ease 0.5s;
 
     .mask-row {
       margin-bottom: 10px;

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

@@ -1,7 +1,10 @@
 import { useEffect, useRef } from "react";
 import { SlotID } from "../constant";
+import { IconButton } from "./button";
 import { EmojiAvatar } from "./emoji";
 import styles from "./new-chat.module.scss";
+import LeftIcon from "../icons/left.svg";
+import { useNavigate } from "react-router-dom";
 
 function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
   const xmin = Math.max(aRect.x, bRect.x);
@@ -59,8 +62,18 @@ export function NewChat() {
     })),
   );
 
+  const navigate = useNavigate();
+
   return (
     <div className={styles["new-chat"]}>
+      <div className={styles["mask-header"]}>
+        <IconButton
+          icon={<LeftIcon />}
+          text="返回"
+          onClick={() => navigate(-1)}
+        ></IconButton>
+        <IconButton text="跳过"></IconButton>
+      </div>
       <div className={styles["mask-cards"]}>
         <div className={styles["mask-card"]}>
           <EmojiAvatar avatar="1f606" size={24} />
@@ -74,7 +87,9 @@ export function NewChat() {
       </div>
 
       <div className={styles["title"]}>挑选一个面具</div>
-      <div className={styles["sub-title"]}>现在开始,与面具背后的思维碰撞</div>
+      <div className={styles["sub-title"]}>
+        现在开始,与面具背后的灵魂思维碰撞
+      </div>
 
       <input className={styles["search-bar"]} placeholder="搜索" type="text" />
 

+ 0 - 2
app/components/settings.module.scss

@@ -1,5 +1,3 @@
-@import "./window.scss";
-
 .settings {
   padding: 20px;
   overflow: auto;

+ 8 - 8
app/components/settings.tsx

@@ -202,17 +202,17 @@ export function Settings() {
 
   return (
     <ErrorBoundary>
-      <div className={styles["window-header"]}>
-        <div className={styles["window-header-title"]}>
-          <div className={styles["window-header-main-title"]}>
+      <div className="window-header">
+        <div className="window-header-title">
+          <div className="window-header-main-title">
             {Locale.Settings.Title}
           </div>
-          <div className={styles["window-header-sub-title"]}>
+          <div className="window-header-sub-title">
             {Locale.Settings.SubTitle}
           </div>
         </div>
-        <div className={styles["window-actions"]}>
-          <div className={styles["window-action-button"]}>
+        <div className="window-actions">
+          <div className="window-action-button">
             <IconButton
               icon={<ClearIcon />}
               onClick={() => {
@@ -227,7 +227,7 @@ export function Settings() {
               title={Locale.Settings.Actions.ClearAll}
             />
           </div>
-          <div className={styles["window-action-button"]}>
+          <div className="window-action-button">
             <IconButton
               icon={<ResetIcon />}
               onClick={() => {
@@ -242,7 +242,7 @@ export function Settings() {
               title={Locale.Settings.Actions.ResetAll}
             />
           </div>
-          <div className={styles["window-action-button"]}>
+          <div className="window-action-button">
             <IconButton
               icon={<CloseIcon />}
               onClick={() => navigate(Path.Home)}

+ 16 - 7
app/components/ui-lib.module.scss

@@ -36,14 +36,23 @@
   padding: 10px 20px;
   animation: slide-in ease 0.6s;
 
-  .list-item-title {
-    font-size: 14px;
-    font-weight: bolder;
-  }
+  .list-header {
+    display: flex;
+    align-items: center;
+
+    .list-icon {
+      margin-right: 10px;
+    }
 
-  .list-item-sub-title {
-    font-size: 12px;
-    font-weight: normal;
+    .list-item-title {
+      font-size: 14px;
+      font-weight: bolder;
+    }
+
+    .list-item-sub-title {
+      font-size: 12px;
+      font-weight: normal;
+    }
   }
 }
 

+ 20 - 7
app/components/ui-lib.tsx

@@ -37,21 +37,34 @@ export function ListItem(props: {
   title: string;
   subTitle?: string;
   children?: JSX.Element | JSX.Element[];
+  icon?: JSX.Element;
+  className?: string;
 }) {
   return (
-    <div className={styles["list-item"]}>
-      <div className={styles["list-item-title"]}>
-        <div>{props.title}</div>
-        {props.subTitle && (
-          <div className={styles["list-item-sub-title"]}>{props.subTitle}</div>
-        )}
+    <div className={styles["list-item"] + ` ${props.className}`}>
+      <div className={styles["list-header"]}>
+        {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
+        <div className={styles["list-item-title"]}>
+          <div>{props.title}</div>
+          {props.subTitle && (
+            <div className={styles["list-item-sub-title"]}>
+              {props.subTitle}
+            </div>
+          )}
+        </div>
       </div>
       {props.children}
     </div>
   );
 }
 
-export function List(props: { children: JSX.Element[] | JSX.Element }) {
+export function List(props: {
+  children:
+    | Array<JSX.Element | null | undefined>
+    | JSX.Element
+    | null
+    | undefined;
+}) {
   return <div className={styles.list}>{props.children}</div>;
 }
 

+ 3 - 0
app/config/masks.ts

@@ -0,0 +1,3 @@
+import { Mask } from "../store/mask";
+
+export const BUILTIN_MASKS: Mask[] = [];

+ 1 - 0
app/constant.ts

@@ -12,6 +12,7 @@ export enum Path {
   Chat = "/chat",
   Settings = "/settings",
   NewChat = "/new-chat",
+  Masks = "/masks",
 }
 
 export enum SlotID {

+ 1 - 0
app/icons/left.svg

@@ -0,0 +1 @@
+<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(6.333333333333333 4)  rotate(0 2 4)" d="M4,8L0,4L4,0 " /></g></g></svg>

+ 1 - 1
app/requests.ts

@@ -30,7 +30,7 @@ const makeRequestParam = (
 
   const modelConfig = {
     ...useAppConfig.getState().modelConfig,
-    ...useChatStore.getState().currentSession().modelConfig,
+    ...useChatStore.getState().currentSession().mask.modelConfig,
   };
 
   // override model config

+ 9 - 16
app/store/chat.ts

@@ -12,6 +12,7 @@ import { isMobileScreen, trimTopic } from "../utils";
 import Locale from "../locales";
 import { showToast } from "../components/ui-lib";
 import { DEFAULT_CONFIG, ModelConfig, ModelType, useAppConfig } from "./config";
+import { createEmptyMask, Mask } from "./mask";
 
 export type Message = ChatCompletionResponseMessage & {
   date: string;
@@ -41,16 +42,16 @@ export interface ChatStat {
 
 export interface ChatSession {
   id: number;
+
   topic: string;
-  avatar?: string;
+
   memoryPrompt: string;
-  context: Message[];
   messages: Message[];
   stat: ChatStat;
   lastUpdate: string;
   lastSummarizeIndex: number;
 
-  modelConfig: ModelConfig;
+  mask: Mask;
 }
 
 export const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
@@ -66,7 +67,6 @@ function createEmptySession(): ChatSession {
     id: Date.now(),
     topic: DEFAULT_TOPIC,
     memoryPrompt: "",
-    context: [],
     messages: [],
     stat: {
       tokenCount: 0,
@@ -75,8 +75,7 @@ function createEmptySession(): ChatSession {
     },
     lastUpdate: createDate,
     lastSummarizeIndex: 0,
-
-    modelConfig: useAppConfig.getState().modelConfig,
+    mask: createEmptyMask(),
   };
 }
 
@@ -322,11 +321,11 @@ export const useChatStore = create<ChatStore>()(
         const messages = session.messages.filter((msg) => !msg.isError);
         const n = messages.length;
 
-        const context = session.context.slice();
+        const context = session.mask.context.slice();
 
         // long term memory
         if (
-          session.modelConfig.sendMemory &&
+          session.mask.modelConfig.sendMemory &&
           session.memoryPrompt &&
           session.memoryPrompt.length > 0
         ) {
@@ -432,7 +431,7 @@ export const useChatStore = create<ChatStore>()(
         if (
           historyMsgLength >
             config.modelConfig.compressMessageLengthThreshold &&
-          session.modelConfig.sendMemory
+          session.mask.modelConfig.sendMemory
         ) {
           requestChatStream(
             toBeSummarizedMsgs.concat({
@@ -485,14 +484,8 @@ export const useChatStore = create<ChatStore>()(
       migrate(persistedState, version) {
         const state = persistedState as ChatStore;
 
-        if (version === 1) {
-          state.sessions.forEach((s) => (s.context = []));
-        }
-
         if (version < 2) {
-          state.sessions.forEach(
-            (s) => (s.modelConfig = { ...DEFAULT_CONFIG.modelConfig }),
-          );
+          state.sessions.forEach((s) => (s.mask = createEmptyMask()));
         }
 
         return state;

+ 16 - 8
app/store/mask.ts

@@ -1,8 +1,8 @@
 import { create } from "zustand";
 import { persist } from "zustand/middleware";
 import { getLang, Lang } from "../locales";
-import { Message } from "./chat";
-import { ModelConfig, useAppConfig } from "./config";
+import { DEFAULT_TOPIC, Message } from "./chat";
+import { ModelConfig, ModelType, useAppConfig } from "./config";
 
 export const MASK_KEY = "mask-store";
 
@@ -11,7 +11,7 @@ export type Mask = {
   avatar: string;
   name: string;
   context: Message[];
-  config: ModelConfig;
+  modelConfig: ModelConfig;
   lang: Lang;
 };
 
@@ -29,6 +29,18 @@ type MaskStore = MaskState & {
   getAll: () => Mask[];
 };
 
+export const DEFAULT_MASK_ID = 1145141919810;
+export const DEFAULT_MASK_AVATAR = "gpt-bot";
+export const createEmptyMask = () =>
+  ({
+    id: DEFAULT_MASK_ID,
+    avatar: DEFAULT_MASK_AVATAR,
+    name: DEFAULT_TOPIC,
+    context: [],
+    modelConfig: useAppConfig.getState().modelConfig,
+    lang: getLang(),
+  } as Mask);
+
 export const useMaskStore = create<MaskStore>()(
   persist(
     (set, get) => ({
@@ -39,12 +51,8 @@ export const useMaskStore = create<MaskStore>()(
         const id = get().globalMaskId;
         const masks = get().masks;
         masks[id] = {
+          ...createEmptyMask(),
           id,
-          avatar: "1f916",
-          name: "",
-          config: useAppConfig.getState().modelConfig,
-          context: [],
-          lang: getLang(),
           ...mask,
         };
 

+ 3 - 0
app/styles/globals.scss

@@ -1,3 +1,6 @@
+@import "./animation.scss";
+@import "./window.scss";
+
 @mixin light {
   --theme: light;
 

+ 0 - 0
app/components/window.scss → app/styles/window.scss