windhamdavid 1 year ago
parent
commit
a1fe998104
100 changed files with 10670 additions and 0 deletions
  1. 40 0
      .gitignore
  2. 71 0
      app/api/auth.ts
  3. 44 0
      app/api/common.ts
  4. 26 0
      app/api/config/route.ts
  5. 101 0
      app/api/openai/[...path]/route.ts
  6. 9 0
      app/api/openai/typing.ts
  7. 28 0
      app/command.ts
  8. 64 0
      app/components/button.module.scss
  9. 45 0
      app/components/button.tsx
  10. 147 0
      app/components/chat-list.tsx
  11. 109 0
      app/components/chat.module.scss
  12. 833 0
      app/components/chat.tsx
  13. 59 0
      app/components/emoji.tsx
  14. 73 0
      app/components/error.tsx
  15. 573 0
      app/components/home.module.scss
  16. 140 0
      app/components/home.tsx
  17. 7 0
      app/components/input-range.module.scss
  18. 37 0
      app/components/input-range.tsx
  19. 180 0
      app/components/markdown.tsx
  20. 108 0
      app/components/mask.module.scss
  21. 443 0
      app/components/mask.tsx
  22. 140 0
      app/components/model-config.tsx
  23. 115 0
      app/components/new-chat.module.scss
  24. 197 0
      app/components/new-chat.tsx
  25. 72 0
      app/components/settings.module.scss
  26. 590 0
      app/components/settings.tsx
  27. 189 0
      app/components/sidebar.tsx
  28. 230 0
      app/components/ui-lib.module.scss
  29. 264 0
      app/components/ui-lib.tsx
  30. 24 0
      app/config/build.ts
  31. 46 0
      app/config/server.ts
  32. 42 0
      app/constant.ts
  33. 11 0
      app/global.d.ts
  34. 23 0
      app/icons/add.svg
  35. 1 0
      app/icons/auto.svg
  36. 22 0
      app/icons/black-bot.svg
  37. 22 0
      app/icons/bot.svg
  38. 1 0
      app/icons/bottom.svg
  39. 25 0
      app/icons/brain.svg
  40. 27 0
      app/icons/chat.svg
  41. 12 0
      app/icons/chatgpt.svg
  42. 1 0
      app/icons/clear.svg
  43. 21 0
      app/icons/close.svg
  44. 1 0
      app/icons/copy.svg
  45. 1 0
      app/icons/dark.svg
  46. 8 0
      app/icons/delete.svg
  47. 1 0
      app/icons/down.svg
  48. 1 0
      app/icons/download.svg
  49. 1 0
      app/icons/edit.svg
  50. 1 0
      app/icons/export.svg
  51. 4 0
      app/icons/eye-off.svg
  52. 4 0
      app/icons/eye.svg
  53. 29 0
      app/icons/github.svg
  54. 1 0
      app/icons/left.svg
  55. 0 0
      app/icons/light.svg
  56. 0 0
      app/icons/lightning.svg
  57. 0 0
      app/icons/mask.svg
  58. 41 0
      app/icons/max.svg
  59. 25 0
      app/icons/menu.svg
  60. 45 0
      app/icons/min.svg
  61. 1 0
      app/icons/pause.svg
  62. 0 0
      app/icons/plugin.svg
  63. 1 0
      app/icons/prompt.svg
  64. 24 0
      app/icons/reload.svg
  65. 1 0
      app/icons/rename.svg
  66. 21 0
      app/icons/return.svg
  67. 21 0
      app/icons/send-white.svg
  68. 21 0
      app/icons/settings.svg
  69. 17 0
      app/icons/share.svg
  70. 33 0
      app/icons/three-dots.svg
  71. 0 0
      app/icons/upload.svg
  72. 44 0
      app/layout.tsx
  73. 244 0
      app/locales/cn.ts
  74. 245 0
      app/locales/cs.ts
  75. 249 0
      app/locales/de.ts
  76. 245 0
      app/locales/en.ts
  77. 246 0
      app/locales/es.ts
  78. 91 0
      app/locales/index.ts
  79. 247 0
      app/locales/it.ts
  80. 245 0
      app/locales/jp.ts
  81. 245 0
      app/locales/ru.ts
  82. 247 0
      app/locales/tr.ts
  83. 237 0
      app/locales/tw.ts
  84. 243 0
      app/locales/vi.ts
  85. 296 0
      app/masks/cn.ts
  86. 147 0
      app/masks/en.ts
  87. 26 0
      app/masks/index.ts
  88. 3 0
      app/masks/typing.ts
  89. 16 0
      app/page.tsx
  90. 27 0
      app/polyfill.ts
  91. 285 0
      app/requests.ts
  92. 93 0
      app/store/access.ts
  93. 521 0
      app/store/chat.ts
  94. 168 0
      app/store/config.ts
  95. 4 0
      app/store/index.ts
  96. 100 0
      app/store/mask.ts
  97. 175 0
      app/store/prompt.ts
  98. 88 0
      app/store/update.ts
  99. 23 0
      app/styles/animation.scss
  100. 355 0
      app/styles/globals.scss

+ 40 - 0
.gitignore

@@ -0,0 +1,40 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+dev
+
+public/prompts.json
+
+.vscode
+.idea

+ 71 - 0
app/api/auth.ts

@@ -0,0 +1,71 @@
+import { NextRequest } from "next/server";
+import { getServerSideConfig } from "../config/server";
+import md5 from "spark-md5";
+import { ACCESS_CODE_PREFIX } from "../constant";
+
+const serverConfig = getServerSideConfig();
+
+function getIP(req: NextRequest) {
+  let ip = req.ip ?? req.headers.get("x-real-ip");
+  const forwardedFor = req.headers.get("x-forwarded-for");
+
+  if (!ip && forwardedFor) {
+    ip = forwardedFor.split(",").at(0) ?? "";
+  }
+
+  return ip;
+}
+
+function parseApiKey(bearToken: string) {
+  const token = bearToken.trim().replaceAll("Bearer ", "").trim();
+  const isOpenAiKey = !token.startsWith(ACCESS_CODE_PREFIX);
+
+  return {
+    accessCode: isOpenAiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length),
+    apiKey: isOpenAiKey ? token : "",
+  };
+}
+
+export function auth(req: NextRequest) {
+  const authToken = req.headers.get("Authorization") ?? "";
+
+  // check if it is openai api key or user token
+  const { accessCode, apiKey: token } = parseApiKey(authToken);
+
+  const hashedCode = md5.hash(accessCode ?? "").trim();
+
+  console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]);
+  console.log("[Auth] got access code:", accessCode);
+  console.log("[Auth] hashed access code:", hashedCode);
+  console.log("[User IP] ", getIP(req));
+  console.log("[Time] ", new Date().toLocaleString());
+
+  if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !token) {
+    return {
+      error: true,
+      needAccessCode: true,
+      msg: "Please go settings page and fill your access code.",
+    };
+  }
+
+  // if user does not provide an api key, inject system api key
+  if (!token) {
+    const apiKey = serverConfig.apiKey;
+    if (apiKey) {
+      console.log("[Auth] use system api key");
+      req.headers.set("Authorization", `Bearer ${apiKey}`);
+    } else {
+      console.log("[Auth] admin did not provide an api key");
+      return {
+        error: true,
+        msg: "Empty Api Key",
+      };
+    }
+  } else {
+    console.log("[Auth] use user api key");
+  }
+
+  return {
+    error: false,
+  };
+}

+ 44 - 0
app/api/common.ts

@@ -0,0 +1,44 @@
+import { NextRequest } from "next/server";
+
+const OPENAI_URL = "api.openai.com";
+const DEFAULT_PROTOCOL = "https";
+const PROTOCOL = process.env.PROTOCOL ?? DEFAULT_PROTOCOL;
+const BASE_URL = process.env.BASE_URL ?? OPENAI_URL;
+
+export async function requestOpenai(req: NextRequest) {
+  const authValue = req.headers.get("Authorization") ?? "";
+  const openaiPath = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
+    "/api/openai/",
+    "",
+  );
+
+  let baseUrl = BASE_URL;
+
+  if (!baseUrl.startsWith("http")) {
+    baseUrl = `${PROTOCOL}://${baseUrl}`;
+  }
+
+  console.log("[Proxy] ", openaiPath);
+  console.log("[Base Url]", baseUrl);
+
+  if (process.env.OPENAI_ORG_ID) {
+    console.log("[Org ID]", process.env.OPENAI_ORG_ID);
+  }
+
+  if (!authValue || !authValue.startsWith("Bearer sk-")) {
+    console.error("[OpenAI Request] invalid api key provided", authValue);
+  }
+
+  return fetch(`${baseUrl}/${openaiPath}`, {
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: authValue,
+      ...(process.env.OPENAI_ORG_ID && {
+        "OpenAI-Organization": process.env.OPENAI_ORG_ID,
+      }),
+    },
+    cache: "no-store",
+    method: req.method,
+    body: req.body,
+  });
+}

+ 26 - 0
app/api/config/route.ts

@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server";
+
+import { getServerSideConfig } from "../../config/server";
+
+const serverConfig = getServerSideConfig();
+
+// Danger! Don not write any secret value here!
+// ่ญฆๅ‘Š๏ผไธ่ฆๅœจ่ฟ™้‡Œๅ†™ๅ…ฅไปปไฝ•ๆ•ๆ„Ÿไฟกๆฏ๏ผ
+const DANGER_CONFIG = {
+  needCode: serverConfig.needCode,
+  hideUserApiKey: serverConfig.hideUserApiKey,
+  enableGPT4: serverConfig.enableGPT4,
+};
+
+declare global {
+  type DangerConfig = typeof DANGER_CONFIG;
+}
+
+async function handle() {
+  return NextResponse.json(DANGER_CONFIG);
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";

+ 101 - 0
app/api/openai/[...path]/route.ts

@@ -0,0 +1,101 @@
+import { createParser } from "eventsource-parser";
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "../../auth";
+import { requestOpenai } from "../../common";
+
+async function createStream(res: Response) {
+  const encoder = new TextEncoder();
+  const decoder = new TextDecoder();
+
+  const stream = new ReadableStream({
+    async start(controller) {
+      function onParse(event: any) {
+        if (event.type === "event") {
+          const data = event.data;
+          // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
+          if (data === "[DONE]") {
+            controller.close();
+            return;
+          }
+          try {
+            const json = JSON.parse(data);
+            const text = json.choices[0].delta.content;
+            const queue = encoder.encode(text);
+            controller.enqueue(queue);
+          } catch (e) {
+            controller.error(e);
+          }
+        }
+      }
+
+      const parser = createParser(onParse);
+      for await (const chunk of res.body as any) {
+        parser.feed(decoder.decode(chunk, { stream: true }));
+      }
+    },
+  });
+  return stream;
+}
+
+function formatResponse(msg: any) {
+  const jsonMsg = ["```json\n", JSON.stringify(msg, null, "  "), "\n```"].join(
+    "",
+  );
+  return new Response(jsonMsg);
+}
+
+async function handle(
+  req: NextRequest,
+  { params }: { params: { path: string[] } },
+) {
+  console.log("[OpenAI Route] params ", params);
+
+  const authResult = auth(req);
+  if (authResult.error) {
+    return NextResponse.json(authResult, {
+      status: 401,
+    });
+  }
+
+  try {
+    const api = await requestOpenai(req);
+
+    const contentType = api.headers.get("Content-Type") ?? "";
+
+    // streaming response
+    if (contentType.includes("stream")) {
+      const stream = await createStream(api);
+      const res = new Response(stream);
+      res.headers.set("Content-Type", contentType);
+      return res;
+    }
+
+    // try to parse error msg
+    try {
+      const mayBeErrorBody = await api.json();
+      if (mayBeErrorBody.error) {
+        console.error("[OpenAI Response] ", mayBeErrorBody);
+        return formatResponse(mayBeErrorBody);
+      } else {
+        const res = new Response(JSON.stringify(mayBeErrorBody));
+        res.headers.set("Content-Type", "application/json");
+        res.headers.set("Cache-Control", "no-cache");
+        return res;
+      }
+    } catch (e) {
+      console.error("[OpenAI Parse] ", e);
+      return formatResponse({
+        msg: "invalid response from openai server",
+        error: e,
+      });
+    }
+  } catch (e) {
+    console.error("[OpenAI] ", e);
+    return formatResponse(e);
+  }
+}
+
+export const GET = handle;
+export const POST = handle;
+
+export const runtime = "edge";

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

@@ -0,0 +1,9 @@
+import type {
+  CreateChatCompletionRequest,
+  CreateChatCompletionResponse,
+} from "openai";
+
+export type ChatRequest = CreateChatCompletionRequest;
+export type ChatResponse = CreateChatCompletionResponse;
+
+export type Updater<T> = (updater: (value: T) => void) => void;

+ 28 - 0
app/command.ts

@@ -0,0 +1,28 @@
+import { useSearchParams } from "react-router-dom";
+
+type Command = (param: string) => void;
+interface Commands {
+  fill?: Command;
+  submit?: Command;
+  mask?: Command;
+}
+
+export function useCommand(commands: Commands = {}) {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  if (commands === undefined) return;
+
+  let shouldUpdate = false;
+  searchParams.forEach((param, name) => {
+    const commandName = name as keyof Commands;
+    if (typeof commands[commandName] === "function") {
+      commands[commandName]!(param);
+      searchParams.delete(name);
+      shouldUpdate = true;
+    }
+  });
+
+  if (shouldUpdate) {
+    setSearchParams(searchParams);
+  }
+}

+ 64 - 0
app/components/button.module.scss

@@ -0,0 +1,64 @@
+.icon-button {
+  background-color: var(--white);
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px;
+
+  cursor: pointer;
+  transition: all 0.3s ease;
+  overflow: hidden;
+  user-select: none;
+  outline: none;
+  border: none;
+  color: var(--black);
+
+  &[disabled] {
+    cursor: not-allowed;
+    opacity: 0.5;
+  }
+
+  &.primary {
+    background-color: var(--primary);
+    color: white;
+
+    path {
+      fill: white !important;
+    }
+  }
+}
+
+.shadow {
+  box-shadow: var(--card-shadow);
+}
+
+.border {
+  border: var(--border-in-light);
+}
+
+.icon-button:hover {
+  border-color: var(--primary);
+}
+
+.icon-button-icon {
+  width: 16px;
+  height: 16px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+@media only screen and (max-width: 600px) {
+  .icon-button {
+    padding: 16px;
+  }
+}
+
+.icon-button-text {
+  margin-left: 5px;
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}

+ 45 - 0
app/components/button.tsx

@@ -0,0 +1,45 @@
+import * as React from "react";
+
+import styles from "./button.module.scss";
+
+export function IconButton(props: {
+  onClick?: () => void;
+  icon?: JSX.Element;
+  type?: "primary" | "danger";
+  text?: string;
+  bordered?: boolean;
+  shadow?: boolean;
+  className?: string;
+  title?: string;
+  disabled?: boolean;
+}) {
+  return (
+    <button
+      className={
+        styles["icon-button"] +
+        ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
+          props.className ?? ""
+        } clickable ${styles[props.type ?? ""]}`
+      }
+      onClick={props.onClick}
+      title={props.title}
+      disabled={props.disabled}
+      role="button"
+    >
+      {props.icon && (
+        <div
+          className={
+            styles["icon-button-icon"] +
+            ` ${props.type === "primary" && "no-dark"}`
+          }
+        >
+          {props.icon}
+        </div>
+      )}
+
+      {props.text && (
+        <div className={styles["icon-button-text"]}>{props.text}</div>
+      )}
+    </button>
+  );
+}

+ 147 - 0
app/components/chat-list.tsx

@@ -0,0 +1,147 @@
+import DeleteIcon from "../icons/delete.svg";
+import BotIcon from "../icons/bot.svg";
+
+import styles from "./home.module.scss";
+import {
+  DragDropContext,
+  Droppable,
+  Draggable,
+  OnDragEndResponder,
+} from "@hello-pangea/dnd";
+
+import { useChatStore } from "../store";
+
+import Locale from "../locales";
+import { Link, useNavigate } from "react-router-dom";
+import { Path } from "../constant";
+import { MaskAvatar } from "./mask";
+import { Mask } from "../store/mask";
+
+export function ChatItem(props: {
+  onClick?: () => void;
+  onDelete?: () => void;
+  title: string;
+  count: number;
+  time: string;
+  selected: boolean;
+  id: number;
+  index: number;
+  narrow?: boolean;
+  mask: Mask;
+}) {
+  return (
+    <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}
+          title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
+            props.count,
+          )}`}
+        >
+          {props.narrow ? (
+            <div className={styles["chat-item-narrow"]}>
+              <div className={styles["chat-item-avatar"] + " no-dark"}>
+                <MaskAvatar mask={props.mask} />
+              </div>
+              <div className={styles["chat-item-narrow-count"]}>
+                {props.count}
+              </div>
+            </div>
+          ) : (
+            <>
+              <div className={styles["chat-item-title"]}>{props.title}</div>
+              <div className={styles["chat-item-info"]}>
+                <div className={styles["chat-item-count"]}>
+                  {Locale.ChatItem.ChatItemCount(props.count)}
+                </div>
+                <div className={styles["chat-item-date"]}>
+                  {new Date(props.time).toLocaleString()}
+                </div>
+              </div>
+            </>
+          )}
+
+          <div
+            className={styles["chat-item-delete"]}
+            onClickCapture={props.onDelete}
+          >
+            <DeleteIcon />
+          </div>
+        </div>
+      )}
+    </Draggable>
+  );
+}
+
+export function ChatList(props: { narrow?: boolean }) {
+  const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
+    (state) => [
+      state.sessions,
+      state.currentSessionIndex,
+      state.selectSession,
+      state.moveSession,
+    ],
+  );
+  const chatStore = useChatStore();
+  const navigate = useNavigate();
+
+  const onDragEnd: OnDragEndResponder = (result) => {
+    const { destination, source } = result;
+    if (!destination) {
+      return;
+    }
+
+    if (
+      destination.droppableId === source.droppableId &&
+      destination.index === source.index
+    ) {
+      return;
+    }
+
+    moveSession(source.index, destination.index);
+  };
+
+  return (
+    <DragDropContext onDragEnd={onDragEnd}>
+      <Droppable droppableId="chat-list">
+        {(provided) => (
+          <div
+            className={styles["chat-list"]}
+            ref={provided.innerRef}
+            {...provided.droppableProps}
+          >
+            {sessions.map((item, i) => (
+              <ChatItem
+                title={item.topic}
+                time={new Date(item.lastUpdate).toLocaleString()}
+                count={item.messages.length}
+                key={item.id}
+                id={item.id}
+                index={i}
+                selected={i === selectedIndex}
+                onClick={() => {
+                  navigate(Path.Chat);
+                  selectSession(i);
+                }}
+                onDelete={() => {
+                  if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
+                    chatStore.deleteSession(i);
+                  }
+                }}
+                narrow={props.narrow}
+                mask={item.mask}
+              />
+            ))}
+            {provided.placeholder}
+          </div>
+        )}
+      </Droppable>
+    </DragDropContext>
+  );
+}

+ 109 - 0
app/components/chat.module.scss

@@ -0,0 +1,109 @@
+@import "../styles/animation.scss";
+
+.chat-input-actions {
+  display: flex;
+  flex-wrap: wrap;
+
+  .chat-input-action {
+    display: inline-flex;
+    border-radius: 20px;
+    font-size: 12px;
+    background-color: var(--white);
+    color: var(--black);
+    border: var(--border-in-light);
+    padding: 4px 10px;
+    animation: slide-in ease 0.3s;
+    box-shadow: var(--card-shadow);
+    transition: all ease 0.3s;
+    margin-bottom: 10px;
+    align-items: center;
+
+    &:not(:last-child) {
+      margin-right: 5px;
+    }
+  }
+}
+
+.prompt-toast {
+  position: absolute;
+  bottom: -50px;
+  z-index: 999;
+  display: flex;
+  justify-content: center;
+  width: calc(100% - 40px);
+
+  .prompt-toast-inner {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 12px;
+    background-color: var(--white);
+    color: var(--black);
+
+    border: var(--border-in-light);
+    box-shadow: var(--card-shadow);
+    padding: 10px 20px;
+    border-radius: 100px;
+
+    animation: slide-in-from-top ease 0.3s;
+
+    .prompt-toast-content {
+      margin-left: 10px;
+    }
+  }
+}
+
+.section-title {
+  font-size: 12px;
+  font-weight: bold;
+  margin-bottom: 10px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  .section-title-action {
+    display: flex;
+    align-items: center;
+  }
+}
+
+.context-prompt {
+  .context-prompt-row {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    margin-bottom: 10px;
+
+    .context-role {
+      margin-right: 10px;
+    }
+
+    .context-content {
+      flex: 1;
+      max-width: 100%;
+      text-align: left;
+    }
+
+    .context-delete-button {
+      margin-left: 10px;
+    }
+  }
+
+  .context-prompt-button {
+    flex: 1;
+  }
+}
+
+.memory-prompt {
+  margin: 20px 0;
+
+  .memory-prompt-content {
+    background-color: var(--white);
+    color: var(--black);
+    border: var(--border-in-light);
+    border-radius: 10px;
+    padding: 10px;
+    font-size: 12px;
+    user-select: text;
+  }
+}

+ 833 - 0
app/components/chat.tsx

@@ -0,0 +1,833 @@
+import { useDebouncedCallback } from "use-debounce";
+import { useState, useRef, useEffect, useLayoutEffect } from "react";
+
+import SendWhiteIcon from "../icons/send-white.svg";
+import BrainIcon from "../icons/brain.svg";
+import RenameIcon from "../icons/rename.svg";
+import ExportIcon from "../icons/share.svg";
+import ReturnIcon from "../icons/return.svg";
+import CopyIcon from "../icons/copy.svg";
+import DownloadIcon from "../icons/download.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import PromptIcon from "../icons/prompt.svg";
+import MaskIcon from "../icons/mask.svg";
+import MaxIcon from "../icons/max.svg";
+import MinIcon from "../icons/min.svg";
+import ResetIcon from "../icons/reload.svg";
+
+import LightIcon from "../icons/light.svg";
+import DarkIcon from "../icons/dark.svg";
+import AutoIcon from "../icons/auto.svg";
+import BottomIcon from "../icons/bottom.svg";
+import StopIcon from "../icons/pause.svg";
+
+import {
+  Message,
+  SubmitKey,
+  useChatStore,
+  BOT_HELLO,
+  createMessage,
+  useAccessStore,
+  Theme,
+  useAppConfig,
+  DEFAULT_TOPIC,
+} from "../store";
+
+import {
+  copyToClipboard,
+  downloadAs,
+  selectOrCopy,
+  autoGrowTextArea,
+  useMobileScreen,
+} from "../utils";
+
+import dynamic from "next/dynamic";
+
+import { ControllerPool } from "../requests";
+import { Prompt, usePromptStore } from "../store/prompt";
+import Locale from "../locales";
+
+import { IconButton } from "./button";
+import styles from "./home.module.scss";
+import chatStyle from "./chat.module.scss";
+
+import { ListItem, Modal, showModal } from "./ui-lib";
+import { useLocation, useNavigate } from "react-router-dom";
+import { LAST_INPUT_KEY, Path } from "../constant";
+import { Avatar } from "./emoji";
+import { MaskAvatar, MaskConfig } from "./mask";
+import { useMaskStore } from "../store/mask";
+import { useCommand } from "../command";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
+
+function exportMessages(messages: Message[], topic: string) {
+  const mdText =
+    `# ${topic}\n\n` +
+    messages
+      .map((m) => {
+        return m.role === "user"
+          ? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
+          : `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
+      })
+      .join("\n\n");
+  const filename = `${topic}.md`;
+
+  showModal({
+    title: Locale.Export.Title,
+    children: (
+      <div className="markdown-body">
+        <pre className={styles["export-content"]}>{mdText}</pre>
+      </div>
+    ),
+    actions: [
+      <IconButton
+        key="copy"
+        icon={<CopyIcon />}
+        bordered
+        text={Locale.Export.Copy}
+        onClick={() => copyToClipboard(mdText)}
+      />,
+      <IconButton
+        key="download"
+        icon={<DownloadIcon />}
+        bordered
+        text={Locale.Export.Download}
+        onClick={() => downloadAs(mdText, filename)}
+      />,
+    ],
+  });
+}
+
+export function SessionConfigModel(props: { onClose: () => void }) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const maskStore = useMaskStore();
+  const navigate = useNavigate();
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Context.Edit}
+        onClose={() => props.onClose()}
+        actions={[
+          <IconButton
+            key="reset"
+            icon={<ResetIcon />}
+            bordered
+            text={Locale.Chat.Config.Reset}
+            onClick={() =>
+              confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
+            }
+          />,
+          <IconButton
+            key="copy"
+            icon={<CopyIcon />}
+            bordered
+            text={Locale.Chat.Config.SaveAs}
+            onClick={() => {
+              navigate(Path.Masks);
+              setTimeout(() => {
+                maskStore.create(session.mask);
+              }, 500);
+            }}
+          />,
+        ]}
+      >
+        <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>
+  );
+}
+
+function PromptToast(props: {
+  showToast?: boolean;
+  showModal?: boolean;
+  setShowModal: (_: boolean) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const context = session.mask.context;
+
+  return (
+    <div className={chatStyle["prompt-toast"]} key="prompt-toast">
+      {props.showToast && (
+        <div
+          className={chatStyle["prompt-toast-inner"] + " clickable"}
+          role="button"
+          onClick={() => props.setShowModal(true)}
+        >
+          <BrainIcon />
+          <span className={chatStyle["prompt-toast-content"]}>
+            {Locale.Context.Toast(context.length)}
+          </span>
+        </div>
+      )}
+      {props.showModal && (
+        <SessionConfigModel onClose={() => props.setShowModal(false)} />
+      )}
+    </div>
+  );
+}
+
+function useSubmitHandler() {
+  const config = useAppConfig();
+  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;
+}) {
+  const noPrompts = props.prompts.length === 0;
+  const [selectIndex, setSelectIndex] = useState(0);
+  const selectedRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    setSelectIndex(0);
+  }, [props.prompts.length]);
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (noPrompts) return;
+      if (e.metaKey || e.altKey || e.ctrlKey) {
+        return;
+      }
+      // arrow up / down to select prompt
+      const changeIndex = (delta: number) => {
+        e.stopPropagation();
+        e.preventDefault();
+        const nextIndex = Math.max(
+          0,
+          Math.min(props.prompts.length - 1, selectIndex + delta),
+        );
+        setSelectIndex(nextIndex);
+        selectedRef.current?.scrollIntoView({
+          block: "center",
+        });
+      };
+
+      if (e.key === "ArrowUp") {
+        changeIndex(1);
+      } else if (e.key === "ArrowDown") {
+        changeIndex(-1);
+      } else if (e.key === "Enter") {
+        const selectedPrompt = props.prompts.at(selectIndex);
+        if (selectedPrompt) {
+          props.onPromptSelect(selectedPrompt);
+        }
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => window.removeEventListener("keydown", onKeyDown);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [noPrompts, selectIndex]);
+
+  if (noPrompts) return null;
+  return (
+    <div className={styles["prompt-hints"]}>
+      {props.prompts.map((prompt, i) => (
+        <div
+          ref={i === selectIndex ? selectedRef : null}
+          className={
+            styles["prompt-hint"] +
+            ` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
+          }
+          key={prompt.title + i.toString()}
+          onClick={() => props.onPromptSelect(prompt)}
+          onMouseEnter={() => setSelectIndex(i)}
+        >
+          <div className={styles["hint-title"]}>{prompt.title}</div>
+          <div className={styles["hint-content"]}>{prompt.content}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function useScrollToBottom() {
+  // for auto-scroll
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const [autoScroll, setAutoScroll] = useState(true);
+  const scrollToBottom = () => {
+    const dom = scrollRef.current;
+    if (dom) {
+      setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
+    }
+  };
+
+  // auto scroll
+  useLayoutEffect(() => {
+    autoScroll && scrollToBottom();
+  });
+
+  return {
+    scrollRef,
+    autoScroll,
+    setAutoScroll,
+    scrollToBottom,
+  };
+}
+
+export function ChatActions(props: {
+  showPromptModal: () => void;
+  scrollToBottom: () => void;
+  showPromptHints: () => void;
+  hitBottom: boolean;
+}) {
+  const config = useAppConfig();
+  const navigate = useNavigate();
+
+  // switch themes
+  const theme = config.theme;
+  function nextTheme() {
+    const themes = [Theme.Auto, Theme.Light, Theme.Dark];
+    const themeIndex = themes.indexOf(theme);
+    const nextIndex = (themeIndex + 1) % themes.length;
+    const nextTheme = themes[nextIndex];
+    config.update((config) => (config.theme = nextTheme));
+  }
+
+  // stop all responses
+  const couldStop = ControllerPool.hasPending();
+  const stopAll = () => ControllerPool.stopAll();
+
+  return (
+    <div className={chatStyle["chat-input-actions"]}>
+      {couldStop && (
+        <div
+          className={`${chatStyle["chat-input-action"]} clickable`}
+          onClick={stopAll}
+        >
+          <StopIcon />
+        </div>
+      )}
+      {!props.hitBottom && (
+        <div
+          className={`${chatStyle["chat-input-action"]} clickable`}
+          onClick={props.scrollToBottom}
+        >
+          <BottomIcon />
+        </div>
+      )}
+      {props.hitBottom && (
+        <div
+          className={`${chatStyle["chat-input-action"]} clickable`}
+          onClick={props.showPromptModal}
+        >
+          <BrainIcon />
+        </div>
+      )}
+
+      <div
+        className={`${chatStyle["chat-input-action"]} clickable`}
+        onClick={nextTheme}
+      >
+        {theme === Theme.Auto ? (
+          <AutoIcon />
+        ) : theme === Theme.Light ? (
+          <LightIcon />
+        ) : theme === Theme.Dark ? (
+          <DarkIcon />
+        ) : null}
+      </div>
+
+      <div
+        className={`${chatStyle["chat-input-action"]} clickable`}
+        onClick={props.showPromptHints}
+      >
+        <PromptIcon />
+      </div>
+
+      <div
+        className={`${chatStyle["chat-input-action"]} clickable`}
+        onClick={() => {
+          navigate(Path.Masks);
+        }}
+      >
+        <MaskIcon />
+      </div>
+    </div>
+  );
+}
+
+export function Chat() {
+  type RenderMessage = Message & { preview?: boolean };
+
+  const chatStore = useChatStore();
+  const [session, sessionIndex] = useChatStore((state) => [
+    state.currentSession(),
+    state.currentSessionIndex,
+  ]);
+  const config = useAppConfig();
+  const fontSize = config.fontSize;
+
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const [userInput, setUserInput] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const { submitKey, shouldSubmit } = useSubmitHandler();
+  const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
+  const [hitBottom, setHitBottom] = useState(true);
+  const isMobileScreen = useMobileScreen();
+  const navigate = useNavigate();
+
+  const onChatBodyScroll = (e: HTMLElement) => {
+    const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 100;
+    setHitBottom(isTouchBottom);
+  };
+
+  // prompt hints
+  const promptStore = usePromptStore();
+  const [promptHints, setPromptHints] = useState<Prompt[]>([]);
+  const onSearch = useDebouncedCallback(
+    (text: string) => {
+      setPromptHints(promptStore.search(text));
+    },
+    100,
+    { leading: true, trailing: true },
+  );
+
+  const onPromptSelect = (prompt: Prompt) => {
+    setPromptHints([]);
+    inputRef.current?.focus();
+    setTimeout(() => setUserInput(prompt.content), 60);
+  };
+
+  // auto grow input
+  const [inputRows, setInputRows] = useState(2);
+  const measure = useDebouncedCallback(
+    () => {
+      const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
+      const inputRows = Math.min(
+        20,
+        Math.max(2 + Number(!isMobileScreen), rows),
+      );
+      setInputRows(inputRows);
+    },
+    100,
+    {
+      leading: true,
+      trailing: true,
+    },
+  );
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect(measure, [userInput]);
+
+  // only search prompts when user input is short
+  const SEARCH_TEXT_LIMIT = 30;
+  const onInput = (text: string) => {
+    setUserInput(text);
+    const n = text.trim().length;
+
+    // clear search results
+    if (n === 0) {
+      setPromptHints([]);
+    } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
+      // check if need to trigger auto completion
+      if (text.startsWith("/")) {
+        let searchText = text.slice(1);
+        onSearch(searchText);
+      }
+    }
+  };
+
+  const doSubmit = (userInput: string) => {
+    if (userInput.trim() === "") return;
+    setIsLoading(true);
+    chatStore.onUserInput(userInput).then(() => setIsLoading(false));
+    localStorage.setItem(LAST_INPUT_KEY, userInput);
+    setUserInput("");
+    setPromptHints([]);
+    if (!isMobileScreen) inputRef.current?.focus();
+    setAutoScroll(true);
+  };
+
+  // stop response
+  const onUserStop = (messageId: number) => {
+    ControllerPool.stop(sessionIndex, messageId);
+  };
+
+  // check if should send message
+  const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    // if ArrowUp and no userInput, fill with last input
+    if (
+      e.key === "ArrowUp" &&
+      userInput.length <= 0 &&
+      !(e.metaKey || e.altKey || e.ctrlKey)
+    ) {
+      setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
+      e.preventDefault();
+      return;
+    }
+    if (shouldSubmit(e)) {
+      doSubmit(userInput);
+      e.preventDefault();
+    }
+  };
+  const onRightClick = (e: any, message: Message) => {
+    // copy to clipboard
+    if (selectOrCopy(e.currentTarget, message.content)) {
+      e.preventDefault();
+    }
+  };
+
+  const findLastUserIndex = (messageId: number) => {
+    // find last user input message and resend
+    let lastUserMessageIndex: number | null = null;
+    for (let i = 0; i < session.messages.length; i += 1) {
+      const message = session.messages[i];
+      if (message.id === messageId) {
+        break;
+      }
+      if (message.role === "user") {
+        lastUserMessageIndex = i;
+      }
+    }
+
+    return lastUserMessageIndex;
+  };
+
+  const deleteMessage = (userIndex: number) => {
+    chatStore.updateCurrentSession((session) =>
+      session.messages.splice(userIndex, 2),
+    );
+  };
+
+  const onDelete = (botMessageId: number) => {
+    const userIndex = findLastUserIndex(botMessageId);
+    if (userIndex === null) return;
+    deleteMessage(userIndex);
+  };
+
+  const onResend = (botMessageId: number) => {
+    // find last user input message and resend
+    const userIndex = findLastUserIndex(botMessageId);
+    if (userIndex === null) return;
+
+    setIsLoading(true);
+    const content = session.messages[userIndex].content;
+    deleteMessage(userIndex);
+    chatStore.onUserInput(content).then(() => setIsLoading(false));
+    inputRef.current?.focus();
+  };
+
+  const context: RenderMessage[] = session.mask.context.slice();
+
+  const accessStore = useAccessStore();
+
+  if (
+    context.length === 0 &&
+    session.messages.at(0)?.content !== BOT_HELLO.content
+  ) {
+    const copiedHello = Object.assign({}, BOT_HELLO);
+    if (!accessStore.isAuthorized()) {
+      copiedHello.content = Locale.Error.Unauthorized;
+    }
+    context.push(copiedHello);
+  }
+
+  // preview messages
+  const messages = context
+    .concat(session.messages as RenderMessage[])
+    .concat(
+      isLoading
+        ? [
+            {
+              ...createMessage({
+                role: "assistant",
+                content: "โ€ฆโ€ฆ",
+              }),
+              preview: true,
+            },
+          ]
+        : [],
+    )
+    .concat(
+      userInput.length > 0 && config.sendPreviewBubble
+        ? [
+            {
+              ...createMessage({
+                role: "user",
+                content: userInput,
+              }),
+              preview: true,
+            },
+          ]
+        : [],
+    );
+
+  const [showPromptModal, setShowPromptModal] = useState(false);
+
+  const renameSession = () => {
+    const newTopic = prompt(Locale.Chat.Rename, session.topic);
+    if (newTopic && newTopic !== session.topic) {
+      chatStore.updateCurrentSession((session) => (session.topic = newTopic!));
+    }
+  };
+
+  const location = useLocation();
+  const isChat = location.pathname === Path.Chat;
+  const autoFocus = !isMobileScreen || isChat; // only focus in chat page
+
+  useCommand({
+    fill: setUserInput,
+    submit: (text) => {
+      doSubmit(text);
+    },
+  });
+
+  return (
+    <div className={styles.chat} key={session.id}>
+      <div className="window-header">
+        <div className="window-header-title">
+          <div
+            className={`window-header-main-title " ${styles["chat-body-title"]}`}
+            onClickCapture={renameSession}
+          >
+            {!session.topic ? DEFAULT_TOPIC : session.topic}
+          </div>
+          <div className="window-header-sub-title">
+            {Locale.Chat.SubTitle(session.messages.length)}
+          </div>
+        </div>
+        <div className="window-actions">
+          <div className={"window-action-button" + " " + styles.mobile}>
+            <IconButton
+              icon={<ReturnIcon />}
+              bordered
+              title={Locale.Chat.Actions.ChatList}
+              onClick={() => navigate(Path.Home)}
+            />
+          </div>
+          <div className="window-action-button">
+            <IconButton
+              icon={<RenameIcon />}
+              bordered
+              onClick={renameSession}
+            />
+          </div>
+          <div className="window-action-button">
+            <IconButton
+              icon={<ExportIcon />}
+              bordered
+              title={Locale.Chat.Actions.Export}
+              onClick={() => {
+                exportMessages(
+                  session.messages.filter((msg) => !msg.isError),
+                  session.topic,
+                );
+              }}
+            />
+          </div>
+          {!isMobileScreen && (
+            <div className="window-action-button">
+              <IconButton
+                icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
+                bordered
+                onClick={() => {
+                  config.update(
+                    (config) => (config.tightBorder = !config.tightBorder),
+                  );
+                }}
+              />
+            </div>
+          )}
+        </div>
+
+        <PromptToast
+          showToast={!hitBottom}
+          showModal={showPromptModal}
+          setShowModal={setShowPromptModal}
+        />
+      </div>
+
+      <div
+        className={styles["chat-body"]}
+        ref={scrollRef}
+        onScroll={(e) => onChatBodyScroll(e.currentTarget)}
+        onMouseDown={() => inputRef.current?.blur()}
+        onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
+        onTouchStart={() => {
+          inputRef.current?.blur();
+          setAutoScroll(false);
+        }}
+      >
+        {messages.map((message, i) => {
+          const isUser = message.role === "user";
+          const showActions =
+            !isUser &&
+            i > 0 &&
+            !(message.preview || message.content.length === 0);
+          const showTyping = message.preview || message.streaming;
+
+          return (
+            <div
+              key={i}
+              className={
+                isUser ? styles["chat-message-user"] : styles["chat-message"]
+              }
+            >
+              <div className={styles["chat-message-container"]}>
+                <div className={styles["chat-message-avatar"]}>
+                  {message.role === "user" ? (
+                    <Avatar avatar={config.avatar} />
+                  ) : (
+                    <MaskAvatar mask={session.mask} />
+                  )}
+                </div>
+                {showTyping && (
+                  <div className={styles["chat-message-status"]}>
+                    {Locale.Chat.Typing}
+                  </div>
+                )}
+                <div className={styles["chat-message-item"]}>
+                  {showActions && (
+                    <div className={styles["chat-message-top-actions"]}>
+                      {message.streaming ? (
+                        <div
+                          className={styles["chat-message-top-action"]}
+                          onClick={() => onUserStop(message.id ?? i)}
+                        >
+                          {Locale.Chat.Actions.Stop}
+                        </div>
+                      ) : (
+                        <>
+                          <div
+                            className={styles["chat-message-top-action"]}
+                            onClick={() => onDelete(message.id ?? i)}
+                          >
+                            {Locale.Chat.Actions.Delete}
+                          </div>
+                          <div
+                            className={styles["chat-message-top-action"]}
+                            onClick={() => onResend(message.id ?? i)}
+                          >
+                            {Locale.Chat.Actions.Retry}
+                          </div>
+                        </>
+                      )}
+
+                      <div
+                        className={styles["chat-message-top-action"]}
+                        onClick={() => copyToClipboard(message.content)}
+                      >
+                        {Locale.Chat.Actions.Copy}
+                      </div>
+                    </div>
+                  )}
+                  <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>
+                {!isUser && !message.preview && (
+                  <div className={styles["chat-message-actions"]}>
+                    <div className={styles["chat-message-action-date"]}>
+                      {message.date.toLocaleString()}
+                    </div>
+                  </div>
+                )}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+
+      <div className={styles["chat-input-panel"]}>
+        <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
+
+        <ChatActions
+          showPromptModal={() => setShowPromptModal(true)}
+          scrollToBottom={scrollToBottom}
+          hitBottom={hitBottom}
+          showPromptHints={() => {
+            // Click again to close
+            if (promptHints.length > 0) {
+              setPromptHints([]);
+              return;
+            }
+
+            inputRef.current?.focus();
+            setUserInput("/");
+            onSearch("");
+          }}
+        />
+        <div className={styles["chat-input-panel-inner"]}>
+          <textarea
+            ref={inputRef}
+            className={styles["chat-input"]}
+            placeholder={Locale.Chat.Input(submitKey)}
+            onInput={(e) => onInput(e.currentTarget.value)}
+            value={userInput}
+            onKeyDown={onInputKeyDown}
+            onFocus={() => setAutoScroll(true)}
+            onBlur={() => setAutoScroll(false)}
+            rows={inputRows}
+            autoFocus={autoFocus}
+          />
+          <IconButton
+            icon={<SendWhiteIcon />}
+            text={Locale.Chat.Send}
+            className={styles["chat-input-send"]}
+            type="primary"
+            onClick={() => doSubmit(userInput)}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 59 - 0
app/components/emoji.tsx

@@ -0,0 +1,59 @@
+import EmojiPicker, {
+  Emoji,
+  EmojiStyle,
+  Theme as EmojiTheme,
+} from "emoji-picker-react";
+
+import { ModelType } from "../store";
+
+import BotIcon from "../icons/bot.svg";
+import BlackBotIcon from "../icons/black-bot.svg";
+
+export function getEmojiUrl(unified: string, style: EmojiStyle) {
+  return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
+}
+
+export function AvatarPicker(props: {
+  onEmojiClick: (emojiId: string) => void;
+}) {
+  return (
+    <EmojiPicker
+      lazyLoadEmojis
+      theme={EmojiTheme.AUTO}
+      getEmojiUrl={getEmojiUrl}
+      onEmojiClick={(e) => {
+        props.onEmojiClick(e.unified);
+      }}
+    />
+  );
+}
+
+export function Avatar(props: { model?: ModelType; avatar?: string }) {
+  if (props.model) {
+    return (
+      <div className="no-dark">
+        {props.model?.startsWith("gpt-4") ? (
+          <BlackBotIcon className="user-avatar" />
+        ) : (
+          <BotIcon className="user-avatar" />
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="user-avatar">
+      {props.avatar && <EmojiAvatar avatar={props.avatar} />}
+    </div>
+  );
+}
+
+export function EmojiAvatar(props: { avatar: string; size?: number }) {
+  return (
+    <Emoji
+      unified={props.avatar}
+      size={props.size ?? 18}
+      getEmojiUrl={getEmojiUrl}
+    />
+  );
+}

+ 73 - 0
app/components/error.tsx

@@ -0,0 +1,73 @@
+import React from "react";
+import { IconButton } from "./button";
+import GithubIcon from "../icons/github.svg";
+import ResetIcon from "../icons/reload.svg";
+import { ISSUE_URL } from "../constant";
+import Locale from "../locales";
+import { downloadAs } from "../utils";
+
+interface IErrorBoundaryState {
+  hasError: boolean;
+  error: Error | null;
+  info: React.ErrorInfo | null;
+}
+
+export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
+  constructor(props: any) {
+    super(props);
+    this.state = { hasError: false, error: null, info: null };
+  }
+
+  componentDidCatch(error: Error, info: React.ErrorInfo) {
+    // Update state with error details
+    this.setState({ hasError: true, error, info });
+  }
+
+  clearAndSaveData() {
+    try {
+      downloadAs(
+        JSON.stringify(localStorage),
+        "chatgpt-next-web-snapshot.json",
+      );
+    } finally {
+      localStorage.clear();
+      location.reload();
+    }
+  }
+
+  render() {
+    if (this.state.hasError) {
+      // Render error message
+      return (
+        <div className="error">
+          <h2>Oops, something went wrong!</h2>
+          <pre>
+            <code>{this.state.error?.toString()}</code>
+            <code>{this.state.info?.componentStack}</code>
+          </pre>
+
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <a href={ISSUE_URL} className="report">
+              <IconButton
+                text="Report This Error"
+                icon={<GithubIcon />}
+                bordered
+              />
+            </a>
+            <IconButton
+              icon={<ResetIcon />}
+              text="Clear All Data"
+              onClick={() =>
+                confirm(Locale.Settings.Actions.ConfirmClearAll) &&
+                this.clearAndSaveData()
+              }
+              bordered
+            />
+          </div>
+        </div>
+      );
+    }
+    // if no error occurred, render children
+    return this.props.children;
+  }
+}

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

@@ -0,0 +1,573 @@
+@mixin container {
+  background-color: var(--white);
+  border: var(--border-in-light);
+  border-radius: 20px;
+  box-shadow: var(--shadow);
+  color: var(--black);
+  background-color: var(--white);
+  min-width: 600px;
+  min-height: 480px;
+  max-width: 1200px;
+
+  display: flex;
+  overflow: hidden;
+  box-sizing: border-box;
+
+  width: var(--window-width);
+  height: var(--window-height);
+}
+
+.container {
+  @include container();
+}
+
+@media only screen and (min-width: 600px) {
+  .tight-container {
+    --window-width: 100vw;
+    --window-height: var(--full-height);
+    --window-content-width: calc(100% - var(--sidebar-width));
+
+    @include container();
+
+    max-width: 100vw;
+    max-height: var(--full-height);
+
+    border-radius: 0;
+    border: 0;
+  }
+}
+
+.sidebar {
+  top: 0;
+  width: var(--sidebar-width);
+  box-sizing: border-box;
+  padding: 20px;
+  background-color: var(--second);
+  display: flex;
+  flex-direction: column;
+  box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
+  position: relative;
+  transition: width ease 0.05s;
+
+  .sidebar-header-bar {
+    display: flex;
+    margin-bottom: 20px;
+
+    .sidebar-bar-button {
+      flex-grow: 1;
+
+      &:not(:last-child) {
+        margin-right: 10px;
+      }
+    }
+  }
+}
+
+.sidebar-drag {
+  $width: 10px;
+
+  position: absolute;
+  top: 0;
+  right: 0;
+  height: 100%;
+  width: $width;
+  background-color: var(--black);
+  cursor: ew-resize;
+  opacity: 0;
+  transition: all ease 0.3s;
+
+  &:hover,
+  &:active {
+    opacity: 0.2;
+  }
+}
+
+.window-content {
+  width: var(--window-content-width);
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.mobile {
+  display: none;
+}
+
+@media only screen and (max-width: 600px) {
+  .container {
+    min-height: unset;
+    min-width: unset;
+    max-height: unset;
+    min-width: unset;
+    border: 0;
+    border-radius: 0;
+  }
+
+  .sidebar {
+    position: absolute;
+    left: -100%;
+    z-index: 1000;
+    height: var(--full-height);
+    transition: all ease 0.3s;
+    box-shadow: none;
+  }
+
+  .sidebar-show {
+    left: 0;
+  }
+
+  .mobile {
+    display: block;
+  }
+}
+
+.sidebar-header {
+  position: relative;
+  padding-top: 20px;
+  padding-bottom: 20px;
+}
+
+.sidebar-logo {
+  position: absolute;
+  right: 0;
+  bottom: 18px;
+}
+
+.sidebar-title {
+  font-size: 20px;
+  font-weight: bold;
+  animation: slide-in ease 0.3s;
+}
+
+.sidebar-sub-title {
+  font-size: 12px;
+  font-weight: 400px;
+  animation: slide-in ease 0.3s;
+}
+
+.sidebar-body {
+  flex: 1;
+  overflow: auto;
+  overflow-x: hidden;
+}
+
+.chat-item {
+  padding: 10px 14px;
+  background-color: var(--white);
+  border-radius: 10px;
+  margin-bottom: 10px;
+  box-shadow: var(--card-shadow);
+  transition: background-color 0.3s ease;
+  cursor: pointer;
+  user-select: none;
+  border: 2px solid transparent;
+  position: relative;
+}
+
+.chat-item:hover {
+  background-color: var(--hover-color);
+}
+
+.chat-item-selected {
+  border-color: var(--primary);
+}
+
+.chat-item-title {
+  font-size: 14px;
+  font-weight: bolder;
+  display: block;
+  width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  animation: slide-in ease 0.3s;
+}
+
+.chat-item-delete {
+  position: absolute;
+  top: 10px;
+  right: -20px;
+  transition: all ease 0.3s;
+  opacity: 0;
+  cursor: pointer;
+}
+
+.chat-item:hover > .chat-item-delete {
+  opacity: 0.5;
+  right: 10px;
+}
+
+.chat-item:hover > .chat-item-delete:hover {
+  opacity: 1;
+}
+
+.chat-item-info {
+  display: flex;
+  justify-content: space-between;
+  color: rgb(166, 166, 166);
+  font-size: 12px;
+  margin-top: 8px;
+  animation: slide-in ease 0.3s;
+}
+
+.chat-item-count,
+.chat-item-date {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.narrow-sidebar {
+  .sidebar-title,
+  .sidebar-sub-title {
+    display: none;
+  }
+  .sidebar-logo {
+    position: relative;
+    display: flex;
+    justify-content: center;
+  }
+
+  .sidebar-header-bar {
+    flex-direction: column;
+
+    .sidebar-bar-button {
+      &:not(:last-child) {
+        margin-right: 0;
+        margin-bottom: 10px;
+      }
+    }
+  }
+
+  .chat-item {
+    padding: 0;
+    min-height: 50px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    transition: all ease 0.3s;
+    overflow: hidden;
+
+    &:hover {
+      .chat-item-narrow {
+        transform: scale(0.7) translateX(-50%);
+      }
+    }
+  }
+
+  .chat-item-narrow {
+    line-height: 0;
+    font-weight: lighter;
+    color: var(--black);
+    transform: translateX(0);
+    transition: all ease 0.3s;
+    padding: 4px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    .chat-item-avatar {
+      display: flex;
+      justify-content: center;
+      opacity: 0.2;
+      position: absolute;
+      transform: scale(4);
+    }
+
+    .chat-item-narrow-count {
+      font-size: 24px;
+      font-weight: bolder;
+      text-align: center;
+      color: var(--primary);
+      opacity: 0.6;
+    }
+  }
+
+  .chat-item-delete {
+    top: 15px;
+  }
+
+  .chat-item:hover > .chat-item-delete {
+    opacity: 0.5;
+    right: 5px;
+  }
+
+  .sidebar-tail {
+    flex-direction: column-reverse;
+    align-items: center;
+
+    .sidebar-actions {
+      flex-direction: column-reverse;
+      align-items: center;
+
+      .sidebar-action {
+        margin-right: 0;
+        margin-top: 15px;
+      }
+    }
+  }
+}
+
+.sidebar-tail {
+  display: flex;
+  justify-content: space-between;
+  padding-top: 20px;
+}
+
+.sidebar-actions {
+  display: inline-flex;
+}
+
+.sidebar-action:not(:last-child) {
+  margin-right: 15px;
+}
+
+.chat {
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  height: 100%;
+}
+
+.chat-body {
+  flex: 1;
+  overflow: auto;
+  padding: 20px;
+  padding-bottom: 40px;
+  position: relative;
+  overscroll-behavior: none;
+}
+
+.chat-body-title {
+  cursor: pointer;
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+.chat-message {
+  display: flex;
+  flex-direction: row;
+
+  &:last-child {
+    animation: slide-in ease 0.3s;
+  }
+}
+
+.chat-message-user {
+  display: flex;
+  flex-direction: row-reverse;
+}
+
+.chat-message-container {
+  max-width: var(--message-max-width);
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+
+  &:hover {
+    .chat-message-top-actions {
+      opacity: 1;
+      right: 10px;
+      pointer-events: all;
+    }
+  }
+}
+
+.chat-message-user > .chat-message-container {
+  align-items: flex-end;
+}
+
+.chat-message-avatar {
+  margin-top: 20px;
+}
+
+.chat-message-status {
+  font-size: 12px;
+  color: #aaa;
+  line-height: 1.5;
+  margin-top: 5px;
+}
+
+.chat-message-item {
+  box-sizing: border-box;
+  max-width: 100%;
+  margin-top: 10px;
+  border-radius: 10px;
+  background-color: rgba(0, 0, 0, 0.05);
+  padding: 10px;
+  font-size: 14px;
+  user-select: text;
+  word-break: break-word;
+  border: var(--border-in-light);
+  position: relative;
+}
+
+.chat-message-top-actions {
+  font-size: 12px;
+  position: absolute;
+  right: 20px;
+  top: -26px;
+  left: 100px;
+  transition: all ease 0.3s;
+  opacity: 0;
+  pointer-events: none;
+
+  display: flex;
+  flex-direction: row-reverse;
+
+  .chat-message-top-action {
+    opacity: 0.5;
+    color: var(--black);
+    white-space: nowrap;
+    cursor: pointer;
+
+    &:hover {
+      opacity: 1;
+    }
+
+    &:not(:first-child) {
+      margin-right: 10px;
+    }
+  }
+}
+
+.chat-message-user > .chat-message-container > .chat-message-item {
+  background-color: var(--second);
+}
+
+.chat-message-actions {
+  display: flex;
+  flex-direction: row-reverse;
+  width: 100%;
+  padding-top: 5px;
+  box-sizing: border-box;
+  font-size: 12px;
+}
+
+.chat-message-action-date {
+  color: #aaa;
+}
+
+.chat-input-panel {
+  position: relative;
+  width: 100%;
+  padding: 20px;
+  padding-top: 10px;
+  box-sizing: border-box;
+  flex-direction: column;
+  border-top-left-radius: 10px;
+  border-top-right-radius: 10px;
+  border-top: var(--border-in-light);
+  box-shadow: var(--card-shadow);
+}
+
+@mixin single-line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.prompt-hints {
+  min-height: 20px;
+  width: 100%;
+  max-height: 50vh;
+  overflow: auto;
+  display: flex;
+  flex-direction: column-reverse;
+
+  background-color: var(--white);
+  border: var(--border-in-light);
+  border-radius: 10px;
+  margin-bottom: 10px;
+  box-shadow: var(--shadow);
+
+  .prompt-hint {
+    color: var(--black);
+    padding: 6px 10px;
+    animation: slide-in ease 0.3s;
+    cursor: pointer;
+    transition: all ease 0.3s;
+    border: transparent 1px solid;
+    margin: 4px;
+    border-radius: 8px;
+
+    &:not(:last-child) {
+      margin-top: 0;
+    }
+
+    .hint-title {
+      font-size: 12px;
+      font-weight: bolder;
+
+      @include single-line();
+    }
+    .hint-content {
+      font-size: 12px;
+
+      @include single-line();
+    }
+
+    &-selected,
+    &:hover {
+      border-color: var(--primary);
+    }
+  }
+}
+
+.chat-input-panel-inner {
+  display: flex;
+  flex: 1;
+}
+
+.chat-input {
+  height: 100%;
+  width: 100%;
+  border-radius: 10px;
+  border: var(--border-in-light);
+  box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
+  background-color: var(--white);
+  color: var(--black);
+  font-family: inherit;
+  padding: 10px 90px 10px 14px;
+  resize: none;
+  outline: none;
+}
+
+.chat-input:focus {
+  border: 1px solid var(--primary);
+}
+
+.chat-input-send {
+  background-color: var(--primary);
+  color: white;
+
+  position: absolute;
+  right: 30px;
+  bottom: 32px;
+}
+
+@media only screen and (max-width: 600px) {
+  .chat-input {
+    font-size: 16px;
+  }
+
+  .chat-input-send {
+    bottom: 30px;
+  }
+}
+
+.export-content {
+  white-space: break-spaces;
+  padding: 10px !important;
+}
+
+.loading-content {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  width: 100%;
+}

+ 140 - 0
app/components/home.tsx

@@ -0,0 +1,140 @@
+"use client";
+
+require("../polyfill");
+
+import { useState, useEffect } from "react";
+
+import styles from "./home.module.scss";
+
+import BotIcon from "../icons/bot.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+
+import { getCSSVar, useMobileScreen } from "../utils";
+
+import dynamic from "next/dynamic";
+import { Path, SlotID } from "../constant";
+import { ErrorBoundary } from "./error";
+
+import {
+  HashRouter as Router,
+  Routes,
+  Route,
+  useLocation,
+} from "react-router-dom";
+import { SideBar } from "./sidebar";
+import { useAppConfig } from "../store/config";
+import { useMaskStore } from "../store/mask";
+
+export function Loading(props: { noLogo?: boolean }) {
+  return (
+    <div className={styles["loading-content"] + " no-dark"}>
+      {!props.noLogo && <BotIcon />}
+      <LoadingIcon />
+    </div>
+  );
+}
+
+const Settings = dynamic(async () => (await import("./settings")).Settings, {
+  loading: () => <Loading noLogo />,
+});
+
+const Chat = dynamic(async () => (await import("./chat")).Chat, {
+  loading: () => <Loading noLogo />,
+});
+
+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();
+
+  useEffect(() => {
+    document.body.classList.remove("light");
+    document.body.classList.remove("dark");
+
+    if (config.theme === "dark") {
+      document.body.classList.add("dark");
+    } else if (config.theme === "light") {
+      document.body.classList.add("light");
+    }
+
+    const metaDescriptionDark = document.querySelector(
+      'meta[name="theme-color"][media]',
+    );
+    const metaDescriptionLight = document.querySelector(
+      'meta[name="theme-color"]:not([media])',
+    );
+
+    if (config.theme === "auto") {
+      metaDescriptionDark?.setAttribute("content", "#151515");
+      metaDescriptionLight?.setAttribute("content", "#fafafa");
+    } else {
+      const themeColor = getCSSVar("--themeColor");
+      metaDescriptionDark?.setAttribute("content", themeColor);
+      metaDescriptionLight?.setAttribute("content", themeColor);
+    }
+  }, [config.theme]);
+}
+
+const useHasHydrated = () => {
+  const [hasHydrated, setHasHydrated] = useState<boolean>(false);
+
+  useEffect(() => {
+    setHasHydrated(true);
+  }, []);
+
+  return hasHydrated;
+};
+
+function Screen() {
+  const config = useAppConfig();
+  const location = useLocation();
+  const isHome = location.pathname === Path.Home;
+  const isMobileScreen = useMobileScreen();
+
+  return (
+    <div
+      className={
+        styles.container +
+        ` ${
+          config.tightBorder && !isMobileScreen
+            ? styles["tight-container"]
+            : styles.container
+        }`
+      }
+    >
+      <SideBar className={isHome ? styles["sidebar-show"] : ""} />
+
+      <div className={styles["window-content"]} id={SlotID.AppBody}>
+        <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>
+      </div>
+    </div>
+  );
+}
+
+export function Home() {
+  useSwitchTheme();
+
+  if (!useHasHydrated()) {
+    return <Loading />;
+  }
+
+  return (
+    <ErrorBoundary>
+      <Router>
+        <Screen />
+      </Router>
+    </ErrorBoundary>
+  );
+}

+ 7 - 0
app/components/input-range.module.scss

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

+ 37 - 0
app/components/input-range.tsx

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

+ 180 - 0
app/components/markdown.tsx

@@ -0,0 +1,180 @@
+import ReactMarkdown from "react-markdown";
+import "katex/dist/katex.min.css";
+import RemarkMath from "remark-math";
+import RemarkBreaks from "remark-breaks";
+import RehypeKatex from "rehype-katex";
+import RemarkGfm from "remark-gfm";
+import RehypeHighlight from "rehype-highlight";
+import { useRef, useState, RefObject, useEffect } from "react";
+import { copyToClipboard } from "../utils";
+import mermaid from "mermaid";
+
+import LoadingIcon from "../icons/three-dots.svg";
+import React from "react";
+
+export function Mermaid(props: { code: string; onError: () => void }) {
+  const ref = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (props.code && ref.current) {
+      mermaid
+        .run({
+          nodes: [ref.current],
+        })
+        .catch((e) => {
+          props.onError();
+          console.error("[Mermaid] ", e.message);
+        });
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [props.code]);
+
+  function viewSvgInNewWindow() {
+    const svg = ref.current?.querySelector("svg");
+    if (!svg) return;
+    const text = new XMLSerializer().serializeToString(svg);
+    const blob = new Blob([text], { type: "image/svg+xml" });
+    const url = URL.createObjectURL(blob);
+    const win = window.open(url);
+    if (win) {
+      win.onload = () => URL.revokeObjectURL(url);
+    }
+  }
+
+  return (
+    <div
+      className="no-dark"
+      style={{ cursor: "pointer", overflow: "auto" }}
+      ref={ref}
+      onClick={() => viewSvgInNewWindow()}
+    >
+      {props.code}
+    </div>
+  );
+}
+
+export function PreCode(props: { children: any }) {
+  const ref = useRef<HTMLPreElement>(null);
+  const [mermaidCode, setMermaidCode] = useState("");
+
+  useEffect(() => {
+    if (!ref.current) return;
+    const mermaidDom = ref.current.querySelector("code.language-mermaid");
+    if (mermaidDom) {
+      setMermaidCode((mermaidDom as HTMLElement).innerText);
+    }
+  }, [props.children]);
+
+  if (mermaidCode) {
+    return <Mermaid code={mermaidCode} onError={() => setMermaidCode("")} />;
+  }
+
+  return (
+    <pre ref={ref}>
+      <span
+        className="copy-code-button"
+        onClick={() => {
+          if (ref.current) {
+            const code = ref.current.innerText;
+            copyToClipboard(code);
+          }
+        }}
+      ></span>
+      {props.children}
+    </pre>
+  );
+}
+
+function _MarkDownContent(props: { content: string }) {
+  return (
+    <ReactMarkdown
+      remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
+      rehypePlugins={[
+        RehypeKatex,
+        [
+          RehypeHighlight,
+          {
+            detect: false,
+            ignoreMissing: true,
+          },
+        ],
+      ]}
+      components={{
+        pre: PreCode,
+        a: (aProps) => {
+          const href = aProps.href || "";
+          const isInternal = /^\/#/i.test(href);
+          const target = isInternal ? "_self" : aProps.target ?? "_blank";
+          return <a {...aProps} target={target} />;
+        },
+      }}
+    >
+      {props.content}
+    </ReactMarkdown>
+  );
+}
+
+export const MarkdownContent = React.memo(_MarkDownContent);
+
+export function Markdown(
+  props: {
+    content: string;
+    loading?: boolean;
+    fontSize?: number;
+    parentRef: RefObject<HTMLDivElement>;
+    defaultShow?: boolean;
+  } & React.DOMAttributes<HTMLDivElement>,
+) {
+  const mdRef = useRef<HTMLDivElement>(null);
+  const renderedHeight = useRef(0);
+  const inView = useRef(!!props.defaultShow);
+
+  const parent = props.parentRef.current;
+  const md = mdRef.current;
+
+  const checkInView = () => {
+    if (parent && md) {
+      const parentBounds = parent.getBoundingClientRect();
+      const twoScreenHeight = Math.max(500, parentBounds.height * 2);
+      const mdBounds = md.getBoundingClientRect();
+      const parentTop = parentBounds.top - twoScreenHeight;
+      const parentBottom = parentBounds.bottom + twoScreenHeight;
+      const isOverlap =
+        Math.max(parentTop, mdBounds.top) <=
+        Math.min(parentBottom, mdBounds.bottom);
+      inView.current = isOverlap;
+    }
+
+    if (inView.current && md) {
+      renderedHeight.current = Math.max(
+        renderedHeight.current,
+        md.getBoundingClientRect().height,
+      );
+    }
+  };
+
+  setTimeout(() => checkInView(), 1);
+
+  return (
+    <div
+      className="markdown-body"
+      style={{
+        fontSize: `${props.fontSize ?? 14}px`,
+        height:
+          !inView.current && renderedHeight.current > 0
+            ? renderedHeight.current
+            : "auto",
+      }}
+      ref={mdRef}
+      onContextMenu={props.onContextMenu}
+      onDoubleClickCapture={props.onDoubleClickCapture}
+    >
+      {inView.current &&
+        (props.loading ? (
+          <LoadingIcon />
+        ) : (
+          <MarkdownContent content={props.content} />
+        ))}
+    </div>
+  );
+}

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

@@ -0,0 +1,108 @@
+@import "../styles/animation.scss";
+.mask-page {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .mask-page-body {
+    padding: 20px;
+    overflow-y: auto;
+
+    .mask-filter {
+      width: 100%;
+      max-width: 100%;
+      margin-bottom: 20px;
+      animation: slide-in ease 0.3s;
+      height: 40px;
+
+      display: flex;
+
+      .search-bar {
+        flex-grow: 1;
+        max-width: 100%;
+        min-width: 0;
+      }
+
+      .mask-filter-lang {
+        height: 100%;
+        margin-left: 10px;
+      }
+
+      .mask-create {
+        height: 100%;
+        margin-left: 10px;
+        box-sizing: border-box;
+        min-width: 80px;
+      }
+    }
+
+    .mask-item {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      border: var(--border-in-light);
+      animation: slide-in ease 0.3s;
+
+      &:not(:last-child) {
+        border-bottom: 0;
+      }
+
+      &:first-child {
+        border-top-left-radius: 10px;
+        border-top-right-radius: 10px;
+      }
+
+      &:last-child {
+        border-bottom-left-radius: 10px;
+        border-bottom-right-radius: 10px;
+      }
+
+      .mask-header {
+        display: flex;
+        align-items: center;
+
+        .mask-icon {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          margin-right: 10px;
+        }
+
+        .mask-title {
+          .mask-name {
+            font-size: 14px;
+            font-weight: bold;
+          }
+          .mask-info {
+            font-size: 12px;
+          }
+        }
+      }
+
+      .mask-actions {
+        display: flex;
+        flex-wrap: nowrap;
+        transition: all ease 0.3s;
+      }
+
+      @media screen and (max-width: 600px) {
+        display: flex;
+        flex-direction: column;
+        padding-bottom: 10px;
+        border-radius: 10px;
+        margin-bottom: 20px;
+        box-shadow: var(--card-shadow);
+
+        &:not(:last-child) {
+          border-bottom: var(--border-in-light);
+        }
+
+        .mask-actions {
+          width: 100%;
+          justify-content: space-between;
+          padding-top: 10px;
+        }
+      }
+    }
+  }
+}

+ 443 - 0
app/components/mask.tsx

@@ -0,0 +1,443 @@
+import { IconButton } from "./button";
+import { ErrorBoundary } from "./error";
+
+import styles from "./mask.module.scss";
+
+import DownloadIcon from "../icons/download.svg";
+import UploadIcon from "../icons/upload.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 EyeIcon from "../icons/eye.svg";
+import CopyIcon from "../icons/copy.svg";
+
+import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
+import { Message, ModelConfig, ROLES, useChatStore } from "../store";
+import { Input, List, ListItem, Modal, Popover, Select } from "./ui-lib";
+import { Avatar, AvatarPicker } from "./emoji";
+import Locale, { AllLangs, Lang } from "../locales";
+import { useNavigate } from "react-router-dom";
+
+import chatStyle from "./chat.module.scss";
+import { useState } from "react";
+import { downloadAs, readFromFile } from "../utils";
+import { Updater } from "../api/openai/typing";
+import { ModelConfigList } from "./model-config";
+import { FileName, Path } from "../constant";
+import { BUILTIN_MASK_STORE } from "../masks";
+
+export function MaskAvatar(props: { mask: Mask }) {
+  return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
+    <Avatar avatar={props.mask.avatar} />
+  ) : (
+    <Avatar model={props.mask.modelConfig.model} />
+  );
+}
+
+export function MaskConfig(props: {
+  mask: Mask;
+  updateMask: Updater<Mask>;
+  extraListItems?: JSX.Element;
+  readonly?: boolean;
+}) {
+  const [showPicker, setShowPicker] = useState(false);
+
+  const updateConfig = (updater: (config: ModelConfig) => void) => {
+    if (props.readonly) return;
+
+    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={Locale.Mask.Config.Avatar}>
+          <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" }}
+            >
+              <MaskAvatar mask={props.mask} />
+            </div>
+          </Popover>
+        </ListItem>
+        <ListItem title={Locale.Mask.Config.Name}>
+          <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>
+    </>
+  );
+}
+
+function ContextPromptItem(props: {
+  prompt: Message;
+  update: (prompt: Message) => void;
+  remove: () => void;
+}) {
+  const [focusingInput, setFocusingInput] = useState(false);
+
+  return (
+    <div className={chatStyle["context-prompt-row"]}>
+      {!focusingInput && (
+        <Select
+          value={props.prompt.role}
+          className={chatStyle["context-role"]}
+          onChange={(e) =>
+            props.update({
+              ...props.prompt,
+              role: e.target.value as any,
+            })
+          }
+        >
+          {ROLES.map((r) => (
+            <option key={r} value={r}>
+              {r}
+            </option>
+          ))}
+        </Select>
+      )}
+      <Input
+        value={props.prompt.content}
+        type="text"
+        className={chatStyle["context-content"]}
+        rows={focusingInput ? 5 : 1}
+        onFocus={() => setFocusingInput(true)}
+        onBlur={() => setFocusingInput(false)}
+        onInput={(e) =>
+          props.update({
+            ...props.prompt,
+            content: e.currentTarget.value as any,
+          })
+        }
+      />
+      {!focusingInput && (
+        <IconButton
+          icon={<DeleteIcon />}
+          className={chatStyle["context-delete-button"]}
+          onClick={() => props.remove()}
+          bordered
+        />
+      )}
+    </div>
+  );
+}
+
+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) => (
+          <ContextPromptItem
+            key={i}
+            prompt={c}
+            update={(prompt) => updateContextPrompt(i, prompt)}
+            remove={() => removeContextPrompt(i)}
+          />
+        ))}
+
+        <div className={chatStyle["context-prompt-row"]}>
+          <IconButton
+            icon={<AddIcon />}
+            text={Locale.Context.Add}
+            bordered
+            className={chatStyle["context-prompt-button"]}
+            onClick={() =>
+              addContextPrompt({
+                role: "user",
+                content: "",
+                date: "",
+              })
+            }
+          />
+        </div>
+      </div>
+    </>
+  );
+}
+
+export function MaskPage() {
+  const navigate = useNavigate();
+
+  const maskStore = useMaskStore();
+  const chatStore = useChatStore();
+
+  const [filterLang, setFilterLang] = useState<Lang>();
+
+  const allMasks = maskStore
+    .getAll()
+    .filter((m) => !filterLang || m.lang === filterLang);
+
+  const [searchMasks, setSearchMasks] = useState<Mask[]>([]);
+  const [searchText, setSearchText] = useState("");
+  const masks = searchText.length > 0 ? searchMasks : allMasks;
+
+  // simple search, will refactor later
+  const onSearch = (text: string) => {
+    setSearchText(text);
+    if (text.length > 0) {
+      const result = allMasks.filter((m) => m.name.includes(text));
+      setSearchMasks(result);
+    } else {
+      setSearchMasks(allMasks);
+    }
+  };
+
+  const [editingMaskId, setEditingMaskId] = useState<number | undefined>();
+  const editingMask =
+    maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
+  const closeMaskModal = () => setEditingMaskId(undefined);
+
+  const downloadAll = () => {
+    downloadAs(JSON.stringify(masks), FileName.Masks);
+  };
+
+  const importFromFile = () => {
+    readFromFile().then((content) => {
+      try {
+        const importMasks = JSON.parse(content);
+        if (Array.isArray(importMasks)) {
+          for (const mask of importMasks) {
+            if (mask.name) {
+              maskStore.create(mask);
+            }
+          }
+        }
+      } catch {}
+    });
+  };
+
+  return (
+    <ErrorBoundary>
+      <div className={styles["mask-page"]}>
+        <div className="window-header">
+          <div className="window-header-title">
+            <div className="window-header-main-title">
+              {Locale.Mask.Page.Title}
+            </div>
+            <div className="window-header-submai-title">
+              {Locale.Mask.Page.SubTitle(allMasks.length)}
+            </div>
+          </div>
+
+          <div className="window-actions">
+            <div className="window-action-button">
+              <IconButton
+                icon={<DownloadIcon />}
+                bordered
+                onClick={downloadAll}
+              />
+            </div>
+            <div className="window-action-button">
+              <IconButton
+                icon={<UploadIcon />}
+                bordered
+                onClick={() => importFromFile()}
+              />
+            </div>
+            <div className="window-action-button">
+              <IconButton
+                icon={<CloseIcon />}
+                bordered
+                onClick={() => navigate(-1)}
+              />
+            </div>
+          </div>
+        </div>
+
+        <div className={styles["mask-page-body"]}>
+          <div className={styles["mask-filter"]}>
+            <input
+              type="text"
+              className={styles["search-bar"]}
+              placeholder={Locale.Mask.Page.Search}
+              autoFocus
+              onInput={(e) => onSearch(e.currentTarget.value)}
+            />
+            <Select
+              className={styles["mask-filter-lang"]}
+              value={filterLang ?? Locale.Settings.Lang.All}
+              onChange={(e) => {
+                const value = e.currentTarget.value;
+                if (value === Locale.Settings.Lang.All) {
+                  setFilterLang(undefined);
+                } else {
+                  setFilterLang(value as Lang);
+                }
+              }}
+            >
+              <option key="all" value={Locale.Settings.Lang.All}>
+                {Locale.Settings.Lang.All}
+              </option>
+              {AllLangs.map((lang) => (
+                <option value={lang} key={lang}>
+                  {Locale.Settings.Lang.Options[lang]}
+                </option>
+              ))}
+            </Select>
+
+            <IconButton
+              className={styles["mask-create"]}
+              icon={<AddIcon />}
+              text={Locale.Mask.Page.Create}
+              bordered
+              onClick={() => {
+                const createdMask = maskStore.create();
+                setEditingMaskId(createdMask.id);
+              }}
+            />
+          </div>
+
+          <div>
+            {masks.map((m) => (
+              <div className={styles["mask-item"]} key={m.id}>
+                <div className={styles["mask-header"]}>
+                  <div className={styles["mask-icon"]}>
+                    <MaskAvatar mask={m} />
+                  </div>
+                  <div className={styles["mask-title"]}>
+                    <div className={styles["mask-name"]}>{m.name}</div>
+                    <div className={styles["mask-info"] + " one-line"}>
+                      {`${Locale.Mask.Item.Info(m.context.length)} / ${
+                        Locale.Settings.Lang.Options[m.lang]
+                      } / ${m.modelConfig.model}`}
+                    </div>
+                  </div>
+                </div>
+                <div className={styles["mask-actions"]}>
+                  <IconButton
+                    icon={<AddIcon />}
+                    text={Locale.Mask.Item.Chat}
+                    onClick={() => {
+                      chatStore.newSession(m);
+                      navigate(Path.Chat);
+                    }}
+                  />
+                  {m.builtin ? (
+                    <IconButton
+                      icon={<EyeIcon />}
+                      text={Locale.Mask.Item.View}
+                      onClick={() => setEditingMaskId(m.id)}
+                    />
+                  ) : (
+                    <IconButton
+                      icon={<EditIcon />}
+                      text={Locale.Mask.Item.Edit}
+                      onClick={() => setEditingMaskId(m.id)}
+                    />
+                  )}
+                  {!m.builtin && (
+                    <IconButton
+                      icon={<DeleteIcon />}
+                      text={Locale.Mask.Item.Delete}
+                      onClick={() => {
+                        if (confirm(Locale.Mask.Item.DeleteConfirm)) {
+                          maskStore.delete(m.id);
+                        }
+                      }}
+                    />
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+
+      {editingMask && (
+        <div className="modal-mask">
+          <Modal
+            title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
+            onClose={closeMaskModal}
+            actions={[
+              <IconButton
+                icon={<DownloadIcon />}
+                text={Locale.Mask.EditModal.Download}
+                key="export"
+                bordered
+                onClick={() =>
+                  downloadAs(
+                    JSON.stringify(editingMask),
+                    `${editingMask.name}.json`,
+                  )
+                }
+              />,
+              <IconButton
+                key="copy"
+                icon={<CopyIcon />}
+                bordered
+                text={Locale.Mask.EditModal.Clone}
+                onClick={() => {
+                  navigate(Path.Masks);
+                  maskStore.create(editingMask);
+                  setEditingMaskId(undefined);
+                }}
+              />,
+            ]}
+          >
+            <MaskConfig
+              mask={editingMask}
+              updateMask={(updater) =>
+                maskStore.update(editingMaskId!, updater)
+              }
+              readonly={editingMask.builtin}
+            />
+          </Modal>
+        </div>
+      )}
+    </ErrorBoundary>
+  );
+}

+ 140 - 0
app/components/model-config.tsx

@@ -0,0 +1,140 @@
+import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
+
+import Locale from "../locales";
+import { InputRange } from "./input-range";
+import { List, ListItem, Select } from "./ui-lib";
+
+export function ModelConfigList(props: {
+  modelConfig: ModelConfig;
+  updateConfig: (updater: (config: ModelConfig) => void) => void;
+}) {
+  return (
+    <>
+      <ListItem title={Locale.Settings.Model}>
+        <Select
+          value={props.modelConfig.model}
+          onChange={(e) => {
+            props.updateConfig(
+              (config) =>
+                (config.model = ModalConfigValidator.model(
+                  e.currentTarget.value,
+                )),
+            );
+          }}
+        >
+          {ALL_MODELS.map((v) => (
+            <option value={v.name} key={v.name} disabled={!v.available}>
+              {v.name}
+            </option>
+          ))}
+        </Select>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.Temperature.Title}
+        subTitle={Locale.Settings.Temperature.SubTitle}
+      >
+        <InputRange
+          value={props.modelConfig.temperature?.toFixed(1)}
+          min="0"
+          max="1" // lets limit it to 0-1
+          step="0.1"
+          onChange={(e) => {
+            props.updateConfig(
+              (config) =>
+                (config.temperature = ModalConfigValidator.temperature(
+                  e.currentTarget.valueAsNumber,
+                )),
+            );
+          }}
+        ></InputRange>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.MaxTokens.Title}
+        subTitle={Locale.Settings.MaxTokens.SubTitle}
+      >
+        <input
+          type="number"
+          min={100}
+          max={32000}
+          value={props.modelConfig.max_tokens}
+          onChange={(e) =>
+            props.updateConfig(
+              (config) =>
+                (config.max_tokens = ModalConfigValidator.max_tokens(
+                  e.currentTarget.valueAsNumber,
+                )),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem
+        title={Locale.Settings.PresencePenlty.Title}
+        subTitle={Locale.Settings.PresencePenlty.SubTitle}
+      >
+        <InputRange
+          value={props.modelConfig.presence_penalty?.toFixed(1)}
+          min="-2"
+          max="2"
+          step="0.1"
+          onChange={(e) => {
+            props.updateConfig(
+              (config) =>
+                (config.presence_penalty =
+                  ModalConfigValidator.presence_penalty(
+                    e.currentTarget.valueAsNumber,
+                  )),
+            );
+          }}
+        ></InputRange>
+      </ListItem>
+
+      <ListItem
+        title={Locale.Settings.HistoryCount.Title}
+        subTitle={Locale.Settings.HistoryCount.SubTitle}
+      >
+        <InputRange
+          title={props.modelConfig.historyMessageCount.toString()}
+          value={props.modelConfig.historyMessageCount}
+          min="0"
+          max="32"
+          step="1"
+          onChange={(e) =>
+            props.updateConfig(
+              (config) => (config.historyMessageCount = e.target.valueAsNumber),
+            )
+          }
+        ></InputRange>
+      </ListItem>
+
+      <ListItem
+        title={Locale.Settings.CompressThreshold.Title}
+        subTitle={Locale.Settings.CompressThreshold.SubTitle}
+      >
+        <input
+          type="number"
+          min={500}
+          max={4000}
+          value={props.modelConfig.compressMessageLengthThreshold}
+          onChange={(e) =>
+            props.updateConfig(
+              (config) =>
+                (config.compressMessageLengthThreshold =
+                  e.currentTarget.valueAsNumber),
+            )
+          }
+        ></input>
+      </ListItem>
+      <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
+        <input
+          type="checkbox"
+          checked={props.modelConfig.sendMemory}
+          onChange={(e) =>
+            props.updateConfig(
+              (config) => (config.sendMemory = e.currentTarget.checked),
+            )
+          }
+        ></input>
+      </ListItem>
+    </>
+  );
+}

+ 115 - 0
app/components/new-chat.module.scss

@@ -0,0 +1,115 @@
+@import "../styles/animation.scss";
+
+.new-chat {
+  height: 100%;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  .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;
+      border: var(--border-in-light);
+      box-shadow: var(--card-shadow);
+      border-radius: 14px;
+      background-color: var(--white);
+      transform: scale(1);
+
+      &:first-child {
+        transform: rotate(-15deg) translateY(5px);
+      }
+
+      &:last-child {
+        transform: rotate(15deg) translateY(5px);
+      }
+    }
+  }
+
+  .title {
+    font-size: 32px;
+    font-weight: bolder;
+    margin-bottom: 1vh;
+    animation: slide-in ease 0.35s;
+  }
+
+  .sub-title {
+    animation: slide-in ease 0.4s;
+  }
+
+  .actions {
+    margin-top: 5vh;
+    margin-bottom: 5vh;
+    animation: slide-in ease 0.45s;
+    display: flex;
+    justify-content: center;
+
+    .more {
+      font-size: 12px;
+      margin-left: 10px;
+    }
+  }
+
+  .masks {
+    flex-grow: 1;
+    width: 100%;
+    overflow: hidden;
+    align-items: center;
+    padding-top: 20px;
+
+    animation: slide-in ease 0.5s;
+
+    .mask-row {
+      margin-bottom: 10px;
+      display: flex;
+      justify-content: center;
+
+      @for $i from 1 to 10 {
+        &:nth-child(#{$i * 2}) {
+          margin-left: 50px;
+        }
+      }
+
+      .mask {
+        display: flex;
+        align-items: center;
+        padding: 10px 14px;
+        border: var(--border-in-light);
+        box-shadow: var(--card-shadow);
+        background-color: var(--white);
+        border-radius: 10px;
+        margin-right: 10px;
+        max-width: 8em;
+        transform: scale(1);
+        cursor: pointer;
+        transition: all ease 0.3s;
+
+        &:hover {
+          transform: translateY(-5px) scale(1.1);
+          z-index: 999;
+          border-color: var(--primary);
+        }
+
+        .mask-name {
+          margin-left: 10px;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+}

+ 197 - 0
app/components/new-chat.tsx

@@ -0,0 +1,197 @@
+import { useEffect, useRef, useState } from "react";
+import { Path, 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 LightningIcon from "../icons/lightning.svg";
+import EyeIcon from "../icons/eye.svg";
+
+import { useLocation, useNavigate } from "react-router-dom";
+import { Mask, useMaskStore } from "../store/mask";
+import Locale from "../locales";
+import { useAppConfig, useChatStore } from "../store";
+import { MaskAvatar } from "./mask";
+import { useCommand } from "../command";
+
+function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
+  const xmin = Math.max(aRect.x, bRect.x);
+  const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
+  const ymin = Math.max(aRect.y, bRect.y);
+  const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
+  const width = xmax - xmin;
+  const height = ymax - ymin;
+  const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
+  return intersectionArea;
+}
+
+function MaskItem(props: { mask: Mask; onClick?: () => void }) {
+  const domRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const changeOpacity = () => {
+      const dom = domRef.current;
+      const parent = document.getElementById(SlotID.AppBody);
+      if (!parent || !dom) return;
+
+      const domRect = dom.getBoundingClientRect();
+      const parentRect = parent.getBoundingClientRect();
+      const intersectionArea = getIntersectionArea(domRect, parentRect);
+      const domArea = domRect.width * domRect.height;
+      const ratio = intersectionArea / domArea;
+      const opacity = ratio > 0.9 ? 1 : 0.4;
+      dom.style.opacity = opacity.toString();
+    };
+
+    setTimeout(changeOpacity, 30);
+
+    window.addEventListener("resize", changeOpacity);
+
+    return () => window.removeEventListener("resize", changeOpacity);
+  }, [domRef]);
+
+  return (
+    <div className={styles["mask"]} ref={domRef} onClick={props.onClick}>
+      <MaskAvatar mask={props.mask} />
+      <div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
+    </div>
+  );
+}
+
+function useMaskGroup(masks: Mask[]) {
+  const [groups, setGroups] = useState<Mask[][]>([]);
+
+  useEffect(() => {
+    const appBody = document.getElementById(SlotID.AppBody);
+    if (!appBody || masks.length === 0) return;
+
+    const rect = appBody.getBoundingClientRect();
+    const maxWidth = rect.width;
+    const maxHeight = rect.height * 0.6;
+    const maskItemWidth = 120;
+    const maskItemHeight = 50;
+
+    const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
+    let maskIndex = 0;
+    const nextMask = () => masks[maskIndex++ % masks.length];
+
+    const rows = Math.ceil(maxHeight / maskItemHeight);
+    const cols = Math.ceil(maxWidth / maskItemWidth);
+
+    const newGroups = new Array(rows)
+      .fill(0)
+      .map((_, _i) =>
+        new Array(cols)
+          .fill(0)
+          .map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
+      );
+
+    setGroups(newGroups);
+
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return groups;
+}
+
+export function NewChat() {
+  const chatStore = useChatStore();
+  const maskStore = useMaskStore();
+
+  const masks = maskStore.getAll();
+  const groups = useMaskGroup(masks);
+
+  const navigate = useNavigate();
+  const config = useAppConfig();
+
+  const { state } = useLocation();
+
+  const startChat = (mask?: Mask) => {
+    chatStore.newSession(mask);
+    setTimeout(() => navigate(Path.Chat), 1);
+  };
+
+  useCommand({
+    mask: (id) => {
+      try {
+        const mask = maskStore.get(parseInt(id));
+        startChat(mask ?? undefined);
+      } catch {
+        console.error("[New Chat] failed to create chat from mask id=", id);
+      }
+    },
+  });
+
+  return (
+    <div className={styles["new-chat"]}>
+      <div className={styles["mask-header"]}>
+        <IconButton
+          icon={<LeftIcon />}
+          text={Locale.NewChat.Return}
+          onClick={() => navigate(Path.Home)}
+        ></IconButton>
+        {!state?.fromHome && (
+          <IconButton
+            text={Locale.NewChat.NotShow}
+            onClick={() => {
+              if (confirm(Locale.NewChat.ConfirmNoShow)) {
+                startChat();
+                config.update(
+                  (config) => (config.dontShowMaskSplashScreen = true),
+                );
+              }
+            }}
+          ></IconButton>
+        )}
+      </div>
+      <div className={styles["mask-cards"]}>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="1f606" size={24} />
+        </div>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="1f916" size={24} />
+        </div>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="1f479" size={24} />
+        </div>
+      </div>
+
+      <div className={styles["title"]}>{Locale.NewChat.Title}</div>
+      <div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
+
+      <div className={styles["actions"]}>
+        <IconButton
+          text={Locale.NewChat.Skip}
+          onClick={() => startChat()}
+          icon={<LightningIcon />}
+          type="primary"
+          shadow
+        />
+
+        <IconButton
+          className={styles["more"]}
+          text={Locale.NewChat.More}
+          onClick={() => navigate(Path.Masks)}
+          icon={<EyeIcon />}
+          bordered
+          shadow
+        />
+      </div>
+
+      <div className={styles["masks"]}>
+        {groups.map((masks, i) => (
+          <div key={i} className={styles["mask-row"]}>
+            {masks.map((mask, index) => (
+              <MaskItem
+                key={index}
+                mask={mask}
+                onClick={() => startChat(mask)}
+              />
+            ))}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,72 @@
+.settings {
+  padding: 20px;
+  overflow: auto;
+}
+
+.avatar {
+  cursor: pointer;
+}
+
+.edit-prompt-modal {
+  display: flex;
+  flex-direction: column;
+
+  .edit-prompt-title {
+    max-width: unset;
+    margin-bottom: 20px;
+    text-align: left;
+  }
+  .edit-prompt-content {
+    max-width: unset;
+  }
+}
+
+.user-prompt-modal {
+  min-height: 40vh;
+
+  .user-prompt-search {
+    width: 100%;
+    max-width: 100%;
+    margin-bottom: 10px;
+    background-color: var(--gray);
+  }
+
+  .user-prompt-list {
+    border: var(--border-in-light);
+    border-radius: 10px;
+
+    .user-prompt-item {
+      display: flex;
+      justify-content: space-between;
+      padding: 10px;
+
+      &:not(:last-child) {
+        border-bottom: var(--border-in-light);
+      }
+
+      .user-prompt-header {
+        max-width: calc(100% - 100px);
+
+        .user-prompt-title {
+          font-size: 14px;
+          line-height: 2;
+          font-weight: bold;
+        }
+        .user-prompt-content {
+          font-size: 12px;
+        }
+      }
+
+      .user-prompt-buttons {
+        display: flex;
+        align-items: center;
+        column-gap: 2px;
+
+        .user-prompt-button {
+          //height: 100%;
+          padding: 7px;
+        }
+      }
+    }
+  }
+}

+ 590 - 0
app/components/settings.tsx

@@ -0,0 +1,590 @@
+import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
+
+import styles from "./settings.module.scss";
+
+import ResetIcon from "../icons/reload.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import CopyIcon from "../icons/copy.svg";
+import ClearIcon from "../icons/clear.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import EditIcon from "../icons/edit.svg";
+import EyeIcon from "../icons/eye.svg";
+import {
+  Input,
+  List,
+  ListItem,
+  Modal,
+  PasswordInput,
+  Popover,
+  Select,
+} from "./ui-lib";
+import { ModelConfigList } from "./model-config";
+
+import { IconButton } from "./button";
+import {
+  SubmitKey,
+  useChatStore,
+  Theme,
+  useUpdateStore,
+  useAccessStore,
+  useAppConfig,
+} from "../store";
+
+import Locale, { AllLangs, changeLang, getLang } from "../locales";
+import { copyToClipboard } from "../utils";
+import Link from "next/link";
+import { Path, UPDATE_URL } from "../constant";
+import { Prompt, SearchService, usePromptStore } from "../store/prompt";
+import { ErrorBoundary } from "./error";
+import { InputRange } from "./input-range";
+import { useNavigate } from "react-router-dom";
+import { Avatar, AvatarPicker } from "./emoji";
+
+function EditPromptModal(props: { id: number; onClose: () => void }) {
+  const promptStore = usePromptStore();
+  const prompt = promptStore.get(props.id);
+
+  return prompt ? (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Settings.Prompt.EditModal.Title}
+        onClose={props.onClose}
+        actions={[
+          <IconButton
+            key=""
+            onClick={props.onClose}
+            text={Locale.UI.Confirm}
+            bordered
+          />,
+        ]}
+      >
+        <div className={styles["edit-prompt-modal"]}>
+          <input
+            type="text"
+            value={prompt.title}
+            readOnly={!prompt.isUser}
+            className={styles["edit-prompt-title"]}
+            onInput={(e) =>
+              promptStore.update(
+                props.id,
+                (prompt) => (prompt.title = e.currentTarget.value),
+              )
+            }
+          ></input>
+          <Input
+            value={prompt.content}
+            readOnly={!prompt.isUser}
+            className={styles["edit-prompt-content"]}
+            rows={10}
+            onInput={(e) =>
+              promptStore.update(
+                props.id,
+                (prompt) => (prompt.content = e.currentTarget.value),
+              )
+            }
+          ></Input>
+        </div>
+      </Modal>
+    </div>
+  ) : null;
+}
+
+function UserPromptModal(props: { onClose?: () => void }) {
+  const promptStore = usePromptStore();
+  const userPrompts = promptStore.getUserPrompts();
+  const builtinPrompts = SearchService.builtinPrompts;
+  const allPrompts = userPrompts.concat(builtinPrompts);
+  const [searchInput, setSearchInput] = useState("");
+  const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
+  const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
+
+  const [editingPromptId, setEditingPromptId] = useState<number>();
+
+  useEffect(() => {
+    if (searchInput.length > 0) {
+      const searchResult = SearchService.search(searchInput);
+      setSearchPrompts(searchResult);
+    } else {
+      setSearchPrompts([]);
+    }
+  }, [searchInput]);
+
+  return (
+    <div className="modal-mask">
+      <Modal
+        title={Locale.Settings.Prompt.Modal.Title}
+        onClose={() => props.onClose?.()}
+        actions={[
+          <IconButton
+            key="add"
+            onClick={() =>
+              promptStore.add({
+                title: "Empty Prompt",
+                content: "Empty Prompt Content",
+              })
+            }
+            icon={<AddIcon />}
+            bordered
+            text={Locale.Settings.Prompt.Modal.Add}
+          />,
+        ]}
+      >
+        <div className={styles["user-prompt-modal"]}>
+          <input
+            type="text"
+            className={styles["user-prompt-search"]}
+            placeholder={Locale.Settings.Prompt.Modal.Search}
+            value={searchInput}
+            onInput={(e) => setSearchInput(e.currentTarget.value)}
+          ></input>
+
+          <div className={styles["user-prompt-list"]}>
+            {prompts.map((v, _) => (
+              <div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
+                <div className={styles["user-prompt-header"]}>
+                  <div className={styles["user-prompt-title"]}>{v.title}</div>
+                  <div className={styles["user-prompt-content"] + " one-line"}>
+                    {v.content}
+                  </div>
+                </div>
+
+                <div className={styles["user-prompt-buttons"]}>
+                  {v.isUser && (
+                    <IconButton
+                      icon={<ClearIcon />}
+                      className={styles["user-prompt-button"]}
+                      onClick={() => promptStore.remove(v.id!)}
+                    />
+                  )}
+                  {v.isUser ? (
+                    <IconButton
+                      icon={<EditIcon />}
+                      className={styles["user-prompt-button"]}
+                      onClick={() => setEditingPromptId(v.id)}
+                    />
+                  ) : (
+                    <IconButton
+                      icon={<EyeIcon />}
+                      className={styles["user-prompt-button"]}
+                      onClick={() => setEditingPromptId(v.id)}
+                    />
+                  )}
+                  <IconButton
+                    icon={<CopyIcon />}
+                    className={styles["user-prompt-button"]}
+                    onClick={() => copyToClipboard(v.content)}
+                  />
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </Modal>
+
+      {editingPromptId !== undefined && (
+        <EditPromptModal
+          id={editingPromptId!}
+          onClose={() => setEditingPromptId(undefined)}
+        />
+      )}
+    </div>
+  );
+}
+
+function formatVersionDate(t: string) {
+  const d = new Date(+t);
+  const year = d.getUTCFullYear();
+  const month = d.getUTCMonth() + 1;
+  const day = d.getUTCDate();
+
+  return [
+    year.toString(),
+    month.toString().padStart(2, "0"),
+    day.toString().padStart(2, "0"),
+  ].join("");
+}
+
+export function Settings() {
+  const navigate = useNavigate();
+  const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+  const config = useAppConfig();
+  const updateConfig = config.update;
+  const resetConfig = config.reset;
+  const chatStore = useChatStore();
+
+  const updateStore = useUpdateStore();
+  const [checkingUpdate, setCheckingUpdate] = useState(false);
+  const currentVersion = formatVersionDate(updateStore.version);
+  const remoteId = formatVersionDate(updateStore.remoteVersion);
+  const hasNewVersion = currentVersion !== remoteId;
+
+  function checkUpdate(force = false) {
+    setCheckingUpdate(true);
+    updateStore.getLatestVersion(force).then(() => {
+      setCheckingUpdate(false);
+    });
+
+    console.log(
+      "[Update] local version ",
+      new Date(+updateStore.version).toLocaleString(),
+    );
+    console.log(
+      "[Update] remote version ",
+      new Date(+updateStore.remoteVersion).toLocaleString(),
+    );
+  }
+
+  const usage = {
+    used: updateStore.used,
+    subscription: updateStore.subscription,
+  };
+  const [loadingUsage, setLoadingUsage] = useState(false);
+  function checkUsage(force = false) {
+    setLoadingUsage(true);
+    updateStore.updateUsage(force).finally(() => {
+      setLoadingUsage(false);
+    });
+  }
+
+  const accessStore = useAccessStore();
+  const enabledAccessControl = useMemo(
+    () => accessStore.enabledAccessControl(),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [],
+  );
+
+  const promptStore = usePromptStore();
+  const builtinCount = SearchService.count.builtin;
+  const customCount = promptStore.getUserPrompts().length ?? 0;
+  const [shouldShowPromptModal, setShowPromptModal] = useState(false);
+
+  const showUsage = accessStore.isAuthorized();
+  useEffect(() => {
+    // checks per minutes
+    checkUpdate();
+    showUsage && checkUsage();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    const keydownEvent = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        navigate(Path.Home);
+      }
+    };
+    document.addEventListener("keydown", keydownEvent);
+    return () => {
+      document.removeEventListener("keydown", keydownEvent);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return (
+    <ErrorBoundary>
+      <div className="window-header">
+        <div className="window-header-title">
+          <div className="window-header-main-title">
+            {Locale.Settings.Title}
+          </div>
+          <div className="window-header-sub-title">
+            {Locale.Settings.SubTitle}
+          </div>
+        </div>
+        <div className="window-actions">
+          <div className="window-action-button">
+            <IconButton
+              icon={<ClearIcon />}
+              onClick={() => {
+                if (confirm(Locale.Settings.Actions.ConfirmClearAll)) {
+                  chatStore.clearAllData();
+                }
+              }}
+              bordered
+              title={Locale.Settings.Actions.ClearAll}
+            />
+          </div>
+          <div className="window-action-button">
+            <IconButton
+              icon={<ResetIcon />}
+              onClick={() => {
+                if (confirm(Locale.Settings.Actions.ConfirmResetAll)) {
+                  resetConfig();
+                }
+              }}
+              bordered
+              title={Locale.Settings.Actions.ResetAll}
+            />
+          </div>
+          <div className="window-action-button">
+            <IconButton
+              icon={<CloseIcon />}
+              onClick={() => navigate(Path.Home)}
+              bordered
+              title={Locale.Settings.Actions.Close}
+            />
+          </div>
+        </div>
+      </div>
+      <div className={styles["settings"]}>
+        <List>
+          <ListItem title={Locale.Settings.Avatar}>
+            <Popover
+              onClose={() => setShowEmojiPicker(false)}
+              content={
+                <AvatarPicker
+                  onEmojiClick={(avatar: string) => {
+                    updateConfig((config) => (config.avatar = avatar));
+                    setShowEmojiPicker(false);
+                  }}
+                />
+              }
+              open={showEmojiPicker}
+            >
+              <div
+                className={styles.avatar}
+                onClick={() => setShowEmojiPicker(true)}
+              >
+                <Avatar avatar={config.avatar} />
+              </div>
+            </Popover>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
+            subTitle={
+              checkingUpdate
+                ? Locale.Settings.Update.IsChecking
+                : hasNewVersion
+                ? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
+                : Locale.Settings.Update.IsLatest
+            }
+          >
+            {checkingUpdate ? (
+              <LoadingIcon />
+            ) : hasNewVersion ? (
+              <Link href={UPDATE_URL} target="_blank" className="link">
+                {Locale.Settings.Update.GoToUpdate}
+              </Link>
+            ) : (
+              <IconButton
+                icon={<ResetIcon></ResetIcon>}
+                text={Locale.Settings.Update.CheckUpdate}
+                onClick={() => checkUpdate(true)}
+              />
+            )}
+          </ListItem>
+
+          <ListItem title={Locale.Settings.SendKey}>
+            <Select
+              value={config.submitKey}
+              onChange={(e) => {
+                updateConfig(
+                  (config) =>
+                    (config.submitKey = e.target.value as any as SubmitKey),
+                );
+              }}
+            >
+              {Object.values(SubmitKey).map((v) => (
+                <option value={v} key={v}>
+                  {v}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+
+          <ListItem title={Locale.Settings.Theme}>
+            <Select
+              value={config.theme}
+              onChange={(e) => {
+                updateConfig(
+                  (config) => (config.theme = e.target.value as any as Theme),
+                );
+              }}
+            >
+              {Object.values(Theme).map((v) => (
+                <option value={v} key={v}>
+                  {v}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+
+          <ListItem title={Locale.Settings.Lang.Name}>
+            <Select
+              value={getLang()}
+              onChange={(e) => {
+                changeLang(e.target.value as any);
+              }}
+            >
+              {AllLangs.map((lang) => (
+                <option value={lang} key={lang}>
+                  {Locale.Settings.Lang.Options[lang]}
+                </option>
+              ))}
+            </Select>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.FontSize.Title}
+            subTitle={Locale.Settings.FontSize.SubTitle}
+          >
+            <InputRange
+              title={`${config.fontSize ?? 14}px`}
+              value={config.fontSize}
+              min="12"
+              max="18"
+              step="1"
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.fontSize = Number.parseInt(e.currentTarget.value)),
+                )
+              }
+            ></InputRange>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.SendPreviewBubble.Title}
+            subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={config.sendPreviewBubble}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.sendPreviewBubble = e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+
+          <ListItem
+            title={Locale.Settings.Mask.Title}
+            subTitle={Locale.Settings.Mask.SubTitle}
+          >
+            <input
+              type="checkbox"
+              checked={!config.dontShowMaskSplashScreen}
+              onChange={(e) =>
+                updateConfig(
+                  (config) =>
+                    (config.dontShowMaskSplashScreen =
+                      !e.currentTarget.checked),
+                )
+              }
+            ></input>
+          </ListItem>
+        </List>
+
+        <List>
+          {enabledAccessControl ? (
+            <ListItem
+              title={Locale.Settings.AccessCode.Title}
+              subTitle={Locale.Settings.AccessCode.SubTitle}
+            >
+              <PasswordInput
+                value={accessStore.accessCode}
+                type="text"
+                placeholder={Locale.Settings.AccessCode.Placeholder}
+                onChange={(e) => {
+                  accessStore.updateCode(e.currentTarget.value);
+                }}
+              />
+            </ListItem>
+          ) : (
+            <></>
+          )}
+
+          {!accessStore.hideUserApiKey ? (
+            <ListItem
+              title={Locale.Settings.Token.Title}
+              subTitle={Locale.Settings.Token.SubTitle}
+            >
+              <PasswordInput
+                value={accessStore.token}
+                type="text"
+                placeholder={Locale.Settings.Token.Placeholder}
+                onChange={(e) => {
+                  accessStore.updateToken(e.currentTarget.value);
+                }}
+              />
+            </ListItem>
+          ) : null}
+
+          <ListItem
+            title={Locale.Settings.Usage.Title}
+            subTitle={
+              showUsage
+                ? loadingUsage
+                  ? Locale.Settings.Usage.IsChecking
+                  : Locale.Settings.Usage.SubTitle(
+                      usage?.used ?? "[?]",
+                      usage?.subscription ?? "[?]",
+                    )
+                : Locale.Settings.Usage.NoAccess
+            }
+          >
+            {!showUsage || loadingUsage ? (
+              <div />
+            ) : (
+              <IconButton
+                icon={<ResetIcon></ResetIcon>}
+                text={Locale.Settings.Usage.Check}
+                onClick={() => checkUsage(true)}
+              />
+            )}
+          </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>
+          <ModelConfigList
+            modelConfig={config.modelConfig}
+            updateConfig={(upater) => {
+              const modelConfig = { ...config.modelConfig };
+              upater(modelConfig);
+              config.update((config) => (config.modelConfig = modelConfig));
+            }}
+          />
+        </List>
+
+        {shouldShowPromptModal && (
+          <UserPromptModal onClose={() => setShowPromptModal(false)} />
+        )}
+      </div>
+    </ErrorBoundary>
+  );
+}

+ 189 - 0
app/components/sidebar.tsx

@@ -0,0 +1,189 @@
+import { useEffect, useRef } from "react";
+
+import styles from "./home.module.scss";
+
+import { IconButton } from "./button";
+import SettingsIcon from "../icons/settings.svg";
+import GithubIcon from "../icons/github.svg";
+import ChatGptIcon from "../icons/chatgpt.svg";
+import AddIcon from "../icons/add.svg";
+import CloseIcon from "../icons/close.svg";
+import MaskIcon from "../icons/mask.svg";
+import PluginIcon from "../icons/plugin.svg";
+
+import Locale from "../locales";
+
+import { useAppConfig, useChatStore } from "../store";
+
+import {
+  MAX_SIDEBAR_WIDTH,
+  MIN_SIDEBAR_WIDTH,
+  NARROW_SIDEBAR_WIDTH,
+  Path,
+  REPO_URL,
+} from "../constant";
+
+import { Link, useNavigate } from "react-router-dom";
+import { useMobileScreen } from "../utils";
+import dynamic from "next/dynamic";
+import { showToast } from "./ui-lib";
+
+const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
+  loading: () => null,
+});
+
+function useHotKey() {
+  const chatStore = useChatStore();
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.metaKey || e.altKey || e.ctrlKey) {
+        const n = chatStore.sessions.length;
+        const limit = (x: number) => (x + n) % n;
+        const i = chatStore.currentSessionIndex;
+        if (e.key === "ArrowUp") {
+          chatStore.selectSession(limit(i - 1));
+        } else if (e.key === "ArrowDown") {
+          chatStore.selectSession(limit(i + 1));
+        }
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  });
+}
+
+function useDragSideBar() {
+  const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
+
+  const config = useAppConfig();
+  const startX = useRef(0);
+  const startDragWidth = useRef(config.sidebarWidth ?? 300);
+  const lastUpdateTime = useRef(Date.now());
+
+  const handleMouseMove = useRef((e: MouseEvent) => {
+    if (Date.now() < lastUpdateTime.current + 50) {
+      return;
+    }
+    lastUpdateTime.current = Date.now();
+    const d = e.clientX - startX.current;
+    const nextWidth = limit(startDragWidth.current + d);
+    config.update((config) => (config.sidebarWidth = nextWidth));
+  });
+
+  const handleMouseUp = useRef(() => {
+    startDragWidth.current = config.sidebarWidth ?? 300;
+    window.removeEventListener("mousemove", handleMouseMove.current);
+    window.removeEventListener("mouseup", handleMouseUp.current);
+  });
+
+  const onDragMouseDown = (e: MouseEvent) => {
+    startX.current = e.clientX;
+
+    window.addEventListener("mousemove", handleMouseMove.current);
+    window.addEventListener("mouseup", handleMouseUp.current);
+  };
+  const isMobileScreen = useMobileScreen();
+  const shouldNarrow =
+    !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
+
+  useEffect(() => {
+    const barWidth = shouldNarrow
+      ? NARROW_SIDEBAR_WIDTH
+      : limit(config.sidebarWidth ?? 300);
+    const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
+    document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
+  }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
+
+  return {
+    onDragMouseDown,
+    shouldNarrow,
+  };
+}
+
+export function SideBar(props: { className?: string }) {
+  const chatStore = useChatStore();
+
+  // drag side bar
+  const { onDragMouseDown, shouldNarrow } = useDragSideBar();
+  const navigate = useNavigate();
+  const config = useAppConfig();
+
+  useHotKey();
+
+  return (
+    <div
+      className={`${styles.sidebar} ${props.className} ${
+        shouldNarrow && styles["narrow-sidebar"]
+      }`}
+    >
+      <div className={styles["sidebar-header"]}>
+        <div className={styles["sidebar-title"]}>AI.DW</div>
+        <div className={styles["sidebar-sub-title"]}></div>
+        <div className={styles["sidebar-logo"] + " no-dark"}></div>
+      </div>
+
+      <div className={styles["sidebar-header-bar"]}>
+        <IconButton
+          icon={<MaskIcon />}
+          text={shouldNarrow ? undefined : Locale.Mask.Name}
+          className={styles["sidebar-bar-button"]}
+          onClick={() => navigate(Path.NewChat, { state: { fromHome: true } })}
+          shadow
+        />
+      </div>
+
+      <div
+        className={styles["sidebar-body"]}
+        onClick={(e) => {
+          if (e.target === e.currentTarget) {
+            navigate(Path.Home);
+          }
+        }}
+      >
+        <ChatList narrow={shouldNarrow} />
+      </div>
+
+      <div className={styles["sidebar-tail"]}>
+        <div className={styles["sidebar-actions"]}>
+          <div className={styles["sidebar-action"] + " " + styles.mobile}>
+            <IconButton
+              icon={<CloseIcon />}
+              onClick={() => {
+                if (confirm(Locale.Home.DeleteChat)) {
+                  chatStore.deleteSession(chatStore.currentSessionIndex);
+                }
+              }}
+            />
+          </div>
+          <div className={styles["sidebar-action"]}>
+            <Link to={Path.Settings}>
+              <IconButton icon={<SettingsIcon />} shadow />
+            </Link>
+          </div>
+        </div>
+        <div>
+          <IconButton
+            icon={<AddIcon />}
+            text={shouldNarrow ? undefined : Locale.Home.NewChat}
+            onClick={() => {
+              if (config.dontShowMaskSplashScreen) {
+                chatStore.newSession();
+                navigate(Path.Chat);
+              } else {
+                navigate(Path.NewChat);
+              }
+            }}
+            shadow
+          />
+        </div>
+      </div>
+
+      <div
+        className={styles["sidebar-drag"]}
+        onMouseDown={(e) => onDragMouseDown(e as any)}
+      ></div>
+    </div>
+  );
+}

+ 230 - 0
app/components/ui-lib.module.scss

@@ -0,0 +1,230 @@
+@import "../styles/animation.scss";
+
+.card {
+  background-color: var(--white);
+  border-radius: 10px;
+  box-shadow: var(--card-shadow);
+  padding: 10px;
+}
+
+.popover {
+  position: relative;
+  z-index: 2;
+}
+
+.popover-content {
+  position: absolute;
+  animation: slide-in 0.3s ease;
+  right: 0;
+  top: calc(100% + 10px);
+}
+
+.popover-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+}
+
+.list-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  min-height: 40px;
+  border-bottom: var(--border-in-light);
+  padding: 10px 20px;
+  animation: slide-in ease 0.6s;
+
+  .list-header {
+    display: flex;
+    align-items: center;
+
+    .list-icon {
+      margin-right: 10px;
+    }
+
+    .list-item-title {
+      font-size: 14px;
+      font-weight: bolder;
+    }
+
+    .list-item-sub-title {
+      font-size: 12px;
+      font-weight: normal;
+    }
+  }
+}
+
+.list {
+  border: var(--border-in-light);
+  border-radius: 10px;
+  box-shadow: var(--card-shadow);
+  margin-bottom: 20px;
+  animation: slide-in ease 0.3s;
+}
+
+.list .list-item:last-child {
+  border: 0;
+}
+
+.modal-container {
+  box-shadow: var(--card-shadow);
+  background-color: var(--white);
+  border-radius: 12px;
+  width: 60vw;
+  animation: slide-in ease 0.3s;
+
+  --modal-padding: 20px;
+
+  .modal-header {
+    padding: var(--modal-padding);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border-bottom: var(--border-in-light);
+
+    .modal-title {
+      font-weight: bolder;
+      font-size: 16px;
+    }
+
+    .modal-close-btn {
+      cursor: pointer;
+
+      &:hover {
+        filter: brightness(1.2);
+      }
+    }
+  }
+
+  .modal-content {
+    max-height: 40vh;
+    padding: var(--modal-padding);
+    overflow: auto;
+  }
+
+  .modal-footer {
+    padding: var(--modal-padding);
+    display: flex;
+    justify-content: flex-end;
+    border-top: var(--border-in-light);
+    box-shadow: var(--shadow);
+
+    .modal-actions {
+      display: flex;
+      align-items: center;
+
+      .modal-action {
+        &:not(:last-child) {
+          margin-right: 20px;
+        }
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .modal-container {
+    width: 100vw;
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
+
+    .modal-content {
+      max-height: 50vh;
+    }
+  }
+}
+
+.show {
+  opacity: 1;
+  transition: all ease 0.3s;
+  transform: translateY(0);
+  position: fixed;
+  left: 0;
+  bottom: 0;
+  animation: slide-in ease 0.6s;
+  z-index: 99999;
+}
+
+.hide {
+  opacity: 0;
+  transition: all ease 0.3s;
+  transform: translateY(20px);
+}
+
+.toast-container {
+  position: fixed;
+  bottom: 5vh;
+  left: 0;
+  width: 100vw;
+  display: flex;
+  justify-content: center;
+  pointer-events: none;
+
+  .toast-content {
+    max-width: 80vw;
+    word-break: break-all;
+    font-size: 14px;
+    background-color: var(--white);
+    box-shadow: var(--card-shadow);
+    border: var(--border-in-light);
+    color: var(--black);
+    padding: 10px 20px;
+    border-radius: 50px;
+    margin-bottom: 20px;
+    display: flex;
+    align-items: center;
+    pointer-events: all;
+
+    .toast-action {
+      padding-left: 20px;
+      color: var(--primary);
+      opacity: 0.8;
+      border: 0;
+      background: none;
+      cursor: pointer;
+      font-family: inherit;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+.input {
+  border: var(--border-in-light);
+  border-radius: 10px;
+  padding: 10px;
+  font-family: inherit;
+  background-color: var(--white);
+  color: var(--black);
+  resize: none;
+  min-width: 50px;
+}
+
+.select-with-icon {
+  position: relative;
+  max-width: fit-content;
+  
+  .select-with-icon-select {
+    height: 100%;
+    border: var(--border-in-light);
+    padding: 10px 25px 10px 10px;
+    border-radius: 10px;
+    appearance: none;
+    cursor: pointer;
+    background-color: var(--white);
+    color: var(--black);
+    text-align: center;
+  }
+
+  .select-with-icon-icon {
+    position: absolute;
+    top: 50%;
+    right: 10px;
+    transform: translateY(-50%);
+    pointer-events: none;
+  }
+}

+ 264 - 0
app/components/ui-lib.tsx

@@ -0,0 +1,264 @@
+import styles from "./ui-lib.module.scss";
+import LoadingIcon from "../icons/three-dots.svg";
+import CloseIcon from "../icons/close.svg";
+import EyeIcon from "../icons/eye.svg";
+import EyeOffIcon from "../icons/eye-off.svg";
+import DownIcon from "../icons/down.svg";
+
+import { createRoot } from "react-dom/client";
+import React, { HTMLProps, useEffect, useState } from "react";
+import { IconButton } from "./button";
+
+export function Popover(props: {
+  children: JSX.Element;
+  content: JSX.Element;
+  open?: boolean;
+  onClose?: () => void;
+}) {
+  return (
+    <div className={styles.popover}>
+      {props.children}
+      {props.open && (
+        <div className={styles["popover-content"]}>
+          <div className={styles["popover-mask"]} onClick={props.onClose}></div>
+          {props.content}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export function Card(props: { children: JSX.Element[]; className?: string }) {
+  return (
+    <div className={styles.card + " " + props.className}>{props.children}</div>
+  );
+}
+
+export function ListItem(props: {
+  title: string;
+  subTitle?: string;
+  children?: JSX.Element | JSX.Element[];
+  icon?: JSX.Element;
+  className?: string;
+}) {
+  return (
+    <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:
+    | Array<JSX.Element | null | undefined>
+    | JSX.Element
+    | null
+    | undefined;
+}) {
+  return <div className={styles.list}>{props.children}</div>;
+}
+
+export function Loading() {
+  return (
+    <div
+      style={{
+        height: "100vh",
+        width: "100vw",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+      }}
+    >
+      <LoadingIcon />
+    </div>
+  );
+}
+
+interface ModalProps {
+  title: string;
+  children?: JSX.Element | JSX.Element[];
+  actions?: JSX.Element[];
+  onClose?: () => void;
+}
+export function Modal(props: ModalProps) {
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        props.onClose?.();
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+
+    return () => {
+      window.removeEventListener("keydown", onKeyDown);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return (
+    <div className={styles["modal-container"]}>
+      <div className={styles["modal-header"]}>
+        <div className={styles["modal-title"]}>{props.title}</div>
+
+        <div className={styles["modal-close-btn"]} onClick={props.onClose}>
+          <CloseIcon />
+        </div>
+      </div>
+
+      <div className={styles["modal-content"]}>{props.children}</div>
+
+      <div className={styles["modal-footer"]}>
+        <div className={styles["modal-actions"]}>
+          {props.actions?.map((action, i) => (
+            <div key={i} className={styles["modal-action"]}>
+              {action}
+            </div>
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function showModal(props: ModalProps) {
+  const div = document.createElement("div");
+  div.className = "modal-mask";
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const closeModal = () => {
+    props.onClose?.();
+    root.unmount();
+    div.remove();
+  };
+
+  div.onclick = (e) => {
+    if (e.target === div) {
+      closeModal();
+    }
+  };
+
+  root.render(<Modal {...props} onClose={closeModal}></Modal>);
+}
+
+export type ToastProps = {
+  content: string;
+  action?: {
+    text: string;
+    onClick: () => void;
+  };
+  onClose?: () => void;
+};
+
+export function Toast(props: ToastProps) {
+  return (
+    <div className={styles["toast-container"]}>
+      <div className={styles["toast-content"]}>
+        <span>{props.content}</span>
+        {props.action && (
+          <button
+            onClick={() => {
+              props.action?.onClick?.();
+              props.onClose?.();
+            }}
+            className={styles["toast-action"]}
+          >
+            {props.action.text}
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function showToast(
+  content: string,
+  action?: ToastProps["action"],
+  delay = 3000,
+) {
+  const div = document.createElement("div");
+  div.className = styles.show;
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const close = () => {
+    div.classList.add(styles.hide);
+
+    setTimeout(() => {
+      root.unmount();
+      div.remove();
+    }, 300);
+  };
+
+  setTimeout(() => {
+    close();
+  }, delay);
+
+  root.render(<Toast content={content} action={action} onClose={close} />);
+}
+
+export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
+  autoHeight?: boolean;
+  rows?: number;
+};
+
+export function Input(props: InputProps) {
+  return (
+    <textarea
+      {...props}
+      className={`${styles["input"]} ${props.className}`}
+    ></textarea>
+  );
+}
+
+export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
+  const [visible, setVisible] = useState(false);
+
+  function changeVisibility() {
+    setVisible(!visible);
+  }
+
+  return (
+    <div className={"password-input-container"}>
+      <IconButton
+        icon={visible ? <EyeIcon /> : <EyeOffIcon />}
+        onClick={changeVisibility}
+        className={"password-eye"}
+      />
+      <input
+        {...props}
+        type={visible ? "text" : "password"}
+        className={"password-input"}
+      />
+    </div>
+  );
+}
+
+export function Select(
+  props: React.DetailedHTMLProps<
+    React.SelectHTMLAttributes<HTMLSelectElement>,
+    HTMLSelectElement
+  >,
+) {
+  const { className, children, ...otherProps } = props;
+  return (
+    <div className={`${styles["select-with-icon"]} ${className}`}>
+      <select className={styles["select-with-icon-select"]} {...otherProps}>
+        {children}
+      </select>
+      <DownIcon className={styles["select-with-icon-icon"]} />
+    </div>
+  );
+}

+ 24 - 0
app/config/build.ts

@@ -0,0 +1,24 @@
+const COMMIT_ID: string = (() => {
+  try {
+    const childProcess = require("child_process");
+    return childProcess
+      .execSync('git log -1 --format="%at000" --date=unix')
+      .toString()
+      .trim();
+  } catch (e) {
+    console.error("[Build Config] No git or not from git repo.");
+    return "unknown";
+  }
+})();
+
+export const getBuildConfig = () => {
+  if (typeof process === "undefined") {
+    throw Error(
+      "[Server Config] you are importing a nodejs-only module outside of nodejs",
+    );
+  }
+
+  return {
+    commitId: COMMIT_ID,
+  };
+};

+ 46 - 0
app/config/server.ts

@@ -0,0 +1,46 @@
+import md5 from "spark-md5";
+
+declare global {
+  namespace NodeJS {
+    interface ProcessEnv {
+      OPENAI_API_KEY?: string;
+      CODE?: string;
+      PROXY_URL?: string;
+      VERCEL?: string;
+      HIDE_USER_API_KEY?: string; // disable user's api key input
+      DISABLE_GPT4?: string; // allow user to use gpt-4 or not
+    }
+  }
+}
+
+const ACCESS_CODES = (function getAccessCodes(): Set<string> {
+  const code = process.env.CODE;
+
+  try {
+    const codes = (code?.split(",") ?? [])
+      .filter((v) => !!v)
+      .map((v) => md5.hash(v.trim()));
+    return new Set(codes);
+  } catch (e) {
+    return new Set();
+  }
+})();
+
+export const getServerSideConfig = () => {
+  if (typeof process === "undefined") {
+    throw Error(
+      "[Server Config] you are importing a nodejs-only module outside of nodejs",
+    );
+  }
+
+  return {
+    apiKey: process.env.OPENAI_API_KEY,
+    code: process.env.CODE,
+    codes: ACCESS_CODES,
+    needCode: ACCESS_CODES.size > 0,
+    proxyUrl: process.env.PROXY_URL,
+    isVercel: !!process.env.VERCEL,
+    hideUserApiKey: !!process.env.HIDE_USER_API_KEY,
+    enableGPT4: !process.env.DISABLE_GPT4,
+  };
+};

+ 42 - 0
app/constant.ts

@@ -0,0 +1,42 @@
+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}#keep-updated`;
+export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
+export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
+export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
+
+export enum Path {
+  Home = "/",
+  Chat = "/chat",
+  Settings = "/settings",
+  NewChat = "/new-chat",
+  Masks = "/masks",
+}
+
+export enum SlotID {
+  AppBody = "app-body",
+}
+
+export enum FileName {
+  Masks = "masks.json",
+  Prompts = "prompts.json",
+}
+
+export enum StoreKey {
+  Chat = "chat-next-web-store",
+  Access = "access-control",
+  Config = "app-config",
+  Mask = "mask-store",
+  Prompt = "prompt-store",
+  Update = "chat-update",
+}
+
+export const MAX_SIDEBAR_WIDTH = 500;
+export const MIN_SIDEBAR_WIDTH = 230;
+export const NARROW_SIDEBAR_WIDTH = 100;
+
+export const ACCESS_CODE_PREFIX = "ak-";
+
+export const LAST_INPUT_KEY = "last-input";

+ 11 - 0
app/global.d.ts

@@ -0,0 +1,11 @@
+declare module "*.jpg";
+declare module "*.png";
+declare module "*.woff2";
+declare module "*.woff";
+declare module "*.ttf";
+declare module "*.scss" {
+  const content: Record<string, string>;
+  export default content;
+}
+
+declare module "*.svg";

+ 23 - 0
app/icons/add.svg

@@ -0,0 +1,23 @@
+<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(1.3333333333333333 1.3333333333333333)  rotate(0 6.666666666666666 6.666666666666666)"
+        d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(8 5.333333333333333)  rotate(0 0 2.6666666666666665)" d="M0,0L0,5.33 " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(5.333333333333333 8)  rotate(0 2.6666666666666665 0)" d="M0,0L5.33,0 " />
+    </g>
+  </g>
+</svg>

+ 1 - 0
app/icons/auto.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; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.333333333333333)  rotate(0 2.333750009536743 2.6666666666666665)" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667 " /><path  id="่ทฏๅพ„ 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333)  rotate(0 6.533316666666666 2.6666666666666665)" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67 " /><path  id="่ทฏๅพ„ 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 9.333333333333332)  rotate(0 6.533316666666666 2.6666666666666665)" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67 " /></g></g></svg>

File diff suppressed because it is too large
+ 22 - 0
app/icons/black-bot.svg


File diff suppressed because it is too large
+ 22 - 0
app/icons/bot.svg


+ 1 - 0
app/icons/bottom.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(4 4)  rotate(0 4 2)" d="M8,0L4,4L0,0 " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 8)  rotate(0 4 2)" d="M8,0L4,4L0,0 " /></g></g></svg>

+ 25 - 0
app/icons/brain.svg

@@ -0,0 +1,25 @@
+<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(1.3333323286384866 1.3334133333333331)  rotate(0 6.66666716901409 6.66666)"
+        d="M5.01,13.33C4.69,12.27 4.19,11.47 3.53,10.95C2.55,10.17 0.97,10.65 0.39,9.84C-0.19,9.04 0.8,7.55 1.15,6.67C1.49,5.79 -0.18,5.48 0.02,5.23C0.15,5.07 0.99,4.59 2.55,3.79C3,1.26 4.63,0 7.47,0C11.71,0 13.33,3.6 13.33,5.89C13.33,8.18 11.37,10.65 8.58,11.18C8.33,11.55 8.69,12.26 9.66,13.33 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(6.374029736345404 3.9567867125879106)  rotate(0 2.8215982497276006 2.4327734241007346)"
+        d="M2.1,3.33C1.91,4.42 2.14,4.93 2.79,4.86C3.44,4.79 3.84,4.52 3.97,4.05C4.99,4.33 5.54,4.09 5.63,3.33C5.75,2.18 5.13,1.26 4.88,1.26C4.63,1.26 3.97,1.23 3.97,0.88C3.97,0.52 3.2,0.33 2.5,0.33C1.81,0.33 2.23,-0.14 1.27,0.04C0.64,0.17 0.26,0.44 0.13,0.88C-0.09,1.72 -0.03,2.31 0.32,2.66C0.67,3 1.26,3.22 2.1,3.33Z " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(8.193033333333332 8.500066666666665)  rotate(0 0.9868499999999998 1.1846833333333333)"
+        d="M1.97,0C1.63,0.21 1.17,0.56 0.97,0.83C0.48,1.52 0.09,1.93 0,2.37 " />
+    </g>
+  </g>
+</svg>

+ 27 - 0
app/icons/chat.svg

@@ -0,0 +1,27 @@
+<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="0.8" 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:#A6A6A6; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(1.3333533333333334 1.3333333333333333)  rotate(0 6.666673333333334 6.666666666666666)"
+        d="M6.67,0C2.98,0 0,2.98 0,6.67C0,8.36 0,13.33 0,13.33C0,13.33 4.68,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67C13.33,2.98 10.35,0 6.67,0Z " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#A6A6A6; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(4.666666666666666 6)  rotate(0 3 0)" d="M0,0L6,0 " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#A6A6A6; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(4.666666666666666 8.666666666666666)  rotate(0 3 0)" d="M0,0L6,0 " />
+      <path id="่ทฏๅพ„ 4"
+        style="stroke:#A6A6A6; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(4.666666666666666 11.333333333333332)  rotate(0 1.6666666666666665 0)"
+        d="M0,0L3.33,0 " />
+    </g>
+  </g>
+</svg>

File diff suppressed because it is too large
+ 12 - 0
app/icons/chatgpt.svg


+ 1 - 0
app/icons/clear.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(2.6666666666666665 5)  rotate(0 5.333333333333333 4.833333333333333)" d="M1,9.67L9.67,9.67L10.67,0L0,0L1,9.67Z " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6.667333333333333 8.334133333333334)  rotate(0 0 1.6666999999999998)" d="M0,0L0,3.33 " /><path  id="่ทฏๅพ„ 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.334133333333334 8.333166666666667)  rotate(0 0 1.666283333333333)" d="M0,0L0,3.33 " /><path  id="่ทฏๅพ„ 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 1)  rotate(0 4 2)" d="M0,4L5.44,0L8,4 " /></g></g></svg>

+ 21 - 0
app/icons/close.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.6666666666666665 2.6666666666666665)  rotate(0 5.333333333333333 5.333333333333333)"
+        d="M0,0L10.67,10.67 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.6666666666666665 2.6666666666666665)  rotate(0 5.333333333333333 5.333333333333333)"
+        d="M0,10.67L10.67,0 " />
+    </g>
+  </g>
+</svg>

+ 1 - 0
app/icons/copy.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(4.333333333333333 1.6666666666666665)  rotate(0 5 5)" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10 " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.6666666666666665 4.333333333333333)  rotate(0 5 5)" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z " /></g></g></svg>

+ 1 - 0
app/icons/dark.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(1.3333333333333333 1.3333333333333333)  rotate(0 6.666666666666666 6.666666666666666)" d="M6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67C13.33,6.2 13.29,5.75 13.2,5.32C12.72,7.14 11.06,8.48 9.09,8.48C6.75,8.48 4.85,6.59 4.85,4.24C4.85,2.27 6.19,0.61 8.02,0.14C7.58,0.05 7.13,0 6.67,0Z " /></g></g></svg>

File diff suppressed because it is too large
+ 8 - 0
app/icons/delete.svg


+ 1 - 0
app/icons/down.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(-90 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 - 0
app/icons/download.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(2 2)  rotate(0 6 6)" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 10.333333333333332)  rotate(0 6.666666666666666 0.6666666666666666)" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0 " /><path  id="่ทฏๅพ„ 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(14 8.666666666666666)  rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /><path  id="่ทฏๅพ„ 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6 7.333333333333333)  rotate(0 2 1)" d="M0,0L2,2L4,0 " /><path  id="่ทฏๅพ„ 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 4)  rotate(0 0 2.6666666666666665)" d="M0,5.33L0,0 " /><path  id="่ทฏๅพ„ 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 8.666666666666666)  rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /></g></g></svg>

+ 1 - 0
app/icons/edit.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(10.5 11)  rotate(0 1.4166666666666665 1.8333333333333333)" d="M2.83,0L2.83,3C2.83,3.37 2.53,3.67 2.17,3.67L0,3.67 " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2.6666666666666665 1.3333333333333333)  rotate(0 5.333333333333333 6.666666666666666)" d="M10.67,4L10.67,0.67C10.67,0.3 10.37,0 10,0L0.67,0C0.3,0 0,0.3 0,0.67L0,12.67C0,13.03 0.3,13.33 0.67,13.33L2.67,13.33 " /><path  id="่ทฏๅพ„ 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 5.333333333333333)  rotate(0 2.333333333333333 0)" d="M0,0L4.67,0 " /><path  id="่ทฏๅพ„ 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(7.666666666666666 7.666666666666666)  rotate(0 2.833333333333333 3.5)" d="M0,7L5.67,0 " /><path  id="่ทฏๅพ„ 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 8)  rotate(0 1.3333333333333333 0)" d="M0,0L2.67,0 " /></g></g></svg>

+ 1 - 0
app/icons/export.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(1.2400716519614834 2.3333321805983163)  rotate(0 6.785117896431597 4.552683909700841)" d="M12.27,9.11C13.36,8.34 13.83,6.94 13.43,5.67C13.02,4.39 11.78,3.69 10.44,3.69L9.67,3.69C9.16,1.72 7.5,0.27 5.47,0.03C3.45,-0.2 1.5,0.84 0.56,2.64C-0.38,4.45 -0.11,6.64 1.23,8.17 " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 7.666666666666666)  rotate(0 0.00140000000000029 3)" d="M0,6L0,0 " /><path  id="่ทฏๅพ„ 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.8786 11.5454)  rotate(0 2.1213333333333333 1.0606666666666662)" d="M4.24,0L2.12,2.12L0,0 " /></g></g></svg>

+ 4 - 0
app/icons/eye-off.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M19.7071 5.70711C20.0976 5.31658 20.0976 4.68342 19.7071 4.29289C19.3166 3.90237 18.6834 3.90237 18.2929 4.29289L14.032 8.55382C13.4365 8.20193 12.7418 8 12 8C9.79086 8 8 9.79086 8 12C8 12.7418 8.20193 13.4365 8.55382 14.032L4.29289 18.2929C3.90237 18.6834 3.90237 19.3166 4.29289 19.7071C4.68342 20.0976 5.31658 20.0976 5.70711 19.7071L9.96803 15.4462C10.5635 15.7981 11.2582 16 12 16C14.2091 16 16 14.2091 16 12C16 11.2582 15.7981 10.5635 15.4462 9.96803L19.7071 5.70711ZM12.518 10.0677C12.3528 10.0236 12.1792 10 12 10C10.8954 10 10 10.8954 10 12C10 12.1792 10.0236 12.3528 10.0677 12.518L12.518 10.0677ZM11.482 13.9323L13.9323 11.482C13.9764 11.6472 14 11.8208 14 12C14 13.1046 13.1046 14 12 14C11.8208 14 11.6472 13.9764 11.482 13.9323ZM15.7651 4.8207C14.6287 4.32049 13.3675 4 12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C1.92276 13.7326 2.86706 15.0637 4.21194 16.3739L5.62626 14.9596C4.4555 13.8229 3.61144 12.6531 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C12.7719 6 13.5135 6.13385 14.2193 6.36658L15.7651 4.8207ZM12 18C11.2282 18 10.4866 17.8661 9.78083 17.6334L8.23496 19.1793C9.37136 19.6795 10.6326 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C22.0773 10.2674 21.133 8.93627 19.7881 7.62611L18.3738 9.04043C19.5446 10.1771 20.3887 11.3469 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18Z" fill="#000000"/>
+</svg>

+ 4 - 0
app/icons/eye.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M6.30147 15.5771C4.77832 14.2684 3.6904 12.7726 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C14.1843 6 16.1261 7.07185 17.6986 8.42294C19.2218 9.73158 20.3097 11.2274 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18C9.81574 18 7.87402 16.9282 6.30147 15.5771ZM12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C2.00757 13.8624 3.23268 15.5772 4.99812 17.0941C6.75717 18.6054 9.14754 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C21.9925 10.1376 20.7674 8.42276 19.002 6.90595C17.2429 5.39462 14.8525 4 12 4ZM10 12C10 10.8954 10.8955 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8955 14 10 13.1046 10 12ZM12 8C9.7909 8 8.00004 9.79086 8.00004 12C8.00004 14.2091 9.7909 16 12 16C14.2092 16 16 14.2091 16 12C16 9.79086 14.2092 8 12 8Z" fill="#000000"/>
+</svg>

+ 29 - 0
app/icons/github.svg

@@ -0,0 +1,29 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.6666666666666665 1.644694921083138)  rotate(0 5.333333333333333 4.287969206125098)"
+        d="M7.11,8.51C7.92,8.35 8.64,8.06 9.21,7.64C10.17,6.91 10.67,5.79 10.67,4.69C10.67,3.91 10.37,3.19 9.86,2.58C9.58,2.24 10.41,-0.31 9.67,0.03C8.94,0.37 7.86,1.13 7.29,0.97C6.68,0.79 6.02,0.69 5.33,0.69C4.73,0.69 4.16,0.76 3.62,0.9C2.83,1.1 2.09,0.36 1.33,0.03C0.58,-0.29 0.99,2.34 0.77,2.62C0.28,3.22 0,3.93 0,4.69C0,5.79 0.6,6.91 1.56,7.64C2.21,8.12 3.01,8.42 3.91,8.58 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(6.000666666666666 10.220633333333332)  rotate(0 0.2896166666666667 2.058116666666666)"
+        d="M0.58,0C0.19,0.43 0,0.83 0,1.21C0,1.59 0,2.56 0,4.12 " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(9.781533333333332 10.158866666666666)  rotate(0 0.2744333333333332 2.0890166666666663)"
+        d="M0,0C0.37,0.48 0.55,0.91 0.55,1.29C0.55,1.68 0.55,2.64 0.55,4.18 " />
+      <path id="่ทฏๅพ„ 4"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 10.405166666666666)  rotate(0 2.0004 1.050416666666667)"
+        d="M0,0C0.3,0.04 0.52,0.17 0.67,0.41C0.88,0.77 1.69,2.1 2.61,2.1C3.22,2.1 3.68,2.1 4,2.1 " />
+    </g>
+  </g>
+</svg>

+ 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>

File diff suppressed because it is too large
+ 0 - 0
app/icons/light.svg


File diff suppressed because it is too large
+ 0 - 0
app/icons/lightning.svg


File diff suppressed because it is too large
+ 0 - 0
app/icons/mask.svg


+ 41 - 0
app/icons/max.svg

@@ -0,0 +1,41 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 2)  rotate(0 1.6666666666666665 1.6499166666666665)"
+        d="M0,0L3.33,3.3 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 10.666666666666666)  rotate(0 1.6666666666666665 1.6499166666666671)"
+        d="M0,3.3L3.33,0 " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(10.700199999999999 10.666666666666666)  rotate(0 1.6499166666666671 1.6499166666666671)"
+        d="M3.3,3.3L0,0 " />
+      <path id="่ทฏๅพ„ 4"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(10.666666666666666 2)  rotate(0 1.6499166666666671 1.6499166666666665)"
+        d="M3.3,0L0,3.3 " />
+      <path id="่ทฏๅพ„ 5"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(11 2)  rotate(0 1.5 1.5)" d="M0,0L3,0L3,3 " />
+      <path id="่ทฏๅพ„ 6"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(11 11)  rotate(0 1.5 1.5)" d="M3,0L3,3L0,3 " />
+      <path id="่ทฏๅพ„ 7"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 11)  rotate(0 1.5 1.5)" d="M3,3L0,3L0,0 " />
+      <path id="่ทฏๅพ„ 8"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 2)  rotate(0 1.5 1.5)" d="M0,3L0,0L3,0 " />
+    </g>
+  </g>
+</svg>

+ 25 - 0
app/icons/menu.svg

@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.649903333333333 3.983233333333333)  rotate(0 5.333331666666666 0)"
+        d="M0,0L10.67,0 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.649903333333333 7.983233333333333)  rotate(0 5.333331666666666 0)"
+        d="M0,0L10.67,0 " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.649903333333333 11.983233333333333)  rotate(0 5.333331666666666 0)"
+        d="M0,0L10.67,0 " />
+    </g>
+  </g>
+</svg>

+ 45 - 0
app/icons/min.svg

@@ -0,0 +1,45 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 2)  rotate(0 1.6666666666666665 1.6499166666666665)"
+        d="M0,0L3.33,3.3 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 10.666666666666666)  rotate(0 1.6666666666666665 1.6499166666666671)"
+        d="M0,3.3L3.33,0 " />
+      <path id="่ทฏๅพ„ 3"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(10.700199999999999 10.666666666666666)  rotate(0 1.6499166666666671 1.6499166666666671)"
+        d="M3.3,3.3L0,0 " />
+      <path id="่ทฏๅพ„ 4"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(10.666666666666666 2)  rotate(0 1.6499166666666671 1.6499166666666665)"
+        d="M3.3,0L0,3.3 " />
+      <path id="่ทฏๅพ„ 5"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(10.666666666666666 2.333333333333333)  rotate(0 1.5 1.5)"
+        d="M0,0L0,3L3,3 " />
+      <path id="่ทฏๅพ„ 6"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.333333333333333 2.333333333333333)  rotate(0 1.5 1.5)"
+        d="M3,0L3,3L0,3 " />
+      <path id="่ทฏๅพ„ 7"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.333333333333333 10.666666666666666)  rotate(0 1.5 1.5)"
+        d="M3,3L3,0L0,0 " />
+      <path id="่ทฏๅพ„ 8"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(10.666666666666666 10.666666666666666)  rotate(0 1.4832500000000004 1.5)"
+        d="M0,3L0,0L2.97,0 " />
+    </g>
+  </g>
+</svg>

+ 1 - 0
app/icons/pause.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(1.3333333333333333 1.3333333333333333)  rotate(0 6.666666666666666 6.666666666666666)" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6.333333333333333 6)  rotate(0 0 2)" d="M0,0L0,4 " /><path  id="่ทฏๅพ„ 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.666666666666666 6)  rotate(0 0 2)" d="M0,0L0,4 " /></g></g></svg>

File diff suppressed because it is too large
+ 0 - 0
app/icons/plugin.svg


+ 1 - 0
app/icons/prompt.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.3; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 1.3333333333333333)  rotate(0 4.666666666666666 4.666666666666666)" d="M1.36683 1.36683L2.77683 2.77683 M4.66667 0L4.66667 2 M4.66667 2L4.66667 0 M7.9623 1.36683L6.5523 2.77683 M6.5523 2.77683L7.9623 1.36683 M9.33333 4.66667L7.33333 4.66667 M7.33333 4.66667L9.33333 4.66667 M7.9623 7.9623L6.5523 6.5523 M6.5523 6.5523L7.9623 7.9623 M4.66667 9.33333L4.66667 7.33333 M4.66667 7.33333L4.66667 9.33333 M1.36683 7.9623L2.77683 6.5523 M2.77683 6.5523L1.36683 7.9623 M0 4.66667L2 4.66667 M2 4.66667L0 4.66667 " /><path  id="่ทฏๅพ„ 9" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.847983333333333 6.1381)  rotate(0 4.006941666666666 4.006933333333333)" d="M8.01,0L0,8.01 " /></g></g></svg>

+ 24 - 0
app/icons/reload.svg

@@ -0,0 +1,24 @@
+<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(14 2.6666666666666665)  rotate(0 0 2.6666666666666665)"
+        d="M0,0L0,5.33 " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 8)  rotate(0 0 2.6666666666666665)" d="M0,0L0,5.33 " />
+      <path id="ๅˆ†็ป„ 1"
+        style="stroke:#333333; stroke-width:1.333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2.000000057161194 2)  rotate(0 6.001349925994873 6)"
+        d="M12.0027 6C12.0027 2.69 9.3127 0 6.0027 0C4.3027 0 2.7727 0.7 1.6827 1.83 M-5.71612e-08 6C-5.71612e-08 9.31 2.69 12 6 12C7.62 12 9.09 11.36 10.17 10.32 " />
+    </g>
+  </g>
+</svg>

+ 1 - 0
app/icons/rename.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(1.774903333333333 1.3006199999999999)  rotate(0 6.599664999999999 6.599656666666666)" d="M2.83,13.2L13.2,2.83L10.37,0L0,10.37L0,13.2L2.83,13.2Z " /><path  id="่ทฏๅพ„ 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.317366666666667 4.129066666666667)  rotate(0 1.4142166666666658 1.4142166666666665)" d="M0,0L2.83,2.83 " /></g></g></svg>

+ 21 - 0
app/icons/return.svg

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

+ 21 - 0
app/icons/send-white.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#FFFFFF; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(1.3333333333333333 2)  rotate(0 6.333333333333333 6.333333333333333)"
+        d="M0,4.71L6.67,6L8.34,12.67L12.67,0L0,4.71Z " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#FFFFFF; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(8.002766666666666 6.1172)  rotate(0 0.9428000000000001 0.9428000000000001)"
+        d="M0,1.89L1.89,0 " />
+    </g>
+  </g>
+</svg>

+ 21 - 0
app/icons/settings.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(1.3333333333333333 2.333333333333333)  rotate(0 6.666666666666666 5.666666666666666)"
+        d="M13.33,5.67L10,0L3.33,0L0,5.67L3.33,11.33L10,11.33L13.33,5.67Z " />
+      <path id="่ทฏๅพ„ 2"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(6.333333333333333 6.333333333333333)  rotate(0 1.6666666666666665 1.6666666666666665)"
+        d="M3.33,1.67C3.33,0.75 2.59,0 1.67,0C0.75,0 0,0.75 0,1.67C0,2.59 0.75,3.33 1.67,3.33C2.59,3.33 3.33,2.59 3.33,1.67Z " />
+    </g>
+  </g>
+</svg>

+ 17 - 0
app/icons/share.svg

@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
+  height="16" viewBox="0 0 16 16" fill="none">
+  <defs>
+    <rect id="path_0" x="0" y="0" width="16" height="16" />
+  </defs>
+  <g opacity="1" transform="translate(0 0)  rotate(0 8 8)">
+    <mask id="bg-mask-0" fill="white">
+      <use xlink:href="#path_0"></use>
+    </mask>
+    <g mask="url(#bg-mask-0)">
+      <path id="่ทฏๅพ„ 1"
+        style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
+        transform="translate(2 1.3333333333333333)  rotate(0 6.333333333333333 6.5)"
+        d="M6.67,3.67C1.67,3.67 0,7.33 0,13C0,13 2,8 6.67,8L6.67,11.67L12.67,6L6.67,0L6.67,3.67Z " />
+    </g>
+  </g>
+</svg>

+ 33 - 0
app/icons/three-dots.svg

@@ -0,0 +1,33 @@
+<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
+<svg width="30" height="14" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#fff">
+    <circle cx="15" cy="15" r="15" fill="var(--primary, red)">
+        <animate attributeName="r" from="15" to="15"
+            begin="0s" dur="0.8s"
+            values="15;9;15" calcMode="linear"
+            repeatCount="indefinite" />
+        <animate attributeName="fill-opacity" from="1" to="1"
+            begin="0s" dur="0.8s"
+            values="1;.5;1" calcMode="linear"
+            repeatCount="indefinite" />
+    </circle>
+    <circle cx="60" cy="15" r="9" fill-opacity="0.3" fill="var(--primary, red)">
+        <animate attributeName="r" from="9" to="9"
+            begin="0s" dur="0.8s"
+            values="9;15;9" calcMode="linear"
+            repeatCount="indefinite" />
+        <animate attributeName="fill-opacity" from="0.5" to="0.5"
+            begin="0s" dur="0.8s"
+            values=".5;1;.5" calcMode="linear"
+            repeatCount="indefinite" />
+    </circle>
+    <circle cx="105" cy="15" r="15" fill="var(--primary, red)">
+        <animate attributeName="r" from="15" to="15"
+            begin="0s" dur="0.8s"
+            values="15;9;15" calcMode="linear"
+            repeatCount="indefinite" />
+        <animate attributeName="fill-opacity" from="1" to="1"
+            begin="0s" dur="0.8s"
+            values="1;.5;1" calcMode="linear"
+            repeatCount="indefinite" />
+    </circle>
+</svg>

File diff suppressed because it is too large
+ 0 - 0
app/icons/upload.svg


+ 44 - 0
app/layout.tsx

@@ -0,0 +1,44 @@
+/* eslint-disable @next/next/no-page-custom-font */
+import "./styles/globals.scss";
+import "./styles/markdown.scss";
+import "./styles/highlight.scss";
+import { getBuildConfig } from "./config/build";
+
+const buildConfig = getBuildConfig();
+
+export const metadata = {
+  title: "AI.DW",
+  description: "",
+  appleWebApp: {
+    title: "AI.DW",
+    statusBarStyle: "default",
+  },
+  viewport: "width=device-width, initial-scale=1, maximum-scale=1",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <head>
+        <meta
+          name="theme-color"
+          content="#fafafa"
+          media="(prefers-color-scheme: light)"
+        />
+        <meta
+          name="theme-color"
+          content="#151515"
+          media="(prefers-color-scheme: dark)"
+        />
+        <meta name="version" content={buildConfig.commitId} />
+        <link rel="manifest" href="/site.webmanifest"></link>
+        <script src="/serviceWorkerRegister.js" defer></script>
+      </head>
+      <body>{children}</body>
+    </html>
+  );
+}

+ 244 - 0
app/locales/cn.ts

@@ -0,0 +1,244 @@
+import { SubmitKey } from "../store/config";
+
+const cn = {
+  WIP: "่ฏฅๅŠŸ่ƒฝไปๅœจๅผ€ๅ‘ไธญโ€ฆโ€ฆ",
+  Error: {
+    Unauthorized:
+      "่ฎฟ้—ฎๅฏ†็ ไธๆญฃ็กฎๆˆ–ไธบ็ฉบ๏ผŒ่ฏทๅ‰ๅพ€[่ฎพ็ฝฎ](/#/settings)้กต่พ“ๅ…ฅๆญฃ็กฎ็š„่ฎฟ้—ฎๅฏ†็ ๏ผŒๆˆ–่€…ๅกซๅ…ฅไฝ ่‡ชๅทฑ็š„ OpenAI API Keyใ€‚",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} ๆกๅฏน่ฏ`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `ไธŽ ChatGPT ็š„ ${count} ๆกๅฏน่ฏ`,
+    Actions: {
+      ChatList: "ๆŸฅ็œ‹ๆถˆๆฏๅˆ—่กจ",
+      CompressedHistory: "ๆŸฅ็œ‹ๅŽ‹็ผฉๅŽ็š„ๅŽ†ๅฒ Prompt",
+      Export: "ๅฏผๅ‡บ่Šๅคฉ่ฎฐๅฝ•",
+      Copy: "ๅคๅˆถ",
+      Stop: "ๅœๆญข",
+      Retry: "้‡่ฏ•",
+      Delete: "ๅˆ ้™ค",
+    },
+    Rename: "้‡ๅ‘ฝๅๅฏน่ฏ",
+    Typing: "ๆญฃๅœจ่พ“ๅ…ฅโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} ๅ‘้€`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += "๏ผŒShift + Enter ๆข่กŒ";
+      }
+      return inputHints + "๏ผŒ/ ่งฆๅ‘่กฅๅ…จ";
+    },
+    Send: "ๅ‘้€",
+    Config: {
+      Reset: "้‡็ฝฎ้ป˜่ฎค",
+      SaveAs: "ๅฆๅญ˜ไธบ้ขๅ…ท",
+    },
+  },
+  Export: {
+    Title: "ๅฏผๅ‡บ่Šๅคฉ่ฎฐๅฝ•ไธบ Markdown",
+    Copy: "ๅ…จ้ƒจๅคๅˆถ",
+    Download: "ไธ‹่ฝฝๆ–‡ไปถ",
+    MessageFromYou: "ๆฅ่‡ชไฝ ็š„ๆถˆๆฏ",
+    MessageFromChatGPT: "ๆฅ่‡ช ChatGPT ็š„ๆถˆๆฏ",
+  },
+  Memory: {
+    Title: "ๅŽ†ๅฒๆ‘˜่ฆ",
+    EmptyContent: "ๅฏน่ฏๅ†…ๅฎน่ฟ‡็Ÿญ๏ผŒๆ— ้œ€ๆ€ป็ป“",
+    Send: "่‡ชๅŠจๅŽ‹็ผฉ่Šๅคฉ่ฎฐๅฝ•ๅนถไฝœไธบไธŠไธ‹ๆ–‡ๅ‘้€",
+    Copy: "ๅคๅˆถๆ‘˜่ฆ",
+    Reset: "้‡็ฝฎๅฏน่ฏ",
+    ResetConfirm: "้‡็ฝฎๅŽๅฐ†ๆธ…็ฉบๅฝ“ๅ‰ๅฏน่ฏ่ฎฐๅฝ•ไปฅๅŠๅŽ†ๅฒๆ‘˜่ฆ๏ผŒ็กฎ่ฎค้‡็ฝฎ๏ผŸ",
+  },
+  Home: {
+    NewChat: "ๆ–ฐ็š„่Šๅคฉ",
+    DeleteChat: "็กฎ่ฎคๅˆ ้™ค้€‰ไธญ็š„ๅฏน่ฏ๏ผŸ",
+    DeleteToast: "ๅทฒๅˆ ้™คไผš่ฏ",
+    Revert: "ๆ’ค้”€",
+  },
+  Settings: {
+    Title: "่ฎพ็ฝฎ",
+    SubTitle: "่ฎพ็ฝฎ้€‰้กน",
+    Actions: {
+      ClearAll: "ๆธ…้™คๆ‰€ๆœ‰ๆ•ฐๆฎ",
+      ResetAll: "้‡็ฝฎๆ‰€ๆœ‰้€‰้กน",
+      Close: "ๅ…ณ้—ญ",
+      ConfirmResetAll: "็กฎ่ฎค้‡็ฝฎๆ‰€ๆœ‰้…็ฝฎ๏ผŸ",
+      ConfirmClearAll: "็กฎ่ฎคๆธ…้™คๆ‰€ๆœ‰ๆ•ฐๆฎ๏ผŸ",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "ๆ‰€ๆœ‰่ฏญ่จ€",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "ๅคดๅƒ",
+    FontSize: {
+      Title: "ๅญ—ไฝ“ๅคงๅฐ",
+      SubTitle: "่Šๅคฉๅ†…ๅฎน็š„ๅญ—ไฝ“ๅคงๅฐ",
+    },
+
+    Update: {
+      Version: (x: string) => `ๅฝ“ๅ‰็‰ˆๆœฌ๏ผš${x}`,
+      IsLatest: "ๅทฒๆ˜ฏๆœ€ๆ–ฐ็‰ˆๆœฌ",
+      CheckUpdate: "ๆฃ€ๆŸฅๆ›ดๆ–ฐ",
+      IsChecking: "ๆญฃๅœจๆฃ€ๆŸฅๆ›ดๆ–ฐ...",
+      FoundUpdate: (x: string) => `ๅ‘็Žฐๆ–ฐ็‰ˆๆœฌ๏ผš${x}`,
+      GoToUpdate: "ๅ‰ๅพ€ๆ›ดๆ–ฐ",
+    },
+    SendKey: "ๅ‘้€้”ฎ",
+    Theme: "ไธป้ข˜",
+    TightBorder: "ๆ— ่พนๆก†ๆจกๅผ",
+    SendPreviewBubble: {
+      Title: "้ข„่งˆๆฐ”ๆณก",
+      SubTitle: "ๅœจ้ข„่งˆๆฐ”ๆณกไธญ้ข„่งˆ Markdown ๅ†…ๅฎน",
+    },
+    Mask: {
+      Title: "้ขๅ…ทๅฏๅŠจ้กต",
+      SubTitle: "ๆ–ฐๅปบ่Šๅคฉๆ—ถ๏ผŒๅฑ•็คบ้ขๅ…ทๅฏๅŠจ้กต",
+    },
+    Prompt: {
+      Disable: {
+        Title: "็ฆ็”จๆ็คบ่ฏ่‡ชๅŠจ่กฅๅ…จ",
+        SubTitle: "ๅœจ่พ“ๅ…ฅๆก†ๅผ€ๅคด่พ“ๅ…ฅ / ๅณๅฏ่งฆๅ‘่‡ชๅŠจ่กฅๅ…จ",
+      },
+      List: "่‡ชๅฎšไน‰ๆ็คบ่ฏๅˆ—่กจ",
+      ListCount: (builtin: number, custom: number) =>
+        `ๅ†…็ฝฎ ${builtin} ๆก๏ผŒ็”จๆˆทๅฎšไน‰ ${custom} ๆก`,
+      Edit: "็ผ–่พ‘",
+      Modal: {
+        Title: "ๆ็คบ่ฏๅˆ—่กจ",
+        Add: "ๆ–ฐๅปบ",
+        Search: "ๆœ็ดขๆ็คบ่ฏ",
+      },
+      EditModal: {
+        Title: "็ผ–่พ‘ๆ็คบ่ฏ",
+      },
+    },
+    HistoryCount: {
+      Title: "้™„ๅธฆๅŽ†ๅฒๆถˆๆฏๆ•ฐ",
+      SubTitle: "ๆฏๆฌก่ฏทๆฑ‚ๆบๅธฆ็š„ๅŽ†ๅฒๆถˆๆฏๆ•ฐ",
+    },
+    CompressThreshold: {
+      Title: "ๅŽ†ๅฒๆถˆๆฏ้•ฟๅบฆๅŽ‹็ผฉ้˜ˆๅ€ผ",
+      SubTitle: "ๅฝ“ๆœชๅŽ‹็ผฉ็š„ๅŽ†ๅฒๆถˆๆฏ่ถ…่ฟ‡่ฏฅๅ€ผๆ—ถ๏ผŒๅฐ†่ฟ›่กŒๅŽ‹็ผฉ",
+    },
+    Token: {
+      Title: "API Key",
+      SubTitle: "ไฝฟ็”จ่‡ชๅทฑ็š„ Key ๅฏ็ป•่ฟ‡ๅฏ†็ ่ฎฟ้—ฎ้™ๅˆถ",
+      Placeholder: "OpenAI API Key",
+    },
+
+    Usage: {
+      Title: "ไฝ™้ขๆŸฅ่ฏข",
+      SubTitle(used: any, total: any) {
+        return `ๆœฌๆœˆๅทฒไฝฟ็”จ $${used}๏ผŒ่ฎข้˜…ๆ€ป้ข $${total}`;
+      },
+      IsChecking: "ๆญฃๅœจๆฃ€ๆŸฅโ€ฆ",
+      Check: "้‡ๆ–ฐๆฃ€ๆŸฅ",
+      NoAccess: "่พ“ๅ…ฅ API Key ๆˆ–่ฎฟ้—ฎๅฏ†็ ๆŸฅ็œ‹ไฝ™้ข",
+    },
+    AccessCode: {
+      Title: "่ฎฟ้—ฎๅฏ†็ ",
+      SubTitle: "็ฎก็†ๅ‘˜ๅทฒๅผ€ๅฏๅŠ ๅฏ†่ฎฟ้—ฎ",
+      Placeholder: "่ฏท่พ“ๅ…ฅ่ฎฟ้—ฎๅฏ†็ ",
+    },
+    Model: "ๆจกๅž‹ (model)",
+    Temperature: {
+      Title: "้šๆœบๆ€ง (temperature)",
+      SubTitle: "ๅ€ผ่ถŠๅคง๏ผŒๅ›žๅค่ถŠ้šๆœบ",
+    },
+    MaxTokens: {
+      Title: "ๅ•ๆฌกๅ›žๅค้™ๅˆถ (max_tokens)",
+      SubTitle: "ๅ•ๆฌกไบคไบ’ๆ‰€็”จ็š„ๆœ€ๅคง Token ๆ•ฐ",
+    },
+    PresencePenlty: {
+      Title: "่ฏ้ข˜ๆ–ฐ้ฒœๅบฆ (presence_penalty)",
+      SubTitle: "ๅ€ผ่ถŠๅคง๏ผŒ่ถŠๆœ‰ๅฏ่ƒฝๆ‰ฉๅฑ•ๅˆฐๆ–ฐ่ฏ้ข˜",
+    },
+  },
+  Store: {
+    DefaultTopic: "ๆ–ฐ็š„่Šๅคฉ",
+    BotHello: "ๆœ‰ไป€ไนˆๅฏไปฅๅธฎไฝ ็š„ๅ—",
+    Error: "ๅ‡บ้”™ไบ†๏ผŒ็จๅŽ้‡่ฏ•ๅง",
+    Prompt: {
+      History: (content: string) =>
+        "่ฟ™ๆ˜ฏ ai ๅ’Œ็”จๆˆท็š„ๅŽ†ๅฒ่Šๅคฉๆ€ป็ป“ไฝœไธบๅ‰ๆƒ…ๆ่ฆ๏ผš" + content,
+      Topic:
+        "ไฝฟ็”จๅ››ๅˆฐไบ”ไธชๅญ—็›ดๆŽฅ่ฟ”ๅ›ž่ฟ™ๅฅ่ฏ็š„็ฎ€่ฆไธป้ข˜๏ผŒไธ่ฆ่งฃ้‡Šใ€ไธ่ฆๆ ‡็‚นใ€ไธ่ฆ่ฏญๆฐ”่ฏใ€ไธ่ฆๅคšไฝ™ๆ–‡ๆœฌ๏ผŒๅฆ‚ๆžœๆฒกๆœ‰ไธป้ข˜๏ผŒ่ฏท็›ดๆŽฅ่ฟ”ๅ›žโ€œ้—ฒ่Šโ€",
+      Summarize:
+        "็ฎ€่ฆๆ€ป็ป“ไธ€ไธ‹ไฝ ๅ’Œ็”จๆˆท็š„ๅฏน่ฏ๏ผŒ็”จไฝœๅŽ็ปญ็š„ไธŠไธ‹ๆ–‡ๆ็คบ prompt๏ผŒๆŽงๅˆถๅœจ 200 ๅญ—ไปฅๅ†…",
+    },
+  },
+  Copy: {
+    Success: "ๅทฒๅ†™ๅ…ฅๅ‰ชๅˆ‡ๆฟ",
+    Failed: "ๅคๅˆถๅคฑ่ดฅ๏ผŒ่ฏท่ต‹ไบˆๅ‰ชๅˆ‡ๆฟๆƒ้™",
+  },
+  Context: {
+    Toast: (x: any) => `ๅทฒ่ฎพ็ฝฎ ${x} ๆกๅ‰็ฝฎไธŠไธ‹ๆ–‡`,
+    Edit: "ๅฝ“ๅ‰ๅฏน่ฏ่ฎพ็ฝฎ",
+    Add: "ๆ–ฐๅขž้ข„่ฎพๅฏน่ฏ",
+  },
+  Plugin: {
+    Name: "ๆ’ไปถ",
+  },
+  Mask: {
+    Name: "้ขๅ…ท",
+    Page: {
+      Title: "้ข„่ฎพ่ง’่‰ฒ้ขๅ…ท",
+      SubTitle: (count: number) => `${count} ไธช้ข„่ฎพ่ง’่‰ฒๅฎšไน‰`,
+      Search: "ๆœ็ดข่ง’่‰ฒ้ขๅ…ท",
+      Create: "ๆ–ฐๅปบ",
+    },
+    Item: {
+      Info: (count: number) => `ๅŒ…ๅซ ${count} ๆก้ข„่ฎพๅฏน่ฏ`,
+      Chat: "ๅฏน่ฏ",
+      View: "ๆŸฅ็œ‹",
+      Edit: "็ผ–่พ‘",
+      Delete: "ๅˆ ้™ค",
+      DeleteConfirm: "็กฎ่ฎคๅˆ ้™ค๏ผŸ",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `็ผ–่พ‘้ข„่ฎพ้ขๅ…ท ${readonly ? "๏ผˆๅช่ฏป๏ผ‰" : ""}`,
+      Download: "ไธ‹่ฝฝ้ข„่ฎพ",
+      Clone: "ๅ…‹้š†้ข„่ฎพ",
+    },
+    Config: {
+      Avatar: "่ง’่‰ฒๅคดๅƒ",
+      Name: "่ง’่‰ฒๅ็งฐ",
+    },
+  },
+  NewChat: {
+    Return: "่ฟ”ๅ›ž",
+    Skip: "็›ดๆŽฅๅผ€ๅง‹",
+    NotShow: "ไธๅ†ๅฑ•็คบ",
+    ConfirmNoShow: "็กฎ่ฎค็ฆ็”จ๏ผŸ็ฆ็”จๅŽๅฏไปฅ้šๆ—ถๅœจ่ฎพ็ฝฎไธญ้‡ๆ–ฐๅฏ็”จใ€‚",
+    Title: "ๆŒ‘้€‰ไธ€ไธช้ขๅ…ท",
+    SubTitle: "็Žฐๅœจๅผ€ๅง‹๏ผŒไธŽ้ขๅ…ท่ƒŒๅŽ็š„็ต้ญ‚ๆ€็ปด็ขฐๆ’ž",
+    More: "ๆŸฅ็œ‹ๅ…จ้ƒจ",
+  },
+
+  UI: {
+    Confirm: "็กฎ่ฎค",
+    Cancel: "ๅ–ๆถˆ",
+    Close: "ๅ…ณ้—ญ",
+    Create: "ๆ–ฐๅปบ",
+    Edit: "็ผ–่พ‘",
+  },
+};
+
+export type LocaleType = typeof cn;
+
+export default cn;

+ 245 - 0
app/locales/cs.ts

@@ -0,0 +1,245 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const cs: LocaleType = {
+  WIP: "V pล™รญpravฤ›...",
+  Error: {
+    Unauthorized:
+      "Neoprรกvnฤ›nรฝ pล™รญstup, zadejte pล™รญstupovรฝ kรณd na strรกnce nastavenรญ.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} zprรกv`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} zprรกv s ChatGPT`,
+    Actions: {
+      ChatList: "Pล™ejรญt na seznam chatลฏ",
+      CompressedHistory: "Pokyn z komprimovanรฉ pamฤ›ti historie",
+      Export: "Exportovat vลกechny zprรกvy jako Markdown",
+      Copy: "Kopรญrovat",
+      Stop: "Zastavit",
+      Retry: "Zopakovat",
+      Delete: "Smazat",
+    },
+    Rename: "Pล™ejmenovat chat",
+    Typing: "Pรญลกe...",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} pro odeslรกnรญ`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter pro ล™รกdkovรกnรญ";
+      }
+      return inputHints + ", / pro vyhledรกvรกnรญ pokynลฏ";
+    },
+    Send: "Odeslat",
+    Config: {
+      Reset: "Obnovit vรฝchozรญ",
+      SaveAs: "Uloลพit jako Masku",
+    },
+  },
+  Export: {
+    Title: "Vลกechny zprรกvy",
+    Copy: "Kopรญrovat vลกe",
+    Download: "Stรกhnout",
+    MessageFromYou: "Zprรกva od vรกs",
+    MessageFromChatGPT: "Zprรกva z ChatGPT",
+  },
+  Memory: {
+    Title: "Pokyn z pamฤ›ti",
+    EmptyContent: "Zatรญm nic.",
+    Send: "Odeslat pamฤ›ลฅ",
+    Copy: "Kopรญrovat pamฤ›ลฅ",
+    Reset: "Obnovit relaci",
+    ResetConfirm:
+      "Resetovรกnรญm se vymaลพe historie aktuรกlnรญch konverzacรญ i pamฤ›ลฅ historie pokynลฏ. Opravdu chcete provรฉst obnovu?",
+  },
+  Home: {
+    NewChat: "Novรฝ chat",
+    DeleteChat: "Potvrzujete smazรกnรญ vybranรฉ konverzace?",
+    DeleteToast: "Chat smazรกn",
+    Revert: "Zvrรกtit",
+  },
+  Settings: {
+    Title: "Nastavenรญ",
+    SubTitle: "Vลกechna nastavenรญ",
+    Actions: {
+      ClearAll: "Vymazat vลกechna data",
+      ResetAll: "Obnovit veลกkerรฉ nastavenรญ",
+      Close: "Zavล™รญt",
+      ConfirmResetAll: "Jste si jisti, ลพe chcete obnovit vลกechna nastavenรญ?",
+      ConfirmClearAll: "Jste si jisti, ลพe chcete smazat vลกechna data?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+        All: "Vลกechny jazyky",
+        Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Velikost pรญsma",
+      SubTitle: "Nastavenรญ velikosti pรญsma obsahu chatu",
+    },
+    Update: {
+      Version: (x: string) => `Verze: ${x}`,
+      IsLatest: "Aktuรกlnรญ verze",
+      CheckUpdate: "Zkontrolovat aktualizace",
+      IsChecking: "Kontrola aktualizace...",
+      FoundUpdate: (x: string) => `Nalezena novรก verze: ${x}`,
+      GoToUpdate: "Aktualizovat",
+    },
+    SendKey: "Odeslat klรญฤ",
+    Theme: "Tรฉma",
+    TightBorder: "Tฤ›snรฉ ohraniฤenรญ",
+    SendPreviewBubble: {
+      Title: "Odesรญlat chatovacรญ bublinu s nรกhledem",
+      SubTitle: "Zobrazit v nรกhledu bubliny",
+    },
+    Mask: {
+      Title: "รšvodnรญ obrazovka Masek",
+      SubTitle: "Pล™ed zahรกjenรญm novรฉho chatu zobrazte รบvodnรญ obrazovku Masek",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Deaktivovat automatickรฉ dokonฤovรกnรญ",
+        SubTitle: "Zadejte / pro spuลกtฤ›nรญ automatickรฉho dokonฤovรกnรญ",
+      },
+      List: "Seznam pokynลฏ",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} vestavฤ›nรฝch, ${custom} uลพivatelskรฝch`,
+      Edit: "Upravit",
+      Modal: {
+        Title: "Seznam pokynลฏ",
+        Add: "Pล™idat pokyn",
+        Search: "Hledat pokyny",
+      },
+      EditModal: {
+        Title: "Editovat pokyn",
+      },
+    },
+    HistoryCount: {
+      Title: "Poฤet pล™ipojenรฝch zprรกv",
+      SubTitle: "Poฤet odeslanรฝch pล™ipojenรฝch zprรกv na ลพรกdost",
+    },
+    CompressThreshold: {
+      Title: "Prรกh pro kompresi historie",
+      SubTitle:
+        "Komprese probฤ›hne, pokud dรฉlka nekomprimovanรฝch zprรกv pล™esรกhne tuto hodnotu",
+    },
+    Token: {
+      Title: "API klรญฤ",
+      SubTitle: "Pouลพitรญm klรญฤe ignorujete omezenรญ pล™รญstupovรฉho kรณdu",
+      Placeholder: "Klรญฤ API OpenAI",
+    },
+    Usage: {
+      Title: "Stav รบฤtu",
+      SubTitle(used: any, total: any) {
+        return `Pouลพito tento mฤ›sรญc $${used}, pล™edplaceno $${total}`;
+      },
+      IsChecking: "Kontroluji...",
+      Check: "Zkontrolovat",
+      NoAccess: "Pro kontrolu zลฏstatku zadejte klรญฤ API",
+    },
+    AccessCode: {
+      Title: "Pล™รญstupovรฝ kรณd",
+      SubTitle: "Kontrola pล™รญstupu povolena",
+      Placeholder: "Potล™ebujete pล™รญstupovรฝ kรณd",
+    },
+    Model: "Model",
+    Temperature: {
+      Title: "Teplota",
+      SubTitle: "Vฤ›tลกรญ hodnota ฤinรญ vรฝstup nรกhodnฤ›jลกรญm",
+    },
+    MaxTokens: {
+      Title: "Max. poฤet tokenลฏ",
+      SubTitle: "Maximรกlnรญ dรฉlka vstupnรญho tokenu a generovanรฝch tokenลฏ",
+    },
+    PresencePenlty: {
+      Title: "Pล™รญtomnostnรญ korekce",
+      SubTitle:
+        "Vฤ›tลกรญ hodnota zvyลกuje pravdฤ›podobnost novรฝch tรฉmat.",
+    },
+  },
+  Store: {
+    DefaultTopic: "Novรก konverzace",
+    BotHello: "Ahoj! Jak mohu dnes pomoci?",
+    Error: "Nฤ›co se pokazilo, zkuste to prosรญm pozdฤ›ji.",
+    Prompt: {
+      History: (content: string) =>
+        "Toto je shrnutรญ historie chatu mezi umฤ›lou inteligencรญ a uลพivatelem v podobฤ› rekapitulace: " +
+        content,
+      Topic:
+        "Vytvoล™te prosรญm nรกzev o ฤtyล™ech aลพ pฤ›ti slovech vystihujรญcรญ prลฏbฤ›h naลกeho rozhovoru bez jakรฝchkoli รบvodnรญch slov, interpunkฤnรญch znamรฉnek, uvozovek, teฤek, symbolลฏ nebo dalลกรญho textu. Odstraลˆte uvozovky.",
+      Summarize:
+        "Krรกtce shrลˆ naลกi diskusi v rozsahu do 200 slov a pouลพij ji jako podnฤ›t pro budoucรญ kontext.",
+      },
+  },
+  Copy: {
+    Success: "Zkopรญrovรกno do schrรกnky",
+    Failed: "Kopรญrovรกnรญ selhalo, prosรญm, povolte pล™รญstup ke schrรกnce",
+  },
+  Context: {
+    Toast: (x: any) => `Pouลพitรญ ${x} kontextovรฝch pokynลฏ`,
+    Edit: "Kontextovรฉ a pamฤ›ลฅovรฉ pokyny",
+    Add: "Pล™idat pokyn",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Maska",
+    Page: {
+      Title: "ล ablona pokynu",
+      SubTitle: (count: number) => `${count} ลกablon pokynลฏ`,
+      Search: "Hledat v ลกablonรกch",
+      Create: "Vytvoล™it",
+    },
+    Item: {
+      Info: (count: number) => `${count} pokynลฏ`,
+      Chat: "Chat",
+      View: "Zobrazit",
+      Edit: "Upravit",
+      Delete: "Smazat",
+      DeleteConfirm: "Potvrdit smazรกnรญ?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Editovat ลกablonu pokynu ${readonly ? "(pouze ke ฤtenรญ)" : ""}`,
+      Download: "Stรกhnout",
+      Clone: "Duplikovat",
+    },
+    Config: {
+      Avatar: "Avatar Bota",
+      Name: "Jmรฉno Bota",
+    },
+  },
+  NewChat: {
+    Return: "Zpฤ›t",
+    Skip: "Pล™eskoฤit",
+    Title: "Vyberte Masku",
+    SubTitle: "Chatovat s duลกรญ za Maskou",
+    More: "Najรญt vรญce",
+    NotShow: "Nezobrazovat znovu",
+    ConfirmNoShow: "Potvrdit zakรกzรกnรญ๏ผŸMลฏลพete jej povolit pozdฤ›ji v nastavenรญ.",
+},
+
+  UI: {
+    Confirm: "Potvrdit",
+    Cancel: "Zruลกit",
+    Close: "Zavล™รญt",
+    Create: "Vytvoล™it",
+    Edit: "Upravit",
+  }
+};
+
+export default cs;

+ 249 - 0
app/locales/de.ts

@@ -0,0 +1,249 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const de: LocaleType = {
+  WIP: "In Bearbeitung...",
+  Error: {
+    Unauthorized:
+      "Unbefugter Zugriff, bitte geben Sie den Zugangscode auf der Einstellungsseite ein.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} Nachrichten`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} Nachrichten mit ChatGPT`,
+    Actions: {
+      ChatList: "Zur Chat-Liste gehen",
+      CompressedHistory: "Komprimierter Gedรคchtnis-Prompt",
+      Export: "Alle Nachrichten als Markdown exportieren",
+      Copy: "Kopieren",
+      Stop: "Stop",
+      Retry: "Wiederholen",
+      Delete: "Delete",
+    },
+    Rename: "Chat umbenennen",
+    Typing: "Tippen...",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} um zu Senden`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Umschalt + Eingabe fรผr Zeilenumbruch";
+      }
+      return inputHints + ", / zum Durchsuchen von Prompts";
+    },
+    Send: "Senden",
+    Config: {
+      Reset: "Reset to Default",
+      SaveAs: "Save as Mask",
+    },
+  },
+  Export: {
+    Title: "Alle Nachrichten",
+    Copy: "Alles kopieren",
+    Download: "Herunterladen",
+    MessageFromYou: "Deine Nachricht",
+    MessageFromChatGPT: "Nachricht von ChatGPT",
+  },
+  Memory: {
+    Title: "Gedรคchtnis-Prompt",
+    EmptyContent: "Noch nichts.",
+    Send: "Gedรคchtnis senden",
+    Copy: "Gedรคchtnis kopieren",
+    Reset: "Sitzung zurรผcksetzen",
+    ResetConfirm:
+      "Das Zurรผcksetzen lรถscht den aktuellen Gesprรคchsverlauf und das Langzeit-Gedรคchtnis. Mรถchten Sie wirklich zurรผcksetzen?",
+  },
+  Home: {
+    NewChat: "Neuer Chat",
+    DeleteChat: "Bestรคtigen Sie, um das ausgewรคhlte Gesprรคch zu lรถschen?",
+    DeleteToast: "Chat gelรถscht",
+    Revert: "Zurรผcksetzen",
+  },
+  Settings: {
+    Title: "Einstellungen",
+    SubTitle: "Alle Einstellungen",
+    Actions: {
+      ClearAll: "Alle Daten lรถschen",
+      ResetAll: "Alle Einstellungen zurรผcksetzen",
+      Close: "SchlieรŸen",
+      ConfirmResetAll:
+        "Mรถchten Sie wirklich alle Konfigurationen zurรผcksetzen?",
+      ConfirmClearAll: "Mรถchten Sie wirklich alle Chats zurรผcksetzen?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "Alle Sprachen",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "SchriftgrรถรŸe",
+      SubTitle: "SchriftgrรถรŸe des Chat-Inhalts anpassen",
+    },
+    Update: {
+      Version: (x: string) => `Version: ${x}`,
+      IsLatest: "Neueste Version",
+      CheckUpdate: "Update prรผfen",
+      IsChecking: "Update wird geprรผft...",
+      FoundUpdate: (x: string) => `Neue Version gefunden: ${x}`,
+      GoToUpdate: "Aktualisieren",
+    },
+    SendKey: "Senden-Taste",
+    Theme: "Erscheinungsbild",
+    TightBorder: "Enger Rahmen",
+    SendPreviewBubble: {
+      Title: "Vorschau-Bubble senden",
+      SubTitle: "Preview markdown in bubble",
+    },
+    Mask: {
+      Title: "Mask Splash Screen",
+      SubTitle: "Show a mask splash screen before starting new chat",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Autovervollstรคndigung deaktivieren",
+        SubTitle: "Autovervollstรคndigung mit / starten",
+      },
+      List: "Prompt-Liste",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} integriert, ${custom} benutzerdefiniert`,
+      Edit: "Bearbeiten",
+      Modal: {
+        Title: "Prompt List",
+        Add: "Add One",
+        Search: "Search Prompts",
+      },
+      EditModal: {
+        Title: "Edit Prompt",
+      },
+    },
+    HistoryCount: {
+      Title: "Anzahl der angehรคngten Nachrichten",
+      SubTitle: "Anzahl der pro Anfrage angehรคngten gesendeten Nachrichten",
+    },
+    CompressThreshold: {
+      Title: "Schwellenwert fรผr Verlaufskomprimierung",
+      SubTitle:
+        "Komprimierung, wenn die Lรคnge der unkomprimierten Nachrichten den Wert รผberschreitet",
+    },
+    Token: {
+      Title: "API-Schlรผssel",
+      SubTitle:
+        "Verwenden Sie Ihren Schlรผssel, um das Zugangscode-Limit zu ignorieren",
+      Placeholder: "OpenAI API-Schlรผssel",
+    },
+    Usage: {
+      Title: "Kontostand",
+      SubTitle(used: any, total: any) {
+        return `Diesen Monat ausgegeben $${used}, Abonnement $${total}`;
+      },
+      IsChecking: "Wird รผberprรผft...",
+      Check: "Erneut prรผfen",
+      NoAccess: "API-Schlรผssel eingeben, um den Kontostand zu รผberprรผfen",
+    },
+    AccessCode: {
+      Title: "Zugangscode",
+      SubTitle: "Zugangskontrolle aktiviert",
+      Placeholder: "Zugangscode erforderlich",
+    },
+    Model: "Modell",
+    Temperature: {
+      Title: "Temperature", //Temperatur
+      SubTitle: "Ein grรถรŸerer Wert fรผhrt zu zufรคlligeren Antworten",
+    },
+    MaxTokens: {
+      Title: "Max Tokens", //Maximale Token
+      SubTitle: "Maximale Anzahl der Anfrage- plus Antwort-Token",
+    },
+    PresencePenlty: {
+      Title: "Presence Penalty", //Anwesenheitsstrafe
+      SubTitle:
+        "Ein grรถรŸerer Wert erhรถht die Wahrscheinlichkeit, dass รผber neue Themen gesprochen wird",
+    },
+  },
+  Store: {
+    DefaultTopic: "Neues Gesprรคch",
+    BotHello: "Hallo! Wie kann ich Ihnen heute helfen?",
+    Error:
+      "Etwas ist schief gelaufen, bitte versuchen Sie es spรคter noch einmal.",
+    Prompt: {
+      History: (content: string) =>
+        "Dies ist eine Zusammenfassung des Chatverlaufs zwischen dem KI und dem Benutzer als Rรผckblick: " +
+        content,
+      Topic:
+        "Bitte erstellen Sie einen vier- bis fรผnfwรถrtigen Titel, der unser Gesprรคch zusammenfasst, ohne Einleitung, Zeichensetzung, Anfรผhrungszeichen, Punkte, Symbole oder zusรคtzlichen Text. Entfernen Sie Anfรผhrungszeichen.",
+      Summarize:
+        "Fassen Sie unsere Diskussion kurz in 200 Wรถrtern oder weniger zusammen, um sie als Pronpt fรผr zukรผnftige Gesprรคche zu verwenden.",
+    },
+  },
+  Copy: {
+    Success: "In die Zwischenablage kopiert",
+    Failed:
+      "Kopieren fehlgeschlagen, bitte geben Sie die Berechtigung zum Zugriff auf die Zwischenablage frei",
+  },
+  Context: {
+    Toast: (x: any) => `Mit ${x} Kontext-Prompts`,
+    Edit: "Kontext- und Gedรคchtnis-Prompts",
+    Add: "Hinzufรผgen",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Mask",
+    Page: {
+      Title: "Prompt Template",
+      SubTitle: (count: number) => `${count} prompt templates`,
+      Search: "Search Templates",
+      Create: "Create",
+    },
+    Item: {
+      Info: (count: number) => `${count} prompts`,
+      Chat: "Chat",
+      View: "View",
+      Edit: "Edit",
+      Delete: "Delete",
+      DeleteConfirm: "Confirm to delete?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Edit Prompt Template ${readonly ? "(readonly)" : ""}`,
+      Download: "Download",
+      Clone: "Clone",
+    },
+    Config: {
+      Avatar: "Bot Avatar",
+      Name: "Bot Name",
+    },
+  },
+  NewChat: {
+    Return: "Return",
+    Skip: "Skip",
+    Title: "Pick a Mask",
+    SubTitle: "Chat with the Soul behind the Mask",
+    More: "Find More",
+    NotShow: "Not Show Again",
+    ConfirmNoShow: "Confirm to disable๏ผŸYou can enable it in settings later.",
+  },
+
+  UI: {
+    Confirm: "Confirm",
+    Cancel: "Cancel",
+    Close: "Close",
+    Create: "Create",
+    Edit: "Edit",
+  },
+};
+
+export default de;

+ 245 - 0
app/locales/en.ts

@@ -0,0 +1,245 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const en: LocaleType = {
+  WIP: "Coming Soon...",
+  Error: {
+    Unauthorized:
+      "Unauthorized access, please enter access code in settings page.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} messages`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} messages`,
+    Actions: {
+      ChatList: "Go To Chat List",
+      CompressedHistory: "Compressed History Memory Prompt",
+      Export: "Export All Messages as Markdown",
+      Copy: "Copy",
+      Stop: "Stop",
+      Retry: "Retry",
+      Delete: "Delete",
+    },
+    Rename: "Rename Chat",
+    Typing: "Typingโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} to send`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter to wrap";
+      }
+      return inputHints + ", / to search prompts";
+    },
+    Send: "Send",
+    Config: {
+      Reset: "Reset to Default",
+      SaveAs: "Save as Mask",
+    },
+  },
+  Export: {
+    Title: "All Messages",
+    Copy: "Copy All",
+    Download: "Download",
+    MessageFromYou: "Message From You",
+    MessageFromChatGPT: "Message From ChatGPT",
+  },
+  Memory: {
+    Title: "Memory Prompt",
+    EmptyContent: "Nothing yet.",
+    Send: "Send Memory",
+    Copy: "Copy Memory",
+    Reset: "Reset Session",
+    ResetConfirm:
+      "Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
+  },
+  Home: {
+    NewChat: "New Chat",
+    DeleteChat: "Confirm to delete the selected conversation?",
+    DeleteToast: "Chat Deleted",
+    Revert: "Revert",
+  },
+  Settings: {
+    Title: "Settings",
+    SubTitle: "All Settings",
+    Actions: {
+      ClearAll: "Clear All Data",
+      ResetAll: "Reset All Settings",
+      Close: "Close",
+      ConfirmResetAll: "Are you sure you want to reset all configurations?",
+      ConfirmClearAll: "Are you sure you want to reset all data?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "All Languages",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Font Size",
+      SubTitle: "Adjust font size of chat content",
+    },
+    Update: {
+      Version: (x: string) => `Version: ${x}`,
+      IsLatest: "Latest version",
+      CheckUpdate: "Check Update",
+      IsChecking: "Checking update...",
+      FoundUpdate: (x: string) => `Found new version: ${x}`,
+      GoToUpdate: "Update",
+    },
+    SendKey: "Send Key",
+    Theme: "Theme",
+    TightBorder: "Tight Border",
+    SendPreviewBubble: {
+      Title: "Send Preview Bubble",
+      SubTitle: "Preview markdown in bubble",
+    },
+    Mask: {
+      Title: "Mask Splash Screen",
+      SubTitle: "Show a mask splash screen before starting new chat",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Disable auto-completion",
+        SubTitle: "Input / to trigger auto-completion",
+      },
+      List: "Prompt List",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} built-in, ${custom} user-defined`,
+      Edit: "Edit",
+      Modal: {
+        Title: "Prompt List",
+        Add: "Add One",
+        Search: "Search Prompts",
+      },
+      EditModal: {
+        Title: "Edit Prompt",
+      },
+    },
+    HistoryCount: {
+      Title: "Attached Messages Count",
+      SubTitle: "Number of sent messages attached per request",
+    },
+    CompressThreshold: {
+      Title: "History Compression Threshold",
+      SubTitle:
+        "Will compress if uncompressed messages length exceeds the value",
+    },
+    Token: {
+      Title: "API Key",
+      SubTitle: "Use your key to ignore access code limit",
+      Placeholder: "OpenAI API Key",
+    },
+    Usage: {
+      Title: "Account Balance",
+      SubTitle(used: any, total: any) {
+        return `Used this month $${used}, subscription $${total}`;
+      },
+      IsChecking: "Checking...",
+      Check: "Check",
+      NoAccess: "Enter API Key to check balance",
+    },
+    AccessCode: {
+      Title: "Access Code",
+      SubTitle: "Access control enabled",
+      Placeholder: "Need Access Code",
+    },
+    Model: "Model",
+    Temperature: {
+      Title: "Temperature",
+      SubTitle: "A larger value makes the more random output",
+    },
+    MaxTokens: {
+      Title: "Max Tokens",
+      SubTitle: "Maximum length of input tokens and generated tokens",
+    },
+    PresencePenlty: {
+      Title: "Presence Penalty",
+      SubTitle:
+        "A larger value increases the likelihood to talk about new topics",
+    },
+  },
+  Store: {
+    DefaultTopic: "New Conversation",
+    BotHello: "Hello! How can I assist you today?",
+    Error: "Something went wrong, please try again later.",
+    Prompt: {
+      History: (content: string) =>
+        "This is a summary of the chat history between the AI and the user as a recap: " +
+        content,
+      Topic:
+        "Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.",
+      Summarize:
+        "Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
+    },
+  },
+  Copy: {
+    Success: "Copied to clipboard",
+    Failed: "Copy failed, please grant permission to access clipboard",
+  },
+  Context: {
+    Toast: (x: any) => `With ${x} contextual prompts`,
+    Edit: "Contextual and Memory Prompts",
+    Add: "Add a Prompt",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Mask",
+    Page: {
+      Title: "Prompt Template",
+      SubTitle: (count: number) => `${count} prompt templates`,
+      Search: "Search Templates",
+      Create: "Create",
+    },
+    Item: {
+      Info: (count: number) => `${count} prompts`,
+      Chat: "Chat",
+      View: "View",
+      Edit: "Edit",
+      Delete: "Delete",
+      DeleteConfirm: "Confirm to delete?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Edit Prompt Template ${readonly ? "(readonly)" : ""}`,
+      Download: "Download",
+      Clone: "Clone",
+    },
+    Config: {
+      Avatar: "Bot Avatar",
+      Name: "Bot Name",
+    },
+  },
+  NewChat: {
+    Return: "Return",
+    Skip: "Skip",
+    Title: "Masks",
+    SubTitle: "",
+    More: "Find More",
+    NotShow: "Not Show Again",
+    ConfirmNoShow: "Confirm to disable๏ผŸYou can enable it in settings later.",
+  },
+
+  UI: {
+    Confirm: "Confirm",
+    Cancel: "Cancel",
+    Close: "Close",
+    Create: "Create",
+    Edit: "Edit",
+  },
+};
+
+export default en;

+ 246 - 0
app/locales/es.ts

@@ -0,0 +1,246 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const es: LocaleType = {
+  WIP: "En construcciรณn...",
+  Error: {
+    Unauthorized:
+      "Acceso no autorizado, por favor ingrese el cรณdigo de acceso en la pรกgina de configuraciรณn.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} mensajes`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} mensajes con ChatGPT`,
+    Actions: {
+      ChatList: "Ir a la lista de chats",
+      CompressedHistory: "Historial de memoria comprimido",
+      Export: "Exportar todos los mensajes como Markdown",
+      Copy: "Copiar",
+      Stop: "Detener",
+      Retry: "Reintentar",
+      Delete: "Delete",
+    },
+    Rename: "Renombrar chat",
+    Typing: "Escribiendo...",
+    Input: (submitKey: string) => {
+      var inputHints = `Escribe algo y presiona ${submitKey} para enviar`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", presiona Shift + Enter para nueva lรญnea";
+      }
+      return inputHints;
+    },
+    Send: "Enviar",
+    Config: {
+      Reset: "Reset to Default",
+      SaveAs: "Save as Mask",
+    },
+  },
+  Export: {
+    Title: "Todos los mensajes",
+    Copy: "Copiar todo",
+    Download: "Descargar",
+    MessageFromYou: "Mensaje de ti",
+    MessageFromChatGPT: "Mensaje de ChatGPT",
+  },
+  Memory: {
+    Title: "Historial de memoria",
+    EmptyContent: "Aรบn no hay nada.",
+    Copy: "Copiar todo",
+    Send: "Send Memory",
+    Reset: "Reset Session",
+    ResetConfirm:
+      "Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
+  },
+  Home: {
+    NewChat: "Nuevo chat",
+    DeleteChat: "ยฟConfirmar eliminaciรณn de la conversaciรณn seleccionada?",
+    DeleteToast: "Chat Deleted",
+    Revert: "Revert",
+  },
+  Settings: {
+    Title: "Configuraciรณn",
+    SubTitle: "Todas las configuraciones",
+    Actions: {
+      ClearAll: "Borrar todos los datos",
+      ResetAll: "Restablecer todas las configuraciones",
+      Close: "Cerrar",
+      ConfirmResetAll: "Are you sure you want to reset all configurations?",
+      ConfirmClearAll: "Are you sure you want to reset all chat?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "Todos los idiomas",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Tamaรฑo de fuente",
+      SubTitle: "Ajustar el tamaรฑo de fuente del contenido del chat",
+    },
+    Update: {
+      Version: (x: string) => `Versiรณn: ${x}`,
+      IsLatest: "รšltima versiรณn",
+      CheckUpdate: "Buscar actualizaciones",
+      IsChecking: "Buscando actualizaciones...",
+      FoundUpdate: (x: string) => `Se encontrรณ una nueva versiรณn: ${x}`,
+      GoToUpdate: "Actualizar",
+    },
+    SendKey: "Tecla de envรญo",
+    Theme: "Tema",
+    TightBorder: "Borde ajustado",
+    SendPreviewBubble: {
+      Title: "Enviar burbuja de vista previa",
+      SubTitle: "Preview markdown in bubble",
+    },
+    Mask: {
+      Title: "Mask Splash Screen",
+      SubTitle: "Show a mask splash screen before starting new chat",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Desactivar autocompletado",
+        SubTitle: "Escribe / para activar el autocompletado",
+      },
+      List: "Lista de autocompletado",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} incorporado, ${custom} definido por el usuario`,
+      Edit: "Editar",
+      Modal: {
+        Title: "Prompt List",
+        Add: "Add One",
+        Search: "Search Prompts",
+      },
+      EditModal: {
+        Title: "Edit Prompt",
+      },
+    },
+    HistoryCount: {
+      Title: "Cantidad de mensajes adjuntos",
+      SubTitle: "Nรบmero de mensajes enviados adjuntos por solicitud",
+    },
+    CompressThreshold: {
+      Title: "Umbral de compresiรณn de historial",
+      SubTitle:
+        "Se comprimirรกn los mensajes si la longitud de los mensajes no comprimidos supera el valor",
+    },
+    Token: {
+      Title: "Clave de API",
+      SubTitle: "Utiliza tu clave para ignorar el lรญmite de cรณdigo de acceso",
+      Placeholder: "Clave de la API de OpenAI",
+    },
+    Usage: {
+      Title: "Saldo de la cuenta",
+      SubTitle(used: any, total: any) {
+        return `Usado $${used}, subscription $${total}`;
+      },
+      IsChecking: "Comprobando...",
+      Check: "Comprobar de nuevo",
+      NoAccess: "Introduzca la clave API para comprobar el saldo",
+    },
+    AccessCode: {
+      Title: "Cรณdigo de acceso",
+      SubTitle: "Control de acceso habilitado",
+      Placeholder: "Necesita cรณdigo de acceso",
+    },
+    Model: "Modelo",
+    Temperature: {
+      Title: "Temperatura",
+      SubTitle: "Un valor mayor genera una salida mรกs aleatoria",
+    },
+    MaxTokens: {
+      Title: "Mรกximo de tokens",
+      SubTitle: "Longitud mรกxima de tokens de entrada y tokens generados",
+    },
+    PresencePenlty: {
+      Title: "Penalizaciรณn de presencia",
+      SubTitle:
+        "Un valor mayor aumenta la probabilidad de hablar sobre nuevos temas",
+    },
+  },
+  Store: {
+    DefaultTopic: "Nueva conversaciรณn",
+    BotHello: "ยกHola! ยฟCรณmo puedo ayudarte hoy?",
+    Error: "Algo saliรณ mal, por favor intenta nuevamente mรกs tarde.",
+    Prompt: {
+      History: (content: string) =>
+        "Este es un resumen del historial del chat entre la IA y el usuario como recapitulaciรณn: " +
+        content,
+      Topic:
+        "Por favor, genera un tรญtulo de cuatro a cinco palabras que resuma nuestra conversaciรณn sin ningรบn inicio, puntuaciรณn, comillas, puntos, sรญmbolos o texto adicional. Elimina las comillas que lo envuelven.",
+      Summarize:
+        "Resuma nuestra discusiรณn brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
+    },
+  },
+  Copy: {
+    Success: "Copiado al portapapeles",
+    Failed:
+      "La copia fallรณ, por favor concede permiso para acceder al portapapeles",
+  },
+  Context: {
+    Toast: (x: any) => `With ${x} contextual prompts`,
+    Edit: "Contextual and Memory Prompts",
+    Add: "Add One",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Mask",
+    Page: {
+      Title: "Prompt Template",
+      SubTitle: (count: number) => `${count} prompt templates`,
+      Search: "Search Templates",
+      Create: "Create",
+    },
+    Item: {
+      Info: (count: number) => `${count} prompts`,
+      Chat: "Chat",
+      View: "View",
+      Edit: "Edit",
+      Delete: "Delete",
+      DeleteConfirm: "Confirm to delete?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Edit Prompt Template ${readonly ? "(readonly)" : ""}`,
+      Download: "Download",
+      Clone: "Clone",
+    },
+    Config: {
+      Avatar: "Bot Avatar",
+      Name: "Bot Name",
+    },
+  },
+  NewChat: {
+    Return: "Return",
+    Skip: "Skip",
+    Title: "Pick a Mask",
+    SubTitle: "Chat with the Soul behind the Mask",
+    More: "Find More",
+    NotShow: "Not Show Again",
+    ConfirmNoShow: "Confirm to disable๏ผŸYou can enable it in settings later.",
+  },
+
+  UI: {
+    Confirm: "Confirm",
+    Cancel: "Cancel",
+    Close: "Close",
+    Create: "Create",
+    Edit: "Edit",
+  },
+};
+
+export default es;

+ 91 - 0
app/locales/index.ts

@@ -0,0 +1,91 @@
+import CN from "./cn";
+import EN from "./en";
+import TW from "./tw";
+import ES from "./es";
+import IT from "./it";
+import TR from "./tr";
+import JP from "./jp";
+import DE from "./de";
+import VI from "./vi";
+import RU from "./ru";
+import CS from "./cs";
+
+export type { LocaleType } from "./cn";
+
+export const AllLangs = [
+  "en",
+  "cn",
+  "tw",
+  "es",
+  "it",
+  "tr",
+  "jp",
+  "de",
+  "vi",
+  "ru",
+  "cs",
+] as const;
+export type Lang = (typeof AllLangs)[number];
+
+const LANG_KEY = "lang";
+const DEFAULT_LANG = "en";
+
+function getItem(key: string) {
+  try {
+    return localStorage.getItem(key);
+  } catch {
+    return null;
+  }
+}
+
+function setItem(key: string, value: string) {
+  try {
+    localStorage.setItem(key, value);
+  } catch {}
+}
+
+function getLanguage() {
+  try {
+    return navigator.language.toLowerCase();
+  } catch {
+    console.log("[Lang] failed to detect user lang.");
+    return DEFAULT_LANG;
+  }
+}
+
+export function getLang(): Lang {
+  const savedLang = getItem(LANG_KEY);
+
+  if (AllLangs.includes((savedLang ?? "") as Lang)) {
+    return savedLang as Lang;
+  }
+
+  const lang = getLanguage();
+
+  for (const option of AllLangs) {
+    if (lang.includes(option)) {
+      return option;
+    }
+  }
+
+  return DEFAULT_LANG;
+}
+
+export function changeLang(lang: Lang) {
+  setItem(LANG_KEY, lang);
+  location.reload();
+}
+
+export default {
+  en: EN,
+  cn: CN,
+  tw: TW,
+  es: ES,
+  it: IT,
+  tr: TR,
+  jp: JP,
+  de: DE,
+  vi: VI,
+  ru: RU,
+  cs: CS,
+}[getLang()] as typeof CN;

+ 247 - 0
app/locales/it.ts

@@ -0,0 +1,247 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const it: LocaleType = {
+  WIP: "Work in progress...",
+  Error: {
+    Unauthorized:
+      "Accesso non autorizzato, inserire il codice di accesso nella pagina delle impostazioni.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} messaggi`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} messaggi con ChatGPT`,
+    Actions: {
+      ChatList: "Vai alla Chat List",
+      CompressedHistory: "Prompt di memoria della cronologia compressa",
+      Export: "Esportazione di tutti i messaggi come Markdown",
+      Copy: "Copia",
+      Stop: "Stop",
+      Retry: "Riprova",
+      Delete: "Delete",
+    },
+    Rename: "Rinomina Chat",
+    Typing: "Typingโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `Scrivi qualcosa e premi ${submitKey} per inviare`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", premi Shift + Enter per andare a capo";
+      }
+      return inputHints;
+    },
+    Send: "Invia",
+    Config: {
+      Reset: "Reset to Default",
+      SaveAs: "Save as Mask",
+    },
+  },
+  Export: {
+    Title: "Tutti i messaggi",
+    Copy: "Copia tutto",
+    Download: "Scarica",
+    MessageFromYou: "Messaggio da te",
+    MessageFromChatGPT: "Messaggio da ChatGPT",
+  },
+  Memory: {
+    Title: "Prompt di memoria",
+    EmptyContent: "Vuoto.",
+    Copy: "Copia tutto",
+    Send: "Send Memory",
+    Reset: "Reset Session",
+    ResetConfirm:
+      "Ripristinare cancellerร  la conversazione corrente e la cronologia di memoria. Sei sicuro che vuoi riavviare?",
+  },
+  Home: {
+    NewChat: "Nuova Chat",
+    DeleteChat: "Confermare la cancellazione della conversazione selezionata?",
+    DeleteToast: "Chat Cancellata",
+    Revert: "Revert",
+  },
+  Settings: {
+    Title: "Impostazioni",
+    SubTitle: "Tutte le impostazioni",
+    Actions: {
+      ClearAll: "Cancella tutti i dati",
+      ResetAll: "Resetta tutte le impostazioni",
+      Close: "Chiudi",
+      ConfirmResetAll: "Sei sicuro vuoi cancellare tutte le impostazioni?",
+      ConfirmClearAll: "Sei sicuro vuoi cancellare tutte le chat?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "Tutte le lingue",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Dimensione carattere",
+      SubTitle: "Regolare la dimensione dei caratteri del contenuto della chat",
+    },
+    Update: {
+      Version: (x: string) => `Versione: ${x}`,
+      IsLatest: "Ultima versione",
+      CheckUpdate: "Controlla aggiornamenti",
+      IsChecking: "Sto controllando gli aggiornamenti...",
+      FoundUpdate: (x: string) => `Trovata nuova versione: ${x}`,
+      GoToUpdate: "Aggiorna",
+    },
+    SendKey: "Tasto invia",
+    Theme: "Tema",
+    TightBorder: "Schermo intero",
+    SendPreviewBubble: {
+      Title: "Anteprima di digitazione",
+      SubTitle: "Preview markdown in bubble",
+    },
+    Mask: {
+      Title: "Mask Splash Screen",
+      SubTitle: "Show a mask splash screen before starting new chat",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Disabilita l'auto completamento",
+        SubTitle: "Input / per attivare il completamento automatico",
+      },
+      List: "Elenco dei suggerimenti",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} built-in, ${custom} user-defined`,
+      Edit: "Modifica",
+      Modal: {
+        Title: "Prompt List",
+        Add: "Add One",
+        Search: "Search Prompts",
+      },
+      EditModal: {
+        Title: "Edit Prompt",
+      },
+    },
+    HistoryCount: {
+      Title: "Conteggio dei messaggi allegati",
+      SubTitle: "Numero di messaggi inviati allegati per richiesta",
+    },
+    CompressThreshold: {
+      Title: "Soglia di compressione della cronologia",
+      SubTitle:
+        "Comprimerร  se la lunghezza dei messaggi non compressi supera il valore",
+    },
+    Token: {
+      Title: "API Key",
+      SubTitle:
+        "Utilizzare la chiave per ignorare il limite del codice di accesso",
+      Placeholder: "OpenAI API Key",
+    },
+    Usage: {
+      Title: "Bilancio Account",
+      SubTitle(used: any, total: any) {
+        return `Attualmente usato in questo mese $${used}, soglia massima $${total}`;
+      },
+      IsChecking: "Controllando...",
+      Check: "Controlla ancora",
+      NoAccess: "Inserire la chiave API per controllare il saldo",
+    },
+    AccessCode: {
+      Title: "Codice d'accesso",
+      SubTitle: "Controllo d'accesso abilitato",
+      Placeholder: "Inserisci il codice d'accesso",
+    },
+    Model: "Modello GPT",
+    Temperature: {
+      Title: "Temperature",
+      SubTitle: "Un valore maggiore rende l'output piรน casuale",
+    },
+    MaxTokens: {
+      Title: "Token massimi",
+      SubTitle: "Lunghezza massima dei token in ingresso e dei token generati",
+    },
+    PresencePenlty: {
+      Title: "Penalitร  di presenza",
+      SubTitle:
+        "Un valore maggiore aumenta la probabilitร  di parlare di nuovi argomenti",
+    },
+  },
+  Store: {
+    DefaultTopic: "Nuova conversazione",
+    BotHello: "Ciao, come posso aiutarti oggi?",
+    Error: "Qualcosa รจ andato storto, riprova piรน tardi.",
+    Prompt: {
+      History: (content: string) =>
+        "Questo รจ un riassunto della cronologia delle chat tra l'IA e l'utente:" +
+        content,
+      Topic:
+        "Si prega di generare un titolo di quattro o cinque parole che riassuma la nostra conversazione senza alcuna traccia, punteggiatura, virgolette, punti, simboli o testo aggiuntivo. Rimuovere le virgolette",
+      Summarize:
+        "Riassumi brevemente la nostra discussione in 200 caratteri o meno per usarla come spunto per una futura conversazione.",
+    },
+  },
+  Copy: {
+    Success: "Copiato sugli appunti",
+    Failed:
+      "Copia fallita, concedere l'autorizzazione all'accesso agli appunti",
+  },
+  Context: {
+    Toast: (x: any) => `Con ${x} prompts contestuali`,
+    Edit: "Prompt contestuali e di memoria",
+    Add: "Aggiungi altro",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Mask",
+    Page: {
+      Title: "Prompt Template",
+      SubTitle: (count: number) => `${count} prompt templates`,
+      Search: "Search Templates",
+      Create: "Create",
+    },
+    Item: {
+      Info: (count: number) => `${count} prompts`,
+      Chat: "Chat",
+      View: "View",
+      Edit: "Edit",
+      Delete: "Delete",
+      DeleteConfirm: "Confirm to delete?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Edit Prompt Template ${readonly ? "(readonly)" : ""}`,
+      Download: "Download",
+      Clone: "Clone",
+    },
+    Config: {
+      Avatar: "Bot Avatar",
+      Name: "Bot Name",
+    },
+  },
+  NewChat: {
+    Return: "Return",
+    Skip: "Skip",
+    Title: "Pick a Mask",
+    SubTitle: "Chat with the Soul behind the Mask",
+    More: "Find More",
+    NotShow: "Not Show Again",
+    ConfirmNoShow: "Confirm to disable๏ผŸYou can enable it in settings later.",
+  },
+
+  UI: {
+    Confirm: "Confirm",
+    Cancel: "Cancel",
+    Close: "Close",
+    Create: "Create",
+    Edit: "Edit",
+  },
+};
+
+export default it;

+ 245 - 0
app/locales/jp.ts

@@ -0,0 +1,245 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const jp: LocaleType = {
+  WIP: "ใ“ใฎๆฉŸ่ƒฝใฏ้–‹็™บไธญใงใ™โ€ฆโ€ฆ",
+  Error: {
+    Unauthorized:
+      "็พๅœจใฏๆœชๆ‰ฟ่ช็Šถๆ…‹ใงใ™ใ€‚ๅทฆไธ‹ใฎ่จญๅฎšใƒœใ‚ฟใƒณใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใ€ใ‚ขใ‚ฏใ‚ปใ‚นใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„ใ€‚",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} ้€šใฎใƒใƒฃใƒƒใƒˆ`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `ChatGPTใจใฎ ${count} ้€šใฎใƒใƒฃใƒƒใƒˆ`,
+    Actions: {
+      ChatList: "ใƒกใƒƒใ‚ปใƒผใ‚ธใƒชใ‚นใƒˆใ‚’่กจ็คบ",
+      CompressedHistory: "ๅœง็ธฎใ•ใ‚ŒใŸๅฑฅๆญดใƒ—ใƒญใƒณใƒ—ใƒˆใ‚’่กจ็คบ",
+      Export: "ใƒใƒฃใƒƒใƒˆๅฑฅๆญดใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆ",
+      Copy: "ใ‚ณใƒ”ใƒผ",
+      Stop: "ๅœๆญข",
+      Retry: "ใƒชใƒˆใƒฉใ‚ค",
+      Delete: "Delete",
+    },
+    Rename: "ใƒใƒฃใƒƒใƒˆใฎๅๅ‰ใ‚’ๅค‰ๆ›ด",
+    Typing: "ๅ…ฅๅŠ›ไธญโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} ใง้€ไฟก`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += "๏ผŒShift + Enter ใงๆ”น่กŒ";
+      }
+      return inputHints + "๏ผŒ/ ใง่‡ชๅ‹•่ฃœๅฎŒใ‚’ใƒˆใƒชใ‚ฌใƒผ";
+    },
+    Send: "้€ไฟก",
+    Config: {
+      Reset: "้‡็ฝฎ้ป˜่ฎค",
+      SaveAs: "ๅฆๅญ˜ไธบ้ขๅ…ท",
+    },
+  },
+  Export: {
+    Title: "ใƒใƒฃใƒƒใƒˆๅฑฅๆญดใ‚’Markdownๅฝขๅผใงใ‚จใ‚ฏใ‚นใƒใƒผใƒˆ",
+    Copy: "ใ™ในใฆใ‚ณใƒ”ใƒผ",
+    Download: "ใƒ•ใ‚กใ‚คใƒซใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰",
+    MessageFromYou: "ใ‚ใชใŸใ‹ใ‚‰ใฎใƒกใƒƒใ‚ปใƒผใ‚ธ",
+    MessageFromChatGPT: "ChatGPTใ‹ใ‚‰ใฎใƒกใƒƒใ‚ปใƒผใ‚ธ",
+  },
+  Memory: {
+    Title: "ๅฑฅๆญดใƒกใƒขใƒช",
+    EmptyContent: "ใพใ ่จ˜ๆ†ถใ•ใ‚Œใฆใ„ใพใ›ใ‚“",
+    Send: "ใƒกใƒขใƒชใ‚’้€ไฟก",
+    Copy: "ใƒกใƒขใƒชใ‚’ใ‚ณใƒ”ใƒผ",
+    Reset: "ใƒใƒฃใƒƒใƒˆใ‚’ใƒชใ‚ปใƒƒใƒˆ",
+    ResetConfirm:
+      "ใƒชใ‚ปใƒƒใƒˆๅพŒใ€็พๅœจใฎใƒใƒฃใƒƒใƒˆๅฑฅๆญดใจ้ŽๅŽปใฎใƒกใƒขใƒชใŒใ‚ฏใƒชใ‚ขใ•ใ‚Œใพใ™ใ€‚ใƒชใ‚ปใƒƒใƒˆใ—ใฆใ‚‚ใ‚ˆใ‚ใ—ใ„ใงใ™ใ‹๏ผŸ",
+  },
+  Home: {
+    NewChat: "ๆ–ฐใ—ใ„ใƒใƒฃใƒƒใƒˆ",
+    DeleteChat: "้ธๆŠžใ—ใŸใƒใƒฃใƒƒใƒˆใ‚’ๅ‰Š้™คใ—ใฆใ‚‚ใ‚ˆใ‚ใ—ใ„ใงใ™ใ‹๏ผŸ",
+    DeleteToast: "ใƒใƒฃใƒƒใƒˆใŒๅ‰Š้™คใ•ใ‚Œใพใ—ใŸ",
+    Revert: "ๅ…ƒใซๆˆปใ™",
+  },
+  Settings: {
+    Title: "่จญๅฎš",
+    SubTitle: "่จญๅฎšใ‚ชใƒ—ใ‚ทใƒงใƒณ",
+    Actions: {
+      ClearAll: "ใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใ‚’ใ‚ฏใƒชใ‚ข",
+      ResetAll: "ใ™ในใฆใฎใ‚ชใƒ—ใ‚ทใƒงใƒณใ‚’ใƒชใ‚ปใƒƒใƒˆ",
+      Close: "้–‰ใ˜ใ‚‹",
+      ConfirmResetAll: "ใ™ในใฆใฎ่จญๅฎšใ‚’ใƒชใ‚ปใƒƒใƒˆใ—ใฆใ‚‚ใ‚ˆใ‚ใ—ใ„ใงใ™ใ‹๏ผŸ",
+      ConfirmClearAll: "ใ™ในใฆใฎใƒใƒฃใƒƒใƒˆใ‚’ใƒชใ‚ปใƒƒใƒˆใ—ใฆใ‚‚ใ‚ˆใ‚ใ—ใ„ใงใ™ใ‹๏ผŸ",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "ๆ‰€ๆœ‰่ฏญ่จ€",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "ใ‚ขใƒใ‚ฟใƒผ",
+    FontSize: {
+      Title: "ใƒ•ใ‚ฉใƒณใƒˆใ‚ตใ‚คใ‚บ",
+      SubTitle: "ใƒใƒฃใƒƒใƒˆๅ†…ๅฎนใฎใƒ•ใ‚ฉใƒณใƒˆใ‚ตใ‚คใ‚บ",
+    },
+
+    Update: {
+      Version: (x: string) => `็พๅœจใฎใƒใƒผใ‚ธใƒงใƒณ๏ผš${x}`,
+      IsLatest: "ๆœ€ๆ–ฐใƒใƒผใ‚ธใƒงใƒณใงใ™",
+      CheckUpdate: "ใ‚ขใƒƒใƒ—ใƒ‡ใƒผใƒˆใ‚’็ขบ่ช",
+      IsChecking: "ใ‚ขใƒƒใƒ—ใƒ‡ใƒผใƒˆใ‚’็ขบ่ชใ—ใฆใ„ใพใ™...",
+      FoundUpdate: (x: string) => `ๆ–ฐใ—ใ„ใƒใƒผใ‚ธใƒงใƒณใŒ่ฆ‹ใคใ‹ใ‚Šใพใ—ใŸ๏ผš${x}`,
+      GoToUpdate: "ๆ›ดๆ–ฐใ™ใ‚‹",
+    },
+    SendKey: "้€ไฟกใ‚ญใƒผ",
+    Theme: "ใƒ†ใƒผใƒž",
+    TightBorder: "ใƒœใƒผใƒ€ใƒผใƒฌใ‚นใƒขใƒผใƒ‰",
+    SendPreviewBubble: {
+      Title: "ใƒ—ใƒฌใƒ“ใƒฅใƒผใƒใƒ–ใƒซใฎ้€ไฟก",
+      SubTitle: "ๅœจ้ข„่งˆๆฐ”ๆณกไธญ้ข„่งˆ Markdown ๅ†…ๅฎน",
+    },
+    Mask: {
+      Title: "้ขๅ…ทๅฏๅŠจ้กต",
+      SubTitle: "ๆ–ฐๅปบ่Šๅคฉๆ—ถ๏ผŒๅฑ•็คบ้ขๅ…ทๅฏๅŠจ้กต",
+    },
+    Prompt: {
+      Disable: {
+        Title: "ใƒ—ใƒญใƒณใƒ—ใƒˆใฎ่‡ชๅ‹•่ฃœๅฎŒใ‚’็„กๅŠนใซใ™ใ‚‹",
+        SubTitle:
+          "ๅ…ฅๅŠ›ใƒ•ใ‚ฃใƒผใƒซใƒ‰ใฎๅ…ˆ้ ญใซ / ใ‚’ๅ…ฅๅŠ›ใ™ใ‚‹ใจใ€่‡ชๅ‹•่ฃœๅฎŒใŒใƒˆใƒชใ‚ฌใƒผใ•ใ‚Œใพใ™ใ€‚",
+      },
+      List: "ใ‚ซใ‚นใ‚ฟใƒ ใƒ—ใƒญใƒณใƒ—ใƒˆใƒชใ‚นใƒˆ",
+      ListCount: (builtin: number, custom: number) =>
+        `็ต„ใฟ่พผใฟ ${builtin} ไปถใ€ใƒฆใƒผใ‚ถใƒผๅฎš็พฉ ${custom} ไปถ`,
+      Edit: "็ทจ้›†",
+      Modal: {
+        Title: "ใƒ—ใƒญใƒณใƒ—ใƒˆใƒชใ‚นใƒˆ",
+        Add: "ๆ–ฐ่ฆ่ฟฝๅŠ ",
+        Search: "ใƒ—ใƒญใƒณใƒ—ใƒˆใƒฏใƒผใƒ‰ๆคœ็ดข",
+      },
+      EditModal: {
+        Title: "็ผ–่พ‘ๆ็คบ่ฏ",
+      },
+    },
+    HistoryCount: {
+      Title: "ๅฑฅๆญดใƒกใƒƒใ‚ปใƒผใ‚ธๆ•ฐใ‚’ๆทปไป˜",
+      SubTitle: "ใƒชใ‚ฏใ‚จใ‚นใƒˆใ”ใจใซๆทปไป˜ใ™ใ‚‹ๅฑฅๆญดใƒกใƒƒใ‚ปใƒผใ‚ธๆ•ฐ",
+    },
+    CompressThreshold: {
+      Title: "ๅฑฅๆญดใƒกใƒƒใ‚ปใƒผใ‚ธใฎ้•ทใ•ๅœง็ธฎใ—ใใ„ๅ€ค",
+      SubTitle:
+        "ๅœง็ธฎใ•ใ‚Œใฆใ„ใชใ„ๅฑฅๆญดใƒกใƒƒใ‚ปใƒผใ‚ธใŒใ“ใฎๅ€คใ‚’่ถ…ใˆใŸๅ ดๅˆใ€ๅœง็ธฎใŒ่กŒใ‚ใ‚Œใพใ™ใ€‚",
+    },
+    Token: {
+      Title: "APIใ‚ญใƒผ",
+      SubTitle: "่‡ชๅˆ†ใฎใ‚ญใƒผใ‚’ไฝฟ็”จใ—ใฆใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚ขใ‚ฏใ‚ปใ‚นๅˆถ้™ใ‚’่ฟ‚ๅ›žใ™ใ‚‹",
+      Placeholder: "OpenAI APIใ‚ญใƒผ",
+    },
+    Usage: {
+      Title: "ๆฎ‹้ซ˜็…งไผš",
+      SubTitle(used: any, total: any) {
+        return `ไปŠๆœˆใฏ $${used} ใ‚’ไฝฟ็”จใ—ใพใ—ใŸใ€‚็ท้กใฏ $${total} ใงใ™ใ€‚`;
+      },
+      IsChecking: "็ขบ่ชไธญ...",
+      Check: "ๅ†็ขบ่ช",
+      NoAccess: "APIใ‚ญใƒผใพใŸใฏใ‚ขใ‚ฏใ‚ปใ‚นใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆๆฎ‹้ซ˜ใ‚’่กจ็คบ",
+    },
+    AccessCode: {
+      Title: "ใ‚ขใ‚ฏใ‚ปใ‚นใƒ‘ใ‚นใƒฏใƒผใƒ‰",
+      SubTitle: "ๆš—ๅทๅŒ–ใ‚ขใ‚ฏใ‚ปใ‚นใŒๆœ‰ๅŠนใซใชใฃใฆใ„ใพใ™",
+      Placeholder: "ใ‚ขใ‚ฏใ‚ปใ‚นใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„",
+    },
+    Model: "ใƒขใƒ‡ใƒซ (model)",
+    Temperature: {
+      Title: "ใƒฉใƒณใƒ€ใƒ ๆ€ง (temperature)",
+      SubTitle:
+        "ๅ€คใŒๅคงใใ„ใปใฉใ€ๅ›ž็ญ”ใŒใƒฉใƒณใƒ€ใƒ ใซใชใ‚Šใพใ™ใ€‚1ไปฅไธŠใฎๅ€คใซใฏๆ–‡ๅญ—ๅŒ–ใ‘ใŒๅซใพใ‚Œใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚",
+    },
+    MaxTokens: {
+      Title: "ใ‚ทใƒณใ‚ฐใƒซใƒฌใ‚นใƒใƒณใ‚นๅˆถ้™ (max_tokens)",
+      SubTitle: "1ๅ›žใฎใ‚คใƒณใ‚ฟใƒฉใ‚ฏใ‚ทใƒงใƒณใงไฝฟ็”จใ•ใ‚Œใ‚‹ๆœ€ๅคงใƒˆใƒผใ‚ฏใƒณๆ•ฐ",
+    },
+    PresencePenlty: {
+      Title: "ใƒˆใƒ”ใƒƒใ‚ฏใฎๆ–ฐ้ฎฎๅบฆ (presence_penalty)",
+      SubTitle: "ๅ€คใŒๅคงใใ„ใปใฉใ€ๆ–ฐใ—ใ„ใƒˆใƒ”ใƒƒใ‚ฏใธใฎๅฑ•้–‹ใŒๅฏ่ƒฝใซใชใ‚Šใพใ™ใ€‚",
+    },
+  },
+  Store: {
+    DefaultTopic: "ๆ–ฐใ—ใ„ใƒใƒฃใƒƒใƒˆ",
+    BotHello: "ไฝ•ใ‹ใŠๆ‰‹ไผใ„ใงใใ‚‹ใ“ใจใฏใ‚ใ‚Šใพใ™ใ‹",
+    Error: "ใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸใ€‚ใ—ใฐใ‚‰ใใ—ใฆใ‹ใ‚‰ใ‚„ใ‚Š็›ดใ—ใฆใใ ใ•ใ„ใ€‚",
+    Prompt: {
+      History: (content: string) =>
+        "ใ“ใ‚Œใฏใ€AI ใจใƒฆใƒผใ‚ถใฎ้ŽๅŽปใฎใƒใƒฃใƒƒใƒˆใ‚’่ฆ็ด„ใ—ใŸๅ‰ๆใจใชใ‚‹ใ‚นใƒˆใƒผใƒชใƒผใงใ™๏ผš" +
+        content,
+      Topic:
+        "4๏ฝž5ๆ–‡ๅญ—ใงใ“ใฎๆ–‡็ซ ใฎ็ฐกๆฝ”ใชไธป้กŒใ‚’่ฟ”ใ—ใฆใใ ใ•ใ„ใ€‚่ชฌๆ˜Žใ€ๅฅ่ชญ็‚นใ€ๆ„Ÿๅ˜†่ฉžใ€ไฝ™ๅˆ†ใชใƒ†ใ‚ญใ‚นใƒˆใฏ็„กใ—ใงใ€‚ใ‚‚ใ—ไธป้กŒใŒใชใ„ๅ ดๅˆใฏใ€ใ€ŒใŠใ—ใ‚ƒในใ‚Šใ€ใ‚’่ฟ”ใ—ใฆใใ ใ•ใ„",
+      Summarize:
+        "ใ‚ใชใŸใจใƒฆใƒผใ‚ถใฎไผš่ฉฑใ‚’็ฐกๆฝ”ใซใพใจใ‚ใฆใ€ๅพŒ็ถšใฎใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆใƒ—ใƒญใƒณใƒ—ใƒˆใจใ—ใฆไฝฟใฃใฆใใ ใ•ใ„ใ€‚200ๅญ—ไปฅๅ†…ใซๆŠ‘ใˆใฆใใ ใ•ใ„ใ€‚",
+    },
+  },
+  Copy: {
+    Success: "ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰ใซๆ›ธใ่พผใฟใพใ—ใŸ",
+    Failed: "ใ‚ณใƒ”ใƒผใซๅคฑๆ•—ใ—ใพใ—ใŸใ€‚ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰่จฑๅฏใ‚’ไธŽใˆใฆใใ ใ•ใ„ใ€‚",
+  },
+  Context: {
+    Toast: (x: any) => `ๅ‰็ฝฎใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆใŒ ${x} ไปถ่จญๅฎšใ•ใ‚Œใพใ—ใŸ`,
+    Edit: "ๅ‰็ฝฎใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆใจๅฑฅๆญดใƒกใƒขใƒช",
+    Add: "ๆ–ฐ่ฆ่ฟฝๅŠ ",
+  },
+  Plugin: { Name: "ๆ’ไปถ" },
+  Mask: {
+    Name: "้ขๅ…ท",
+    Page: {
+      Title: "้ข„่ฎพ่ง’่‰ฒ้ขๅ…ท",
+      SubTitle: (count: number) => `${count} ไธช้ข„่ฎพ่ง’่‰ฒๅฎšไน‰`,
+      Search: "ๆœ็ดข่ง’่‰ฒ้ขๅ…ท",
+      Create: "ๆ–ฐๅปบ",
+    },
+    Item: {
+      Info: (count: number) => `ๅŒ…ๅซ ${count} ๆก้ข„่ฎพๅฏน่ฏ`,
+      Chat: "ๅฏน่ฏ",
+      View: "ๆŸฅ็œ‹",
+      Edit: "็ผ–่พ‘",
+      Delete: "ๅˆ ้™ค",
+      DeleteConfirm: "็กฎ่ฎคๅˆ ้™ค๏ผŸ",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `็ผ–่พ‘้ข„่ฎพ้ขๅ…ท ${readonly ? "๏ผˆๅช่ฏป๏ผ‰" : ""}`,
+      Download: "ไธ‹่ฝฝ้ข„่ฎพ",
+      Clone: "ๅ…‹้š†้ข„่ฎพ",
+    },
+    Config: {
+      Avatar: "่ง’่‰ฒๅคดๅƒ",
+      Name: "่ง’่‰ฒๅ็งฐ",
+    },
+  },
+  NewChat: {
+    Return: "่ฟ”ๅ›ž",
+    Skip: "่ทณ่ฟ‡",
+    Title: "ๆŒ‘้€‰ไธ€ไธช้ขๅ…ท",
+    SubTitle: "็Žฐๅœจๅผ€ๅง‹๏ผŒไธŽ้ขๅ…ท่ƒŒๅŽ็š„็ต้ญ‚ๆ€็ปด็ขฐๆ’ž",
+    More: "ๆœ็ดขๆ›ดๅคš",
+    NotShow: "ไธๅ†ๅฑ•็คบ",
+    ConfirmNoShow: "็กฎ่ฎค็ฆ็”จ๏ผŸ็ฆ็”จๅŽๅฏไปฅ้šๆ—ถๅœจ่ฎพ็ฝฎไธญ้‡ๆ–ฐๅฏ็”จใ€‚",
+  },
+
+  UI: {
+    Confirm: "็กฎ่ฎค",
+    Cancel: "ๅ–ๆถˆ",
+    Close: "ๅ…ณ้—ญ",
+    Create: "ๆ–ฐๅปบ",
+    Edit: "็ผ–่พ‘",
+  },
+};
+
+export default jp;

+ 245 - 0
app/locales/ru.ts

@@ -0,0 +1,245 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const ru: LocaleType = {
+  WIP: "ะกะบะพั€ะพ...",
+  Error: {
+    Unauthorized:
+      "ะะตัะฐะฝะบั†ะธะพะฝะธั€ะพะฒะฐะฝะฝั‹ะน ะดะพัั‚ัƒะฟ. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฒะฒะตะดะธั‚ะต ะบะพะด ะดะพัั‚ัƒะฟะฐ ะฝะฐ ัั‚ั€ะฐะฝะธั†ะต ะฝะฐัั‚ั€ะพะตะบ.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} ัะพะพะฑั‰ะตะฝะธะน`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} ัะพะพะฑั‰ะตะฝะธะน ั ChatGPT`,
+    Actions: {
+      ChatList: "ะŸะตั€ะตะนั‚ะธ ะบ ัะฟะธัะบัƒ ั‡ะฐั‚ะพะฒ",
+      CompressedHistory: "ะกะถะฐั‚ะฐั ะธัั‚ะพั€ะธั ะฟะฐะผัั‚ะธ",
+      Export: "ะญะบัะฟะพั€ั‚ะธั€ะพะฒะฐั‚ัŒ ะฒัะต ัะพะพะฑั‰ะตะฝะธั ะฒ ั„ะพั€ะผะฐั‚ะต Markdown",
+      Copy: "ะšะพะฟะธั€ะพะฒะฐั‚ัŒ",
+      Stop: "ะžัั‚ะฐะฝะพะฒะธั‚ัŒ",
+      Retry: "ะŸะพะฒั‚ะพั€ะธั‚ัŒ",
+      Delete: "ะฃะดะฐะปะธั‚ัŒ",
+    },
+    Rename: "ะŸะตั€ะตะธะผะตะฝะพะฒะฐั‚ัŒ ั‡ะฐั‚",
+    Typing: "ะŸะตั‡ะฐั‚ะฐะตั‚โ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} ะดะปั ะพั‚ะฟั€ะฐะฒะบะธ ัะพะพะฑั‰ะตะฝะธั`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter ะดะปั ะฟะตั€ะตะฝะพัะฐ ัั‚ั€ะพะบะธ";
+      }
+      return inputHints + ", / ะดะปั ะฟะพะธัะบะฐ ะฟะพะดัะบะฐะทะพะบ";
+    },
+    Send: "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ",
+    Config: {
+      Reset: "ะกะฑั€ะพัะธั‚ัŒ ะฝะฐัั‚ั€ะพะนะบะธ",
+      SaveAs: "ะกะพั…ั€ะฐะฝะธั‚ัŒ ะบะฐะบ ะผะฐัะบัƒ",
+    },
+  },
+  Export: {
+    Title: "ะ’ัะต ัะพะพะฑั‰ะตะฝะธั",
+    Copy: "ะšะพะฟะธั€ะพะฒะฐั‚ัŒ ะฒัะต",
+    Download: "ะกะบะฐั‡ะฐั‚ัŒ",
+    MessageFromYou: "ะกะพะพะฑั‰ะตะฝะธะต ะพั‚ ะฒะฐั",
+    MessageFromChatGPT: "ะกะพะพะฑั‰ะตะฝะธะต ะพั‚ ChatGPT",
+  },
+  Memory: {
+    Title: "ะŸะฐะผัั‚ัŒ",
+    EmptyContent: "ะŸัƒัั‚ะพ.",
+    Send: "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ะฟะฐะผัั‚ัŒ",
+    Copy: "ะšะพะฟะธั€ะพะฒะฐั‚ัŒ ะฟะฐะผัั‚ัŒ",
+    Reset: "ะกะฑั€ะพัะธั‚ัŒ ัะตััะธัŽ",
+    ResetConfirm:
+      "ะŸั€ะธ ัะฑั€ะพัะต ั‚ะตะบัƒั‰ะฐั ะธัั‚ะพั€ะธั ะฟะตั€ะตะฟะธัะบะธ ะธ ะธัั‚ะพั€ะธั‡ะตัะบะฐั ะฟะฐะผัั‚ัŒ ะฑัƒะดัƒั‚ ัƒะดะฐะปะตะฝั‹. ะ’ั‹ ัƒะฒะตั€ะตะฝั‹, ั‡ั‚ะพ ั…ะพั‚ะธั‚ะต ัะฑั€ะพัะธั‚ัŒ?",
+  },
+  Home: {
+    NewChat: "ะะพะฒั‹ะน ั‡ะฐั‚",
+    DeleteChat: "ะ’ั‹ ะดะตะนัั‚ะฒะธั‚ะตะปัŒะฝะพ ั…ะพั‚ะธั‚ะต ัƒะดะฐะปะธั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะน ั€ะฐะทะณะพะฒะพั€?",
+    DeleteToast: "ะงะฐั‚ ัƒะดะฐะปะตะฝ",
+    Revert: "ะžั‚ะผะตะฝะฐ",
+  },
+  Settings: {
+    Title: "ะะฐัั‚ั€ะพะนะบะธ",
+    SubTitle: "ะ’ัะต ะฝะฐัั‚ั€ะพะนะบะธ",
+    Actions: {
+      ClearAll: "ะžั‡ะธัั‚ะธั‚ัŒ ะฒัะต ะดะฐะฝะฝั‹ะต",
+      ResetAll: "ะกะฑั€ะพัะธั‚ัŒ ะฒัะต ะฝะฐัั‚ั€ะพะนะบะธ",
+      Close: "ะ—ะฐะบั€ั‹ั‚ัŒ",
+      ConfirmResetAll: "ะ’ั‹ ัƒะฒะตั€ะตะฝั‹, ั‡ั‚ะพ ั…ะพั‚ะธั‚ะต ัะฑั€ะพัะธั‚ัŒ ะฒัะต ะฝะฐัั‚ั€ะพะนะบะธ?",
+      ConfirmClearAll: "ะ’ั‹ ัƒะฒะตั€ะตะฝั‹, ั‡ั‚ะพ ั…ะพั‚ะธั‚ะต ะพั‡ะธัั‚ะธั‚ัŒ ะฒัะต ะดะฐะฝะฝั‹ะต?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "ะ’ัะต ัะทั‹ะบะธ",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+      Avatar: "ะะฒะฐั‚ะฐั€",
+      FontSize: {
+        Title: "ะ ะฐะทะผะตั€ ัˆั€ะธั„ั‚ะฐ",
+        SubTitle: "ะะฐัั‚ั€ะพะธั‚ัŒ ั€ะฐะทะผะตั€ ัˆั€ะธั„ั‚ะฐ ะบะพะฝั‚ะตะฝั‚ะฐ ั‡ะฐั‚ะฐ",
+      },
+      Update: {
+        Version: (x: string) => `ะ’ะตั€ัะธั: ${x}`,
+        IsLatest: "ะŸะพัะปะตะดะฝัั ะฒะตั€ัะธั",
+        CheckUpdate: "ะŸั€ะพะฒะตั€ะธั‚ัŒ ะพะฑะฝะพะฒะปะตะฝะธะต",
+        IsChecking: "ะŸั€ะพะฒะตั€ะบะฐ ะพะฑะฝะพะฒะปะตะฝะธั...",
+        FoundUpdate: (x: string) => `ะะฐะนะดะตะฝะฐ ะฝะพะฒะฐั ะฒะตั€ัะธั: ${x}`,
+        GoToUpdate: "ะžะฑะฝะพะฒะธั‚ัŒ",
+      },
+      SendKey: "ะšะปะฐะฒะธัˆะฐ ะพั‚ะฟั€ะฐะฒะบะธ",
+      Theme: "ะขะตะผะฐ",
+      TightBorder: "ะฃะทะบะฐั ะณั€ะฐะฝะธั†ะฐ",
+      SendPreviewBubble: {
+        Title: "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€",
+        SubTitle: "ะŸั€ะตะดะฒะฐั€ะธั‚ะตะปัŒะฝั‹ะน ะฟั€ะพัะผะพั‚ั€ markdown ะฒ ะฟัƒะทั‹ั€ะต",
+      },
+      Mask: {
+        Title: "ะญะบั€ะฐะฝ ะทะฐัั‚ะฐะฒะบะธ ะผะฐัะบะธ",
+        SubTitle: "ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ัะบั€ะฐะฝ ะทะฐัั‚ะฐะฒะบะธ ะผะฐัะบะธ ะฟะตั€ะตะด ะฝะฐั‡ะฐะปะพะผ ะฝะพะฒะพะณะพ ั‡ะฐั‚ะฐ",
+      },
+      Prompt: {
+        Disable: {
+          Title: "ะžั‚ะบะปัŽั‡ะธั‚ัŒ ะฐะฒั‚ะพะทะฐะฟะพะปะฝะตะฝะธะต",
+          SubTitle: "ะ’ะฒะพะด / ะดะปั ะทะฐะฟัƒัะบะฐ ะฐะฒั‚ะพะทะฐะฟะพะปะฝะตะฝะธั",
+        },
+        List: "ะกะฟะธัะพะบ ะฟะพะดัะบะฐะทะพะบ",
+        ListCount: (builtin: number, custom: number) =>
+          `${builtin} ะฒัั‚ั€ะพะตะฝะฝั‹ั…, ${custom} ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธั…`,
+        Edit: "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ",
+        Modal: {
+          Title: "ะกะฟะธัะพะบ ะฟะพะดัะบะฐะทะพะบ",
+          Add: "ะ”ะพะฑะฐะฒะธั‚ัŒ",
+          Search: "ะŸะพะธัะบ ะฟะพะดัะบะฐะทะพะบ",
+        },
+        EditModal: {
+          Title: "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะฟะพะดัะบะฐะทะบัƒ",
+        },
+      },
+      HistoryCount: {
+        Title: "ะšะพะปะธั‡ะตัั‚ะฒะพ ะฟั€ะธะบั€ะตะฟะปัะตะผั‹ั… ัะพะพะฑั‰ะตะฝะธะน",
+        SubTitle: "ะšะพะปะธั‡ะตัั‚ะฒะพ ะพั‚ะฟั€ะฐะฒะปัะตะผั‹ั… ัะพะพะฑั‰ะตะฝะธะน, ะฟั€ะธะบั€ะตะฟะปัะตะผั‹ั… ะบ ะบะฐะถะดะพะผัƒ ะทะฐะฟั€ะพััƒ",
+    },
+    CompressThreshold: {
+      Title: "ะŸะพั€ะพะณ ัะถะฐั‚ะธั ะธัั‚ะพั€ะธะธ",
+      SubTitle:
+        "ะ‘ัƒะดะตั‚ ัะถะธะผะฐั‚ัŒ, ะตัะปะธ ะดะปะธะฝะฐ ะฝะตัะถะฐั‚ั‹ั… ัะพะพะฑั‰ะตะฝะธะน ะฟั€ะตะฒั‹ัˆะฐะตั‚ ัƒะบะฐะทะฐะฝะฝะพะต ะทะฝะฐั‡ะตะฝะธะต",
+    },
+    Token: {
+      Title: "API ะบะปัŽั‡",
+      SubTitle: "ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ัะฒะพะน ะบะปัŽั‡, ั‡ั‚ะพะฑั‹ ะธะณะฝะพั€ะธั€ะพะฒะฐั‚ัŒ ะปะธะผะธั‚ ะดะพัั‚ัƒะฟะฐ",
+      Placeholder: "API ะบะปัŽั‡ OpenAI",
+    },
+    Usage: {
+      Title: "ะ‘ะฐะปะฐะฝั ะฐะบะบะฐัƒะฝั‚ะฐ",
+      SubTitle(used: any, total: any) {
+        return `ะ˜ัะฟะพะปัŒะทะพะฒะฐะฝะพ ะฒ ัั‚ะพะผ ะผะตััั†ะต $${used}, ะฟะพะดะฟะธัะบะฐ $${total}`;
+      },
+      IsChecking: "ะŸั€ะพะฒะตั€ะบะฐ...",
+      Check: "ะŸั€ะพะฒะตั€ะธั‚ัŒ",
+      NoAccess: "ะ’ะฒะตะดะธั‚ะต API ะบะปัŽั‡, ั‡ั‚ะพะฑั‹ ะฟั€ะพะฒะตั€ะธั‚ัŒ ะฑะฐะปะฐะฝั",
+    },
+    AccessCode: {
+      Title: "ะšะพะด ะดะพัั‚ัƒะฟะฐ",
+      SubTitle: "ะšะพะฝั‚ั€ะพะปัŒ ะดะพัั‚ัƒะฟะฐ ะฒะบะปัŽั‡ะตะฝ",
+      Placeholder: "ะขั€ะตะฑัƒะตั‚ัั ะบะพะด ะดะพัั‚ัƒะฟะฐ",
+    },
+    Model: "ะœะพะดะตะปัŒ",
+    Temperature: {
+      Title: "ะขะตะผะฟะตั€ะฐั‚ัƒั€ะฐ",
+      SubTitle: "ะงะตะผ ะฒั‹ัˆะต ะทะฝะฐั‡ะตะฝะธะต, ั‚ะตะผ ะฑะพะปะตะต ัะปัƒั‡ะฐะนะฝั‹ะน ะฒั‹ะฒะพะด",
+    },
+    MaxTokens: {
+      Title: "ะœะฐะบัะธะผะฐะปัŒะฝะพะต ะบะพะปะธั‡ะตัั‚ะฒะพ ั‚ะพะบะตะฝะพะฒ",
+      SubTitle: "ะœะฐะบัะธะผะฐะปัŒะฝะฐั ะดะปะธะฝะฐ ะฒะฒะพะดะฝั‹ั… ะธ ะณะตะฝะตั€ะธั€ัƒะตะผั‹ั… ั‚ะพะบะตะฝะพะฒ",
+    },
+    PresencePenlty: {
+      Title: "ะจั‚ั€ะฐั„ ะทะฐ ะฟะพะฒั‚ะพั€ะตะฝะธั",
+      SubTitle:
+        "ะงะตะผ ะฒั‹ัˆะต ะทะฝะฐั‡ะตะฝะธะต, ั‚ะตะผ ะฑะพะปัŒัˆะต ะฒะตั€ะพัั‚ะฝะพัั‚ัŒ ะพะฑั‰ะตะฝะธั ะฝะฐ ะฝะพะฒั‹ะต ั‚ะตะผั‹",
+    },
+  },
+  Store: {
+    DefaultTopic: "ะะพะฒั‹ะน ั€ะฐะทะณะพะฒะพั€",
+    BotHello: "ะ—ะดั€ะฐะฒัั‚ะฒัƒะนั‚ะต! ะšะฐะบ ั ะผะพะณัƒ ะฒะฐะผ ะฟะพะผะพั‡ัŒ ัะตะณะพะดะฝั?",
+    Error: "ะงั‚ะพ-ั‚ะพ ะฟะพัˆะปะพ ะฝะต ั‚ะฐะบ. ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ะตั‰ะต ั€ะฐะท ะฟะพะทะถะต.",
+    Prompt: {
+      History: (content: string) =>
+        "ะญั‚ะพ ะบั€ะฐั‚ะบะพะต ัะพะดะตั€ะถะฐะฝะธะต ะธัั‚ะพั€ะธะธ ั‡ะฐั‚ะฐ ะผะตะถะดัƒ ะ˜ะ˜ ะธ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปะตะผ: " +
+        content,
+      Topic:
+        "ะŸะพะถะฐะปัƒะนัั‚ะฐ, ัะพะทะดะฐะนั‚ะต ะทะฐะณะพะปะพะฒะพะบ ะธะท ั‡ะตั‚ั‹ั€ะตั… ะธะปะธ ะฟัั‚ะธ ัะปะพะฒ, ะบะพั‚ะพั€ั‹ะน ะบั€ะฐั‚ะบะพ ะพะฟะธัั‹ะฒะฐะตั‚ ะฝะฐัˆัƒ ะฑะตัะตะดัƒ, ะฑะตะท ะฒะฒะตะดะตะฝะธั, ะทะฝะฐะบะพะฒ ะฟัƒะฝะบั‚ัƒะฐั†ะธะธ, ะบะฐะฒั‹ั‡ะตะบ, ั‚ะพั‡ะตะบ, ัะธะผะฒะพะปะพะฒ ะธะปะธ ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝะพะณะพ ั‚ะตะบัั‚ะฐ. ะฃะดะฐะปะธั‚ะต ะบะฐะฒั‹ั‡ะบะธ.",
+      Summarize:
+        "ะšั€ะฐั‚ะบะพ ะธะทะปะพะถะธั‚ะต ะฝะฐัˆัƒ ะดะธัะบัƒััะธัŽ ะฒ 200 ัะปะพะฒะฐั… ะธะปะธ ะผะตะฝะตะต ะดะปั ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั ะฒ ะฑัƒะดัƒั‰ะตะผ ะบะพะฝั‚ะตะบัั‚ะต.",
+    },
+  },
+  Copy: {
+    Success: "ะกะบะพะฟะธั€ะพะฒะฐะฝะพ ะฒ ะฑัƒั„ะตั€ ะพะฑะผะตะฝะฐ",
+    Failed: "ะะต ัƒะดะฐะปะพััŒ ัะบะพะฟะธั€ะพะฒะฐั‚ัŒ, ะฟะพะถะฐะปัƒะนัั‚ะฐ, ะฟั€ะตะดะพัั‚ะฐะฒัŒั‚ะต ั€ะฐะทั€ะตัˆะตะฝะธะต ะฝะฐ ะดะพัั‚ัƒะฟ ะบ ะฑัƒั„ะตั€ัƒ ะพะฑะผะตะฝะฐ",
+  },
+  Context: {
+    Toast: (x: any) => `ะก ${x} ะบะพะฝั‚ะตะบัั‚ะฝั‹ะผะธ ะฟะพะดัะบะฐะทะบะฐะผะธ`,
+    Edit: "ะšะพะฝั‚ะตะบัั‚ะฝั‹ะต ะธ ะฟะฐะผัั‚ะฝั‹ะต ะฟะพะดัะบะฐะทะบะธ",
+    Add: "ะ”ะพะฑะฐะฒะธั‚ัŒ ะฟะพะดัะบะฐะทะบัƒ",
+  },
+  Plugin: {
+    Name: "ะŸะปะฐะณะธะฝ",
+  },
+  Mask: {
+    Name: "ะœะฐัะบะฐ",
+    Page: {
+      Title: "ะจะฐะฑะปะพะฝ ะฟะพะดัะบะฐะทะบะธ",
+      SubTitle: (count: number) => `${count} ัˆะฐะฑะปะพะฝะพะฒ ะฟะพะดัะบะฐะทะพะบ`,
+      Search: "ะŸะพะธัะบ ัˆะฐะฑะปะพะฝะพะฒ",
+      Create: "ะกะพะทะดะฐั‚ัŒ",
+    },
+    Item: {
+      Info: (count: number) => `${count} ะฟะพะดัะบะฐะทะพะบ`,
+      Chat: "ะงะฐั‚",
+      View: "ะŸั€ะพัะผะพั‚ั€",
+      Edit: "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ",
+      Delete: "ะฃะดะฐะปะธั‚ัŒ",
+      DeleteConfirm: "ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ ัƒะดะฐะปะตะฝะธะต?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต ัˆะฐะฑะปะพะฝะฐ ะฟะพะดัะบะฐะทะบะธ ${readonly ? "(ั‚ะพะปัŒะบะพ ะดะปั ั‡ั‚ะตะฝะธั)" : ""}`,
+      Download: "ะกะบะฐั‡ะฐั‚ัŒ",
+      Clone: "ะšะปะพะฝะธั€ะพะฒะฐั‚ัŒ",
+    },
+    Config: {
+      Avatar: "ะะฒะฐั‚ะฐั€ ะฑะพั‚ะฐ",
+      Name: "ะ˜ะผั ะฑะพั‚ะฐ",
+    },
+  },
+  NewChat: {
+    Return: "ะ’ะตั€ะฝัƒั‚ัŒัั",
+    Skip: "ะŸั€ะพะฟัƒัั‚ะธั‚ัŒ",
+    Title: "ะ’ั‹ะฑะตั€ะธั‚ะต ะผะฐัะบัƒ",
+    SubTitle: "ะžะฑั‰ะฐะนั‚ะตััŒ ั ะดัƒัˆะพะน ะทะฐ ะผะฐัะบะพะน",
+    More: "ะะฐะนั‚ะธ ะตั‰ะต",
+    NotShow: "ะะต ะฟะพะบะฐะทั‹ะฒะฐั‚ัŒ ัะฝะพะฒะฐ",
+    ConfirmNoShow: "ะŸะพะดั‚ะฒะตั€ะดะธั‚ะต ะพั‚ะบะปัŽั‡ะตะฝะธะต? ะ’ั‹ ะผะพะถะตั‚ะต ะฒะบะปัŽั‡ะธั‚ัŒ ัั‚ะพ ะฟะพะทะถะต ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั….",
+  },
+
+  UI: {
+    Confirm: "ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ",
+    Cancel: "ะžั‚ะผะตะฝะฐ",
+    Close: "ะ—ะฐะบั€ั‹ั‚ัŒ",
+    Create: "ะกะพะทะดะฐั‚ัŒ",
+    Edit: "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ",
+  },
+};
+
+export default ru;

+ 247 - 0
app/locales/tr.ts

@@ -0,0 +1,247 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const tr: LocaleType = {
+  WIP: "ร‡alฤฑลŸma devam ediyor...",
+  Error: {
+    Unauthorized:
+      "Yetkisiz eriลŸim, lรผtfen eriลŸim kodunu ayarlar sayfasฤฑndan giriniz.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} mesaj`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `ChatGPT tarafฤฑndan ${count} mesaj`,
+    Actions: {
+      ChatList: "Sohbet Listesine Git",
+      CompressedHistory: "SฤฑkฤฑลŸtฤฑrฤฑlmฤฑลŸ GeรงmiลŸ Bellek Komutu",
+      Export: "Tรผm Mesajlarฤฑ Markdown Olarak DฤฑลŸa Aktar",
+      Copy: "Kopyala",
+      Stop: "Durdur",
+      Retry: "Tekrar Dene",
+      Delete: "Delete",
+    },
+    Rename: "Sohbeti Yeniden Adlandฤฑr",
+    Typing: "Yazฤฑyorโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `Gรถndermek iรงin ${submitKey}`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", kaydฤฑrmak iรงin Shift + Enter";
+      }
+      return inputHints + ", komutlarฤฑ aramak iรงin / (eฤŸik รงizgi)";
+    },
+    Send: "Gรถnder",
+    Config: {
+      Reset: "Reset to Default",
+      SaveAs: "Save as Mask",
+    },
+  },
+  Export: {
+    Title: "Tรผm Mesajlar",
+    Copy: "Tรผmรผnรผ Kopyala",
+    Download: "ฤฐndir",
+    MessageFromYou: "Sizin Mesajฤฑnฤฑz",
+    MessageFromChatGPT: "ChatGPT'nin Mesajฤฑ",
+  },
+  Memory: {
+    Title: "Bellek Komutlarฤฑ",
+    EmptyContent: "Henรผz deฤŸil.",
+    Send: "BelleฤŸi Gรถnder",
+    Copy: "BelleฤŸi Kopyala",
+    Reset: "Oturumu Sฤฑfฤฑrla",
+    ResetConfirm:
+      "Sฤฑfฤฑrlama, geรงerli gรถrรผลŸme geรงmiลŸini ve geรงmiลŸ belleฤŸi siler. Sฤฑfฤฑrlamak istediฤŸinizden emin misiniz?",
+  },
+  Home: {
+    NewChat: "Yeni Sohbet",
+    DeleteChat: "Seรงili sohbeti silmeyi onaylฤฑyor musunuz?",
+    DeleteToast: "Sohbet Silindi",
+    Revert: "Geri Al",
+  },
+  Settings: {
+    Title: "Ayarlar",
+    SubTitle: "Tรผm Ayarlar",
+    Actions: {
+      ClearAll: "Tรผm Verileri Temizle",
+      ResetAll: "Tรผm Ayarlarฤฑ Sฤฑfฤฑrla",
+      Close: "Kapat",
+      ConfirmResetAll: "Tรผm ayarlarฤฑ sฤฑfฤฑrlamak istediฤŸinizden emin misiniz?",
+      ConfirmClearAll: "Tรผm sohbeti sฤฑfฤฑrlamak istediฤŸinizden emin misiniz?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "Tรผm Diller",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "Avatar",
+    FontSize: {
+      Title: "Yazฤฑ Boyutu",
+      SubTitle: "Sohbet iรงeriฤŸinin yazฤฑ boyutunu ayarlayฤฑn",
+    },
+    Update: {
+      Version: (x: string) => `Sรผrรผm: ${x}`,
+      IsLatest: "En son sรผrรผm",
+      CheckUpdate: "Gรผncellemeyi Kontrol Et",
+      IsChecking: "Gรผncelleme kontrol ediliyor...",
+      FoundUpdate: (x: string) => `Yeni sรผrรผm bulundu: ${x}`,
+      GoToUpdate: "Gรผncelle",
+    },
+    SendKey: "Gรถnder TuลŸu",
+    Theme: "Tema",
+    TightBorder: "Tam Ekran",
+    SendPreviewBubble: {
+      Title: "Mesaj ร–nizleme Balonu",
+      SubTitle: "Preview markdown in bubble",
+    },
+    Mask: {
+      Title: "Mask Splash Screen",
+      SubTitle: "Show a mask splash screen before starting new chat",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Otomatik tamamlamayฤฑ devre dฤฑลŸฤฑ bฤฑrak",
+        SubTitle: "Otomatik tamamlamayฤฑ kullanmak iรงin / (eฤŸik รงizgi) girin",
+      },
+      List: "Komut Listesi",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} yerleลŸik, ${custom} kullanฤฑcฤฑ tanฤฑmlฤฑ`,
+      Edit: "Dรผzenle",
+      Modal: {
+        Title: "Prompt List",
+        Add: "Add One",
+        Search: "Search Prompts",
+      },
+      EditModal: {
+        Title: "Edit Prompt",
+      },
+    },
+    HistoryCount: {
+      Title: "Ekli Mesaj Sayฤฑsฤฑ",
+      SubTitle: "ฤฐstek baลŸฤฑna ekli gรถnderilen mesaj sayฤฑsฤฑ",
+    },
+    CompressThreshold: {
+      Title: "GeรงmiลŸ SฤฑkฤฑลŸtฤฑrma EลŸiฤŸi",
+      SubTitle:
+        "SฤฑkฤฑลŸtฤฑrฤฑlmamฤฑลŸ mesajlarฤฑn uzunluฤŸu bu deฤŸeri aลŸarsa sฤฑkฤฑลŸtฤฑrฤฑlฤฑr",
+    },
+    Token: {
+      Title: "API Anahtarฤฑ",
+      SubTitle: "EriลŸim kodu sฤฑnฤฑrฤฑnฤฑ yoksaymak iรงin anahtarฤฑnฤฑzฤฑ kullanฤฑn",
+      Placeholder: "OpenAI API Anahtarฤฑ",
+    },
+    Usage: {
+      Title: "Hesap Bakiyesi",
+      SubTitle(used: any, total: any) {
+        return `Bu ay kullanฤฑlan $${used}, abonelik $${total}`;
+      },
+      IsChecking: "Kontrol ediliyor...",
+      Check: "Tekrar Kontrol Et",
+      NoAccess: "Bakiyeyi kontrol etmek iรงin API anahtarฤฑnฤฑ girin",
+    },
+    AccessCode: {
+      Title: "EriลŸim Kodu",
+      SubTitle: "EriลŸim kontrolรผ etkinleลŸtirme",
+      Placeholder: "EriลŸim Kodu Gerekiyor",
+    },
+    Model: "Model",
+    Temperature: {
+      Title: "Gerรงeklik",
+      SubTitle:
+        "Daha bรผyรผk bir deฤŸer girildiฤŸinde gerรงeklik oranฤฑ dรผลŸer ve daha rastgele รงฤฑktฤฑlar รผretir",
+    },
+    MaxTokens: {
+      Title: "Maksimum Belirteรง",
+      SubTitle:
+        "Girdi belirteรงlerinin ve oluลŸturulan belirteรงlerin maksimum uzunluฤŸu",
+    },
+    PresencePenlty: {
+      Title: "Varlฤฑk Cezasฤฑ",
+      SubTitle:
+        "Daha bรผyรผk bir deฤŸer, yeni konular hakkฤฑnda konuลŸma olasฤฑlฤฑฤŸฤฑnฤฑ artฤฑrฤฑr",
+    },
+  },
+  Store: {
+    DefaultTopic: "Yeni KonuลŸma",
+    BotHello: "Merhaba! Size bugรผn nasฤฑl yardฤฑmcฤฑ olabilirim?",
+    Error: "Bir ลŸeyler yanlฤฑลŸ gitti. Lรผtfen daha sonra tekrar deneyiniz.",
+    Prompt: {
+      History: (content: string) =>
+        "Bu, yapay zeka ile kullanฤฑcฤฑ arasฤฑndaki sohbet geรงmiลŸinin bir รถzetidir: " +
+        content,
+      Topic:
+        "Lรผtfen herhangi bir giriลŸ, noktalama iลŸareti, tฤฑrnak iลŸareti, nokta, sembol veya ek metin olmadan konuลŸmamฤฑzฤฑ รถzetleyen dรถrt ila beลŸ kelimelik bir baลŸlฤฑk oluลŸturun. ร‡evreleyen tฤฑrnak iลŸaretlerini kaldฤฑrฤฑn.",
+      Summarize:
+        "Gelecekteki baฤŸlam iรงin bir bilgi istemi olarak kullanmak รผzere tartฤฑลŸmamฤฑzฤฑ en fazla 200 kelimeyle รถzetleyin.",
+    },
+  },
+  Copy: {
+    Success: "Panoya kopyalandฤฑ",
+    Failed: "Kopyalama baลŸarฤฑsฤฑz oldu, lรผtfen panoya eriลŸim izni verin",
+  },
+  Context: {
+    Toast: (x: any) => `${x} baฤŸlamsal bellek komutu`,
+    Edit: "BaฤŸlamsal ve Bellek Komutlarฤฑ",
+    Add: "Yeni Ekle",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Mask",
+    Page: {
+      Title: "Prompt Template",
+      SubTitle: (count: number) => `${count} prompt templates`,
+      Search: "Search Templates",
+      Create: "Create",
+    },
+    Item: {
+      Info: (count: number) => `${count} prompts`,
+      Chat: "Chat",
+      View: "View",
+      Edit: "Edit",
+      Delete: "Delete",
+      DeleteConfirm: "Confirm to delete?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Edit Prompt Template ${readonly ? "(readonly)" : ""}`,
+      Download: "Download",
+      Clone: "Clone",
+    },
+    Config: {
+      Avatar: "Bot Avatar",
+      Name: "Bot Name",
+    },
+  },
+  NewChat: {
+    Return: "Return",
+    Skip: "Skip",
+    Title: "Pick a Mask",
+    SubTitle: "Chat with the Soul behind the Mask",
+    More: "Find More",
+    NotShow: "Not Show Again",
+    ConfirmNoShow: "Confirm to disable๏ผŸYou can enable it in settings later.",
+  },
+
+  UI: {
+    Confirm: "Confirm",
+    Cancel: "Cancel",
+    Close: "Close",
+    Create: "Create",
+    Edit: "Edit",
+  },
+};
+
+export default tr;

+ 237 - 0
app/locales/tw.ts

@@ -0,0 +1,237 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const tw: LocaleType = {
+  WIP: "่ฉฒๅŠŸ่ƒฝไปๅœจ้–‹็™ผไธญโ€ฆโ€ฆ",
+  Error: {
+    Unauthorized: "็›ฎๅ‰ๆ‚จ็š„็‹€ๆ…‹ๆ˜ฏๆœชๆŽˆๆฌŠ๏ผŒ่ซ‹ๅ‰ๅพ€่จญๅฎš้ ้ข่ผธๅ…ฅๆŽˆๆฌŠ็ขผใ€‚",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} ๆขๅฐ่ฉฑ`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `ๆ‚จๅทฒ็ถ“่ˆ‡ ChatGPT ้€ฒ่กŒไบ† ${count} ๆขๅฐ่ฉฑ`,
+    Actions: {
+      ChatList: "ๆŸฅ็œ‹่จŠๆฏๅˆ—่กจ",
+      CompressedHistory: "ๆŸฅ็œ‹ๅฃ“็ธฎๅพŒ็š„ๆญทๅฒ Prompt",
+      Export: "ๅŒฏๅ‡บ่Šๅคฉ็ด€้Œ„",
+      Copy: "่ค‡่ฃฝ",
+      Stop: "ๅœๆญข",
+      Retry: "้‡่ฉฆ",
+      Delete: "ๅˆช้™ค",
+    },
+    Rename: "้‡ๅ‘ฝๅๅฐ่ฉฑ",
+    Typing: "ๆญฃๅœจ่ผธๅ…ฅโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `่ผธๅ…ฅ่จŠๆฏๅพŒ๏ผŒๆŒ‰ไธ‹ ${submitKey} ้ตๅณๅฏ็™ผ้€`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += "๏ผŒShift + Enter ้ตๆ›่กŒ";
+      }
+      return inputHints;
+    },
+    Send: "็™ผ้€",
+    Config: {
+      Reset: "้‡็ฝฎ้ป˜่ฎค",
+      SaveAs: "ๅฆๅญ˜ไธบ้ขๅ…ท",
+    },
+  },
+  Export: {
+    Title: "ๅฐ‡่Šๅคฉ่จ˜้Œ„ๅŒฏๅ‡บ็‚บ Markdown",
+    Copy: "่ค‡่ฃฝๅ…จ้ƒจ",
+    Download: "ไธ‹่ผ‰ๆช”ๆกˆ",
+    MessageFromYou: "ไพ†่‡ชๆ‚จ็š„่จŠๆฏ",
+    MessageFromChatGPT: "ไพ†่‡ช ChatGPT ็š„่จŠๆฏ",
+  },
+  Memory: {
+    Title: "ไธŠไธ‹ๆ–‡่จ˜ๆ†ถ Prompt",
+    EmptyContent: "ๅฐšๆœช่จ˜ๆ†ถ",
+    Copy: "่ค‡่ฃฝๅ…จ้ƒจ",
+    Send: "็™ผ้€่จ˜ๆ†ถ",
+    Reset: "้‡่จญๅฐ่ฉฑ",
+    ResetConfirm: "้‡่จญๅพŒๅฐ‡ๆธ…้™ค็›ฎๅ‰ๅฐ่ฉฑ่จ˜้Œ„ไปฅๅŠๆญทๅฒ่จ˜ๆ†ถ๏ผŒ็ขบ่ช้‡่จญ๏ผŸ",
+  },
+  Home: {
+    NewChat: "ๆ–ฐ็š„ๅฐ่ฉฑ",
+    DeleteChat: "็ขบๅฎš่ฆๅˆช้™ค้ธๅ–็š„ๅฐ่ฉฑๅ—Ž๏ผŸ",
+    DeleteToast: "ๅทฒๅˆช้™คๅฐ่ฉฑ",
+    Revert: "ๆ’ค้Šท",
+  },
+  Settings: {
+    Title: "่จญๅฎš",
+    SubTitle: "่จญๅฎš้ธ้ …",
+    Actions: {
+      ClearAll: "ๆธ…้™คๆ‰€ๆœ‰่ณ‡ๆ–™",
+      ResetAll: "้‡่จญๆ‰€ๆœ‰่จญๅฎš",
+      Close: "้—œ้–‰",
+      ConfirmResetAll: "ๆ‚จ็ขบๅฎš่ฆ้‡่จญๆ‰€ๆœ‰่จญๅฎšๅ—Ž๏ผŸ",
+      ConfirmClearAll: "ๆ‚จ็ขบๅฎš่ฆๆธ…้™คๆ‰€ๆœ‰ๆ•ฐๆฎๅ—Ž๏ผŸ",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "ๆ‰€ๆœ‰่ฏญ่จ€",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "ๅคง้ ญ่ฒผ",
+    FontSize: {
+      Title: "ๅญ—ๅž‹ๅคงๅฐ",
+      SubTitle: "่Šๅคฉๅ…งๅฎน็š„ๅญ—ๅž‹ๅคงๅฐ",
+    },
+    Update: {
+      Version: (x: string) => `็•ถๅ‰็‰ˆๆœฌ๏ผš${x}`,
+      IsLatest: "ๅทฒๆ˜ฏๆœ€ๆ–ฐ็‰ˆๆœฌ",
+      CheckUpdate: "ๆชขๆŸฅๆ›ดๆ–ฐ",
+      IsChecking: "ๆญฃๅœจๆชขๆŸฅๆ›ดๆ–ฐ...",
+      FoundUpdate: (x: string) => `็™ผ็พๆ–ฐ็‰ˆๆœฌ๏ผš${x}`,
+      GoToUpdate: "ๅ‰ๅพ€ๆ›ดๆ–ฐ",
+    },
+    SendKey: "็™ผ้€้ต",
+    Theme: "ไธป้กŒ",
+    TightBorder: "็ทŠๆนŠ้‚Šๆก†",
+    SendPreviewBubble: {
+      Title: "้ ่ฆฝๆฐฃๆณก",
+      SubTitle: "ๅœจ้ข„่งˆๆฐ”ๆณกไธญ้ข„่งˆ Markdown ๅ†…ๅฎน",
+    },
+    Mask: {
+      Title: "้ขๅ…ทๅฏๅŠจ้กต",
+      SubTitle: "ๆ–ฐๅปบ่Šๅคฉๆ—ถ๏ผŒๅฑ•็คบ้ขๅ…ทๅฏๅŠจ้กต",
+    },
+    Prompt: {
+      Disable: {
+        Title: "ๅœ็”จๆ็คบ่ฉž่‡ชๅ‹•่ฃœ้ฝŠ",
+        SubTitle: "ๅœจ่ผธๅ…ฅๆก†้–‹้ ญ่ผธๅ…ฅ / ๅณๅฏ่งธ็™ผ่‡ชๅ‹•่ฃœ้ฝŠ",
+      },
+      List: "่‡ชๅฎš็พฉๆ็คบ่ฉžๅˆ—่กจ",
+      ListCount: (builtin: number, custom: number) =>
+        `ๅ…งๅปบ ${builtin} ๆข๏ผŒ็”จๆˆถๅฎš็พฉ ${custom} ๆข`,
+      Edit: "็ทจ่ผฏ",
+      Modal: {
+        Title: "ๆ็คบ่ฉžๅˆ—่กจ",
+        Add: "ๆ–ฐๅขžไธ€ๆข",
+        Search: "ๆœๅฐ‹ๆ็คบ่ฉž",
+      },
+      EditModal: {
+        Title: "็ผ–่พ‘ๆ็คบ่ฏ",
+      },
+    },
+    HistoryCount: {
+      Title: "้™„ๅธถๆญทๅฒ่จŠๆฏๆ•ธ",
+      SubTitle: "ๆฏๆฌก่ซ‹ๆฑ‚้™„ๅธถ็š„ๆญทๅฒ่จŠๆฏๆ•ธ",
+    },
+    CompressThreshold: {
+      Title: "ๆญทๅฒ่จŠๆฏ้•ทๅบฆๅฃ“็ธฎ้–พๅ€ผ",
+      SubTitle: "็•ถๆœชๅฃ“็ธฎ็š„ๆญทๅฒ่จŠๆฏ่ถ…้Ž่ฉฒๅ€ผๆ™‚๏ผŒๅฐ‡้€ฒ่กŒๅฃ“็ธฎ",
+    },
+    Token: {
+      Title: "API Key",
+      SubTitle: "ไฝฟ็”จ่‡ชๅทฑ็š„ Key ๅฏ่ฆ้ฟๆŽˆๆฌŠๅญ˜ๅ–้™ๅˆถ",
+      Placeholder: "OpenAI API Key",
+    },
+    Usage: {
+      Title: "ๅธณๆˆถ้ค˜้ก",
+      SubTitle(used: any, total: any) {
+        return `ๆœฌๆœˆๅทฒไฝฟ็”จ $${used}๏ผŒ่จ‚้–ฑ็ธฝ้ก $${total}`;
+      },
+      IsChecking: "ๆญฃๅœจๆชขๆŸฅโ€ฆ",
+      Check: "้‡ๆ–ฐๆชขๆŸฅ",
+      NoAccess: "่ผธๅ…ฅAPI KeyๆŸฅ็œ‹้ค˜้ก",
+    },
+    AccessCode: {
+      Title: "ๆŽˆๆฌŠ็ขผ",
+      SubTitle: "็›ฎๅ‰ๆ˜ฏๆœชๆŽˆๆฌŠๅญ˜ๅ–็‹€ๆ…‹",
+      Placeholder: "่ซ‹่ผธๅ…ฅๆŽˆๆฌŠ็ขผ",
+    },
+    Model: "ๆจกๅž‹ (model)",
+    Temperature: {
+      Title: "้šจๆฉŸๆ€ง (temperature)",
+      SubTitle: "ๅ€ผ่ถŠๅคง๏ผŒๅ›žๆ‡‰่ถŠ้šจๆฉŸ",
+    },
+    MaxTokens: {
+      Title: "ๅ–ฎๆฌกๅ›žๆ‡‰้™ๅˆถ (max_tokens)",
+      SubTitle: "ๅ–ฎๆฌกไบ’ๅ‹•ๆ‰€็”จ็š„ๆœ€ๅคง Token ๆ•ธ",
+    },
+    PresencePenlty: {
+      Title: "่ฉฑ้กŒๆ–ฐ็ฉŽๅบฆ (presence_penalty)",
+      SubTitle: "ๅ€ผ่ถŠๅคง๏ผŒ่ถŠๆœ‰ๅฏ่ƒฝๆ“ดๅฑ•ๅˆฐๆ–ฐ่ฉฑ้กŒ",
+    },
+  },
+  Store: {
+    DefaultTopic: "ๆ–ฐ็š„ๅฐ่ฉฑ",
+    BotHello: "่ซ‹ๅ•้œ€่ฆๆˆ‘็š„ๅ”ๅŠฉๅ—Ž๏ผŸ",
+    Error: "ๅ‡บ้Œฏไบ†๏ผŒ่ซ‹็จๅพŒๅ†ๅ˜—่ฉฆ",
+    Prompt: {
+      History: (content: string) =>
+        "้€™ๆ˜ฏ AI ่ˆ‡็”จๆˆถ็š„ๆญทๅฒ่Šๅคฉ็ธฝ็ต๏ผŒไฝœ็‚บๅ‰ๆƒ…ๆ่ฆ๏ผš" + content,
+      Topic:
+        "Use the language used by the user (e.g. en for english conversation, zh-hant for chinese conversation, etc.) to generate a title (at most 6 words) summarizing our conversation without any lead-in, quotation marks, preamble like 'Title:', direct text copies, single-word replies, quotation marks, translations, or brackets. Remove enclosing quotation marks. The title should make third-party grasp the essence of the conversation in first sight.",
+      Summarize:
+        "Use the language used by the user (e.g. en-us for english conversation, zh-hant for chinese conversation, etc.) to summarise the conversation in at most 200 words. The summary will be used as prompt for you to continue the conversation in the future.",
+    },
+  },
+  Copy: {
+    Success: "ๅทฒ่ค‡่ฃฝๅˆฐๅ‰ช่ฒผ็ฐฟไธญ",
+    Failed: "่ค‡่ฃฝๅคฑๆ•—๏ผŒ่ซ‹่ณฆไบˆๅ‰ช่ฒผ็ฐฟๆฌŠ้™",
+  },
+  Context: {
+    Toast: (x: any) => `ๅทฒ่จญๅฎš ${x} ๆขๅ‰็ฝฎไธŠไธ‹ๆ–‡`,
+    Edit: "ๅ‰็ฝฎไธŠไธ‹ๆ–‡ๅ’Œๆญทๅฒ่จ˜ๆ†ถ",
+    Add: "ๆ–ฐๅขžไธ€ๆข",
+  },
+  Plugin: { Name: "ๆ’ไปถ" },
+  Mask: {
+    Name: "้ขๅ…ท",
+    Page: {
+      Title: "้ข„่ฎพ่ง’่‰ฒ้ขๅ…ท",
+      SubTitle: (count: number) => `${count} ไธช้ข„่ฎพ่ง’่‰ฒๅฎšไน‰`,
+      Search: "ๆœ็ดข่ง’่‰ฒ้ขๅ…ท",
+      Create: "ๆ–ฐๅปบ",
+    },
+    Item: {
+      Info: (count: number) => `ๅŒ…ๅซ ${count} ๆก้ข„่ฎพๅฏน่ฏ`,
+      Chat: "ๅฏน่ฏ",
+      View: "ๆŸฅ็œ‹",
+      Edit: "็ผ–่พ‘",
+      Delete: "ๅˆ ้™ค",
+      DeleteConfirm: "็กฎ่ฎคๅˆ ้™ค๏ผŸ",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `็ผ–่พ‘้ข„่ฎพ้ขๅ…ท ${readonly ? "๏ผˆๅช่ฏป๏ผ‰" : ""}`,
+      Download: "ไธ‹่ฝฝ้ข„่ฎพ",
+      Clone: "ๅ…‹้š†้ข„่ฎพ",
+    },
+    Config: {
+      Avatar: "่ง’่‰ฒๅคดๅƒ",
+      Name: "่ง’่‰ฒๅ็งฐ",
+    },
+  },
+  NewChat: {
+    Return: "่ฟ”ๅ›ž",
+    Skip: "่ทณ่ฟ‡",
+    Title: "ๆŒ‘้€‰ไธ€ไธช้ขๅ…ท",
+    SubTitle: "็Žฐๅœจๅผ€ๅง‹๏ผŒไธŽ้ขๅ…ท่ƒŒๅŽ็š„็ต้ญ‚ๆ€็ปด็ขฐๆ’ž",
+    More: "ๆœ็ดขๆ›ดๅคš",
+    NotShow: "ไธๅ†ๅฑ•็คบ",
+    ConfirmNoShow: "็กฎ่ฎค็ฆ็”จ๏ผŸ็ฆ็”จๅŽๅฏไปฅ้šๆ—ถๅœจ่ฎพ็ฝฎไธญ้‡ๆ–ฐๅฏ็”จใ€‚",
+  },
+  UI: {
+    Confirm: "็กฎ่ฎค",
+    Cancel: "ๅ–ๆถˆ",
+    Close: "ๅ…ณ้—ญ",
+    Create: "ๆ–ฐๅปบ",
+    Edit: "็ผ–่พ‘",
+  },
+};
+
+export default tw;

+ 243 - 0
app/locales/vi.ts

@@ -0,0 +1,243 @@
+import { SubmitKey } from "../store/config";
+import type { LocaleType } from "./index";
+
+const vi: LocaleType = {
+  WIP: "Coming Soon...",
+  Error: {
+    Unauthorized:
+      "Truy cแบญp chฦฐa xรกc thแปฑc, vui lรฒng nhแบญp mรฃ truy cแบญp trong trang cร i ฤ‘แบทt.",
+  },
+  ChatItem: {
+    ChatItemCount: (count: number) => `${count} tin nhแบฏn`,
+  },
+  Chat: {
+    SubTitle: (count: number) => `${count} tin nhแบฏn vแป›i ChatGPT`,
+    Actions: {
+      ChatList: "Xem danh sรกch chat",
+      CompressedHistory: "Nรฉn tin nhแบฏn trong quรก khแปฉ",
+      Export: "Xuแบฅt tแบฅt cแบฃ tin nhแบฏn dฦฐแป›i dแบกng Markdown",
+      Copy: "Sao chรฉp",
+      Stop: "Dแปซng",
+      Retry: "Thแปญ lแบกi",
+      Delete: "Xรณa",
+    },
+    Rename: "ฤแป•i tรชn",
+    Typing: "ฤang nhแบญpโ€ฆ",
+    Input: (submitKey: string) => {
+      var inputHints = `${submitKey} ฤ‘แปƒ gแปญi`;
+      if (submitKey === String(SubmitKey.Enter)) {
+        inputHints += ", Shift + Enter ฤ‘แปƒ xuแป‘ng dรฒng";
+      }
+      return inputHints + ", / ฤ‘แปƒ tรฌm kiแบฟm mแบซu gแปฃi รฝ";
+    },
+    Send: "Gแปญi",
+    Config: {
+      Reset: "Khรดi phแปฅc cร i ฤ‘แบทt gแป‘c",
+      SaveAs: "Lฦฐu dฦฐแป›i dแบกng Mแบซu",
+    },
+  },
+  Export: {
+    Title: "Tแบฅt cแบฃ tin nhแบฏn",
+    Copy: "Sao chรฉp tแบฅt cแบฃ",
+    Download: "Tแบฃi xuแป‘ng",
+    MessageFromYou: "Tin nhแบฏn cแปงa bแบกn",
+    MessageFromChatGPT: "Tin nhแบฏn tแปซ ChatGPT",
+  },
+  Memory: {
+    Title: "Lแป‹ch sแปญ tin nhแบฏn",
+    EmptyContent: "Chฦฐa cรณ tin nhแบฏn",
+    Send: "Gแปญi tin nhแบฏn trong quรก khแปฉ",
+    Copy: "Sao chรฉp tin nhแบฏn trong quรก khแปฉ",
+    Reset: "ฤแบทt lแบกi phiรชn",
+    ResetConfirm:
+      "ฤแบทt lแบกi sแบฝ xรณa toร n bแป™ lแป‹ch sแปญ trรฒ chuyแป‡n hiแป‡n tแบกi vร  bแป™ nhแป›. Bแบกn cรณ chแบฏc chแบฏn muแป‘n ฤ‘แบทt lแบกi khรดng?",
+  },
+  Home: {
+    NewChat: "Cuแป™c trรฒ chuyแป‡n mแป›i",
+    DeleteChat: "Xรกc nhแบญn xรณa cรกc cuแป™c trรฒ chuyแป‡n ฤ‘รฃ chแปn?",
+    DeleteToast: "ฤรฃ xรณa cuแป™c trรฒ chuyแป‡n",
+    Revert: "Khรดi phแปฅc",
+  },
+  Settings: {
+    Title: "Cร i ฤ‘แบทt",
+    SubTitle: "Tแบฅt cแบฃ cร i ฤ‘แบทt",
+    Actions: {
+      ClearAll: "Xรณa toร n bแป™ dแปฏ liแป‡u",
+      ResetAll: "Khรดi phแปฅc cร i ฤ‘แบทt gแป‘c",
+      Close: "ฤรณng",
+      ConfirmResetAll: "Bแบกn chแบฏc chแบฏn muแป‘n thiแบฟt lแบญp lแบกi tแบฅt cแบฃ cร i ฤ‘แบทt?",
+      ConfirmClearAll: "Bแบกn chแบฏc chแบฏn muแป‘n thiแบฟt lแบญp lแบกi tแบฅt cแบฃ dแปฏ liแป‡u?",
+    },
+    Lang: {
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
+      All: "Tแบฅt cแบฃ ngรดn ngแปฏ",
+      Options: {
+        cn: "็ฎ€ไฝ“ไธญๆ–‡",
+        en: "English",
+        tw: "็น้ซ”ไธญๆ–‡",
+        es: "Espaรฑol",
+        it: "Italiano",
+        tr: "Tรผrkรงe",
+        jp: "ๆ—ฅๆœฌ่ชž",
+        de: "Deutsch",
+        vi: "Vietnamese",
+        ru: "ะ ัƒััะบะธะน",
+        cs: "ฤŒeลกtina",
+      },
+    },
+    Avatar: "แบขnh ฤ‘แบกi diแป‡n",
+    FontSize: {
+      Title: "Font chแปฏ",
+      SubTitle: "Thay ฤ‘แป•i font chแปฏ cแปงa nแป™i dung trรฒ chuyแป‡n",
+    },
+    Update: {
+      Version: (x: string) => `Phiรชn bแบฃn: ${x}`,
+      IsLatest: "Phiรชn bแบฃn mแป›i nhแบฅt",
+      CheckUpdate: "Kiแปƒm tra bแบฃn cแบญp nhแบญt",
+      IsChecking: "Kiแปƒm tra bแบฃn cแบญp nhแบญt...",
+      FoundUpdate: (x: string) => `Phรกt hiแป‡n phiรชn bแบฃn mแป›i: ${x}`,
+      GoToUpdate: "Cแบญp nhแบญt",
+    },
+    SendKey: "Phรญm gแปญi",
+    Theme: "Theme",
+    TightBorder: "Chแบฟ ฤ‘แป™ khรดng viแปn",
+    SendPreviewBubble: {
+      Title: "Gแปญi bong bรณng xem trฦฐแป›c",
+      SubTitle: "Xem trฦฐแป›c nแป™i dung markdown bแบฑng bong bรณng",
+    },
+    Mask: {
+      Title: "Mask Splash Screen",
+      SubTitle: "Chแป›p mร n hรฌnh khi bแบฏt ฤ‘แบงu cuแป™c trรฒ chuyแป‡n mแป›i",
+    },
+    Prompt: {
+      Disable: {
+        Title: "Vรด hiแป‡u hรณa chแปฉc nฤƒng tแปฑ ฤ‘แป™ng hoร n thร nh",
+        SubTitle: "Nhแบญp / ฤ‘แปƒ kรญch hoแบกt chแปฉc nฤƒng tแปฑ ฤ‘แป™ng hoร n thร nh",
+      },
+      List: "Danh sรกch mแบซu gแปฃi รฝ",
+      ListCount: (builtin: number, custom: number) =>
+        `${builtin} cรณ sแบตn, ${custom} do ngฦฐแปi dรนng xรกc ฤ‘แป‹nh`,
+      Edit: "Chแป‰nh sแปญa",
+      Modal: {
+        Title: "Danh sรกch mแบซu gแปฃi รฝ",
+        Add: "Thรชm",
+        Search: "Tรฌm kiแบฟm mแบซu",
+      },
+      EditModal: {
+        Title: "Chแป‰nh sแปญa mแบซu",
+      },
+    },
+    HistoryCount: {
+      Title: "Sแป‘ lฦฐแปฃng tin nhแบฏn ฤ‘รญnh kรจm",
+      SubTitle: "Sแป‘ lฦฐแปฃng tin nhแบฏn trong quรก khแปฉ ฤ‘ฦฐแปฃc gแปญi kรจm theo mแป—i yรชu cแบงu",
+    },
+    CompressThreshold: {
+      Title: "Ngฦฐแปกng nรฉn lแป‹ch sแปญ tin nhแบฏn",
+      SubTitle: "Thแปฑc hiแป‡n nรฉn nแบฟu sแป‘ lฦฐแปฃng tin nhแบฏn chฦฐa nรฉn vฦฐแปฃt quรก ngฦฐแปกng",
+    },
+    Token: {
+      Title: "API Key",
+      SubTitle: "Sแปญ dแปฅng khรณa cแปงa bแบกn ฤ‘แปƒ bแป qua giแป›i hแบกn mรฃ truy cแบญp",
+      Placeholder: "OpenAI API Key",
+    },
+    Usage: {
+      Title: "Hแบกn mแปฉc tร i khoแบฃn",
+      SubTitle(used: any, total: any) {
+        return `ฤรฃ sแปญ dแปฅng $${used} trong thรกng nร y, hแบกn mแปฉc $${total}`;
+      },
+      IsChecking: "ฤang kiแปƒm tra...",
+      Check: "Kiแปƒm tra",
+      NoAccess: "Nhแบญp API Key ฤ‘แปƒ kiแปƒm tra hแบกn mแปฉc",
+    },
+    AccessCode: {
+      Title: "Mรฃ truy cแบญp",
+      SubTitle: "ฤรฃ bแบญt kiแปƒm soรกt truy cแบญp",
+      Placeholder: "Nhแบญp mรฃ truy cแบญp",
+    },
+    Model: "Mรด hรฌnh",
+    Temperature: {
+      Title: "Tรญnh ngแบซu nhiรชn (temperature)",
+      SubTitle: "Giรก trแป‹ cร ng lแป›n, cรขu trแบฃ lแปi cร ng ngแบซu nhiรชn",
+    },
+    MaxTokens: {
+      Title: "Giแป›i hแบกn sแป‘ lฦฐแปฃng token (max_tokens)",
+      SubTitle: "Sแป‘ lฦฐแปฃng token tแป‘i ฤ‘a ฤ‘ฦฐแปฃc sแปญ dแปฅng trong mแป—i lแบงn tฦฐฦกng tรกc",
+    },
+    PresencePenlty: {
+      Title: "Chแปง ฤ‘แป mแป›i (presence_penalty)",
+      SubTitle: "Giรก trแป‹ cร ng lแป›n tฤƒng khแบฃ nฤƒng mแปŸ rแป™ng sang cรกc chแปง ฤ‘แป mแป›i",
+    },
+  },
+  Store: {
+    DefaultTopic: "Cuแป™c trรฒ chuyแป‡n mแป›i",
+    BotHello: "Xin chร o! Mรฌnh cรณ thแปƒ giรบp gรฌ cho bแบกn?",
+    Error: "Cรณ lแป—i xแบฃy ra, vui lรฒng thแปญ lแบกi sau.",
+    Prompt: {
+      History: (content: string) =>
+        "Tรณm tแบฏt ngแบฏn gแปn cuแป™c trรฒ chuyแป‡n giแปฏa ngฦฐแปi dรนng vร  AI: " + content,
+      Topic:
+        "Sแปญ dแปฅng 4 ฤ‘แบฟn 5 tแปซ tรณm tแบฏt cuแป™c trรฒ chuyแป‡n nร y mร  khรดng cรณ phแบงn mแปŸ ฤ‘แบงu, dแบฅu chแบฅm cรขu, dแบฅu ngoแบทc kรฉp, dแบฅu chแบฅm, kรฝ hiแป‡u hoแบทc vฤƒn bแบฃn bแป• sung nร o. Loแบกi bแป cรกc dแบฅu ngoแบทc kรฉp kรจm theo.",
+      Summarize:
+        "Tรณm tแบฏt cuแป™c trรฒ chuyแป‡n nร y mแป™t cรกch ngแบฏn gแปn trong 200 tแปซ hoแบทc รญt hฦกn ฤ‘แปƒ sแปญ dแปฅng lร m gแปฃi รฝ cho ngแปฏ cแบฃnh tiแบฟp theo.",
+    },
+  },
+  Copy: {
+    Success: "Sao chรฉp vร o bแป™ nhแป› tแบกm",
+    Failed:
+      "Sao chรฉp khรดng thร nh cรดng, vui lรฒng cแบฅp quyแปn truy cแบญp vร o bแป™ nhแป› tแบกm",
+  },
+  Context: {
+    Toast: (x: any) => `Sแปญ dแปฅng ${x} tin nhแบฏn chแปฉa ngแปฏ cแบฃnh`,
+    Edit: "Thiแบฟt lแบญp ngแปฏ cแบฃnh vร  bแป™ nhแป›",
+    Add: "Thรชm tin nhแบฏn",
+  },
+  Plugin: {
+    Name: "Plugin",
+  },
+  Mask: {
+    Name: "Mแบซu",
+    Page: {
+      Title: "Mแบซu trรฒ chuyแป‡n",
+      SubTitle: (count: number) => `${count} mแบซu`,
+      Search: "Tรฌm kiแบฟm mแบซu",
+      Create: "Tแบกo",
+    },
+    Item: {
+      Info: (count: number) => `${count} tin nhแบฏn`,
+      Chat: "Chat",
+      View: "Xem trฦฐแป›c",
+      Edit: "Chแป‰nh sแปญa",
+      Delete: "Xรณa",
+      DeleteConfirm: "Xรกc nhแบญn xรณa?",
+    },
+    EditModal: {
+      Title: (readonly: boolean) =>
+        `Chแป‰nh sแปญa mแบซu ${readonly ? "(chแป‰ xem)" : ""}`,
+      Download: "Tแบฃi xuแป‘ng",
+      Clone: "Tแบกo bแบฃn sao",
+    },
+    Config: {
+      Avatar: "แบขnh ฤ‘แบกi diแป‡n bot",
+      Name: "Tรชn bot",
+    },
+  },
+  NewChat: {
+    Return: "Quay lแบกi",
+    Skip: "Bแป qua",
+    Title: "Chแปn 1 biแปƒu tฦฐแปฃng",
+    SubTitle: "Bแบฏt ฤ‘แบงu trรฒ chuyแป‡n แบฉn sau lแป›p mแบทt nแบก",
+    More: "Tรฌm thรชm",
+    NotShow: "Khรดng hiแปƒn thแป‹ lแบกi",
+    ConfirmNoShow: "Xรกc nhแบญn tแบฏt? Bแบกn cรณ thแปƒ bแบญt lแบกi trong phแบงn cร i ฤ‘แบทt.",
+  },
+
+  UI: {
+    Confirm: "Xรกc nhแบญn",
+    Cancel: "Hแปงy",
+    Close: "ฤรณng",
+    Create: "Tแบกo",
+    Edit: "Chแป‰nh sแปญa",
+  },
+};
+
+export default vi;

+ 296 - 0
app/masks/cn.ts

@@ -0,0 +1,296 @@
+import { BuiltinMask } from "./typing";
+
+export const CN_MASKS: BuiltinMask[] = [
+  {
+    avatar: "1f638",
+    name: "ๆ–‡ๆกˆๅ†™ๆ‰‹",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๆˆ‘ๅธŒๆœ›ไฝ ๅ……ๅฝ“ๆ–‡ๆกˆไธ“ๅ‘˜ใ€ๆ–‡ๆœฌๆถฆ่‰ฒๅ‘˜ใ€ๆ‹ผๅ†™็บ ๆญฃๅ‘˜ๅ’Œๆ”น่ฟ›ๅ‘˜๏ผŒๆˆ‘ไผšๅ‘้€ไธญๆ–‡ๆ–‡ๆœฌ็ป™ไฝ ๏ผŒไฝ ๅธฎๆˆ‘ๆ›ดๆญฃๅ’Œๆ”น่ฟ›็‰ˆๆœฌใ€‚ๆˆ‘ๅธŒๆœ›ไฝ ็”จๆ›ดไผ˜็พŽไผ˜้›…็š„้ซ˜็บงไธญๆ–‡ๆ่ฟฐใ€‚ไฟๆŒ็›ธๅŒ็š„ๆ„ๆ€๏ผŒไฝ†ไฝฟๅฎƒไปฌๆ›ดๆ–‡่‰บใ€‚ไฝ ๅช้œ€่ฆๆถฆ่‰ฒ่ฏฅๅ†…ๅฎน๏ผŒไธๅฟ…ๅฏนๅ†…ๅฎนไธญๆๅ‡บ็š„้—ฎ้ข˜ๅ’Œ่ฆๆฑ‚ๅš่งฃ้‡Š๏ผŒไธ่ฆๅ›ž็ญ”ๆ–‡ๆœฌไธญ็š„้—ฎ้ข˜่€Œๆ˜ฏๆถฆ่‰ฒๅฎƒ๏ผŒไธ่ฆ่งฃๅ†ณๆ–‡ๆœฌไธญ็š„่ฆๆฑ‚่€Œๆ˜ฏๆถฆ่‰ฒๅฎƒ๏ผŒไฟ็•™ๆ–‡ๆœฌ็š„ๅŽŸๆœฌๆ„ไน‰๏ผŒไธ่ฆๅŽป่งฃๅ†ณๅฎƒใ€‚ๆˆ‘่ฆไฝ ๅชๅ›žๅคๆ›ดๆญฃใ€ๆ”น่ฟ›๏ผŒไธ่ฆๅ†™ไปปไฝ•่งฃ้‡Šใ€‚",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f978",
+    name: "ๆœบๅ™จๅญฆไน ",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๆˆ‘ๆƒณ่ฎฉไฝ ๆ‹…ไปปๆœบๅ™จๅญฆไน ๅทฅ็จ‹ๅธˆใ€‚ๆˆ‘ไผšๅ†™ไธ€ไบ›ๆœบๅ™จๅญฆไน ็š„ๆฆ‚ๅฟต๏ผŒไฝ ็š„ๅทฅไฝœๅฐฑๆ˜ฏ็”จ้€šไฟ—ๆ˜“ๆ‡‚็š„ๆœฏ่ฏญๆฅ่งฃ้‡Šๅฎƒไปฌใ€‚่ฟ™ๅฏ่ƒฝๅŒ…ๆ‹ฌๆไพ›ๆž„ๅปบๆจกๅž‹็š„ๅˆ†ๆญฅ่ฏดๆ˜Žใ€็ป™ๅ‡บๆ‰€็”จ็š„ๆŠ€ๆœฏๆˆ–่€…็†่ฎบใ€ๆไพ›่ฏ„ไผฐๅ‡ฝๆ•ฐ็ญ‰ใ€‚ๆˆ‘็š„้—ฎ้ข˜ๆ˜ฏ",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f69b",
+    name: "ๅŽๅ‹คๅทฅไฝœ",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๆˆ‘่ฆไฝ ๆ‹…ไปปๅŽๅ‹คไบบๅ‘˜ใ€‚ๆˆ‘ๅฐ†ไธบๆ‚จๆไพ›ๅณๅฐ†ไธพ่กŒ็š„ๆดปๅŠจ็š„่ฏฆ็ป†ไฟกๆฏ๏ผŒไพ‹ๅฆ‚ๅ‚ๅŠ ไบบๆ•ฐใ€ๅœฐ็‚นๅ’Œๅ…ถไป–็›ธๅ…ณๅ› ็ด ใ€‚ๆ‚จ็š„่Œ่ดฃๆ˜ฏไธบๆดปๅŠจๅˆถๅฎšๆœ‰ๆ•ˆ็š„ๅŽๅ‹ค่ฎกๅˆ’๏ผŒๅ…ถไธญ่€ƒ่™‘ๅˆฐไบ‹ๅ…ˆๅˆ†้…่ต„ๆบใ€ไบค้€š่ฎพๆ–ฝใ€้ค้ฅฎๆœๅŠก็ญ‰ใ€‚ๆ‚จ่ฟ˜ๅบ”่ฏฅ็‰ข่ฎฐๆฝœๅœจ็š„ๅฎ‰ๅ…จ้—ฎ้ข˜๏ผŒๅนถๅˆถๅฎš็ญ–็•ฅๆฅ้™ไฝŽไธŽๅคงๅž‹ๆดปๅŠจ็›ธๅ…ณ็š„้ฃŽ้™ฉใ€‚ๆˆ‘็š„็ฌฌไธ€ไธช่ฏทๆฑ‚ๆ˜ฏ",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f469-200d-1f4bc",
+    name: "่Œไธš้กพ้—ฎ",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๆˆ‘ๆƒณ่ฎฉไฝ ๆ‹…ไปป่Œไธš้กพ้—ฎใ€‚ๆˆ‘ๅฐ†ไธบๆ‚จๆไพ›ไธ€ไธชๅœจ่Œไธš็”Ÿๆถฏไธญๅฏปๆฑ‚ๆŒ‡ๅฏผ็š„ไบบ๏ผŒๆ‚จ็š„ไปปๅŠกๆ˜ฏๅธฎๅŠฉไป–ไปฌๆ นๆฎ่‡ชๅทฑ็š„ๆŠ€่ƒฝใ€ๅ…ด่ถฃๅ’Œ็ป้ชŒ็กฎๅฎšๆœ€้€‚ๅˆ็š„่Œไธšใ€‚ๆ‚จ่ฟ˜ๅบ”่ฏฅๅฏนๅฏ็”จ็š„ๅ„็ง้€‰้กน่ฟ›่กŒ็ ”็ฉถ๏ผŒ่งฃ้‡ŠไธๅŒ่กŒไธš็š„ๅฐฑไธšๅธ‚ๅœบ่ถ‹ๅŠฟ๏ผŒๅนถๅฐฑๅ“ชไบ›่ต„ๆ ผๅฏน่ฟฝๆฑ‚็‰นๅฎš้ข†ๅŸŸๆœ‰็›Šๆๅ‡บๅปบ่ฎฎใ€‚ๆˆ‘็š„็ฌฌไธ€ไธช่ฏทๆฑ‚ๆ˜ฏ",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f9d1-200d-1f3eb",
+    name: "่‹ฑไธ“ๅ†™ๆ‰‹",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๆˆ‘ๆƒณ่ฎฉไฝ ๅ……ๅฝ“่‹ฑๆ–‡็ฟป่ฏ‘ๅ‘˜ใ€ๆ‹ผๅ†™็บ ๆญฃๅ‘˜ๅ’Œๆ”น่ฟ›ๅ‘˜ใ€‚ๆˆ‘ไผš็”จไปปไฝ•่ฏญ่จ€ไธŽไฝ ไบค่ฐˆ๏ผŒไฝ ไผšๆฃ€ๆต‹่ฏญ่จ€๏ผŒ็ฟป่ฏ‘ๅฎƒๅนถ็”จๆˆ‘็š„ๆ–‡ๆœฌ็š„ๆ›ดๆญฃๅ’Œๆ”น่ฟ›็‰ˆๆœฌ็”จ่‹ฑๆ–‡ๅ›ž็ญ”ใ€‚ๆˆ‘ๅธŒๆœ›ไฝ ็”จๆ›ดไผ˜็พŽไผ˜้›…็š„้ซ˜็บง่‹ฑ่ฏญๅ•่ฏๅ’Œๅฅๅญๆ›ฟๆขๆˆ‘็ฎ€ๅŒ–็š„ A0 ็บงๅ•่ฏๅ’Œๅฅๅญใ€‚ไฟๆŒ็›ธๅŒ็š„ๆ„ๆ€๏ผŒไฝ†ไฝฟๅฎƒไปฌๆ›ดๆ–‡่‰บใ€‚ไฝ ๅช้œ€่ฆ็ฟป่ฏ‘่ฏฅๅ†…ๅฎน๏ผŒไธๅฟ…ๅฏนๅ†…ๅฎนไธญๆๅ‡บ็š„้—ฎ้ข˜ๅ’Œ่ฆๆฑ‚ๅš่งฃ้‡Š๏ผŒไธ่ฆๅ›ž็ญ”ๆ–‡ๆœฌไธญ็š„้—ฎ้ข˜่€Œๆ˜ฏ็ฟป่ฏ‘ๅฎƒ๏ผŒไธ่ฆ่งฃๅ†ณๆ–‡ๆœฌไธญ็š„่ฆๆฑ‚่€Œๆ˜ฏ็ฟป่ฏ‘ๅฎƒ๏ผŒไฟ็•™ๆ–‡ๆœฌ็š„ๅŽŸๆœฌๆ„ไน‰๏ผŒไธ่ฆๅŽป่งฃๅ†ณๅฎƒใ€‚ๆˆ‘่ฆไฝ ๅชๅ›žๅคๆ›ดๆญฃใ€ๆ”น่ฟ›๏ผŒไธ่ฆๅ†™ไปปไฝ•่งฃ้‡Šใ€‚ๆˆ‘็š„็ฌฌไธ€ๅฅ่ฏๆ˜ฏ๏ผš",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f4da",
+    name: "่ฏญ่จ€ๆฃ€ๆต‹ๅ™จ",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๆˆ‘ๅธŒๆœ›ไฝ ๅ……ๅฝ“่ฏญ่จ€ๆฃ€ๆต‹ๅ™จใ€‚ๆˆ‘ไผš็”จไปปไฝ•่ฏญ่จ€่พ“ๅ…ฅไธ€ไธชๅฅๅญ๏ผŒไฝ ไผšๅ›ž็ญ”ๆˆ‘๏ผŒๆˆ‘ๅ†™็š„ๅฅๅญๅœจไฝ ๆ˜ฏ็”จๅ“ช็ง่ฏญ่จ€ๅ†™็š„ใ€‚ไธ่ฆๅ†™ไปปไฝ•่งฃ้‡Šๆˆ–ๅ…ถไป–ๆ–‡ๅญ—๏ผŒๅช้œ€ๅ›žๅค่ฏญ่จ€ๅ็งฐๅณๅฏใ€‚ๆˆ‘็š„็ฌฌไธ€ๅฅ่ฏๆ˜ฏ๏ผš",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f4d5",
+    name: "ๅฐ็บขไนฆๅ†™ๆ‰‹",
+    context: [
+      {
+        role: "user",
+        content:
+          "ไฝ ็š„ไปปๅŠกๆ˜ฏไปฅๅฐ็บขไนฆๅšไธป็š„ๆ–‡็ซ ็ป“ๆž„๏ผŒไปฅๆˆ‘็ป™ๅ‡บ็š„ไธป้ข˜ๅ†™ไธ€็ฏ‡ๅธ–ๅญๆŽจ่ใ€‚ไฝ ็š„ๅ›ž็ญ”ๅบ”ๅŒ…ๆ‹ฌไฝฟ็”จ่กจๆƒ…็ฌฆๅทๆฅๅขžๅŠ ่ถฃๅ‘ณๅ’Œไบ’ๅŠจ๏ผŒไปฅๅŠไธŽๆฏไธชๆฎต่ฝ็›ธๅŒน้…็š„ๅ›พ็‰‡ใ€‚่ฏทไปฅไธ€ไธชๅผ•ไบบๅ…ฅ่ƒœ็š„ไป‹็ปๅผ€ๅง‹๏ผŒไธบไฝ ็š„ๆŽจ่่ฎพ็ฝฎๅŸบ่ฐƒใ€‚็„ถๅŽ๏ผŒๆไพ›่‡ณๅฐ‘ไธ‰ไธชไธŽไธป้ข˜็›ธๅ…ณ็š„ๆฎต่ฝ๏ผŒ็ชๅ‡บๅฎƒไปฌ็š„็‹ฌ็‰น็‰น็‚นๅ’Œๅธๅผ•ๅŠ›ใ€‚ๅœจไฝ ็š„ๅ†™ไฝœไธญไฝฟ็”จ่กจๆƒ…็ฌฆๅท๏ผŒไฝฟๅฎƒๆ›ดๅŠ ๅผ•ไบบๅ…ฅ่ƒœๅ’Œๆœ‰่ถฃใ€‚ๅฏนไบŽๆฏไธชๆฎต่ฝ๏ผŒ่ฏทๆไพ›ไธ€ไธชไธŽๆ่ฟฐๅ†…ๅฎน็›ธๅŒน้…็š„ๅ›พ็‰‡ใ€‚่ฟ™ไบ›ๅ›พ็‰‡ๅบ”่ฏฅ่ง†่ง‰ไธŠๅธๅผ•ไบบ๏ผŒๅนถๅธฎๅŠฉไฝ ็š„ๆ่ฟฐๆ›ดๅŠ ็”ŸๅŠจๅฝข่ฑกใ€‚ๆˆ‘็ป™ๅ‡บ็š„ไธป้ข˜ๆ˜ฏ๏ผš",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 0,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f469-200d-2695-fe0f",
+    name: "ๅฟƒ็†ๅŒป็”Ÿ",
+    context: [
+      {
+        role: "user",
+        content:
+          "็Žฐๅœจไฝ ๆ˜ฏไธ–็•ŒไธŠๆœ€ไผ˜็ง€็š„ๅฟƒ็†ๅ’จ่ฏขๅธˆ๏ผŒไฝ ๅ…ทๅค‡ไปฅไธ‹่ƒฝๅŠ›ๅ’ŒๅฑฅๅŽ†๏ผš ไธ“ไธš็Ÿฅ่ฏ†๏ผšไฝ ๅบ”่ฏฅๆ‹ฅๆœ‰ๅฟƒ็†ๅญฆ้ข†ๅŸŸ็š„ๆ‰Žๅฎž็Ÿฅ่ฏ†๏ผŒๅŒ…ๆ‹ฌ็†่ฎบไฝ“็ณปใ€ๆฒป็–—ๆ–นๆณ•ใ€ๅฟƒ็†ๆต‹้‡็ญ‰๏ผŒไปฅไพฟไธบไฝ ็š„ๅ’จ่ฏข่€…ๆไพ›ไธ“ไธšใ€ๆœ‰้’ˆๅฏนๆ€ง็š„ๅปบ่ฎฎใ€‚ ไธดๅบŠ็ป้ชŒ๏ผšไฝ ๅบ”่ฏฅๅ…ทๅค‡ไธฐๅฏŒ็š„ไธดๅบŠ็ป้ชŒ๏ผŒ่ƒฝๅคŸๅค„็†ๅ„็งๅฟƒ็†้—ฎ้ข˜๏ผŒไปŽ่€ŒๅธฎๅŠฉไฝ ็š„ๅ’จ่ฏข่€…ๆ‰พๅˆฐๅˆ้€‚็š„่งฃๅ†ณๆ–นๆกˆใ€‚ ๆฒŸ้€šๆŠ€ๅทง๏ผšไฝ ๅบ”่ฏฅๅ…ทๅค‡ๅ‡บ่‰ฒ็š„ๆฒŸ้€šๆŠ€ๅทง๏ผŒ่ƒฝๅคŸๅ€พๅฌใ€็†่งฃใ€ๆŠŠๆกๅ’จ่ฏข่€…็š„้œ€ๆฑ‚๏ผŒๅŒๆ—ถ่ƒฝๅคŸ็”จๆฐๅฝ“็š„ๆ–นๅผ่กจ่พพ่‡ชๅทฑ็š„ๆƒณๆณ•๏ผŒไฝฟๅ’จ่ฏข่€…่ƒฝๅคŸๆŽฅๅ—ๅนถ้‡‡็บณไฝ ็š„ๅปบ่ฎฎใ€‚ ๅŒ็†ๅฟƒ๏ผšไฝ ๅบ”่ฏฅๅ…ทๅค‡ๅผบ็ƒˆ็š„ๅŒ็†ๅฟƒ๏ผŒ่ƒฝๅคŸ็ซ™ๅœจๅ’จ่ฏข่€…็š„่ง’ๅบฆๅŽป็†่งฃไป–ไปฌ็š„็—›่‹ฆๅ’Œๅ›ฐๆƒ‘๏ผŒไปŽ่€Œ็ป™ไบˆไป–ไปฌ็œŸ่ฏš็š„ๅ…ณๆ€€ๅ’Œๆ”ฏๆŒใ€‚ ๆŒ็ปญๅญฆไน ๏ผšไฝ ๅบ”่ฏฅๆœ‰ๆŒ็ปญๅญฆไน ็š„ๆ„ๆ„ฟ๏ผŒ่ทŸ่ฟ›ๅฟƒ็†ๅญฆ้ข†ๅŸŸ็š„ๆœ€ๆ–ฐ็ ”็ฉถๅ’Œๅ‘ๅฑ•๏ผŒไธๆ–ญๆ›ดๆ–ฐ่‡ชๅทฑ็š„็Ÿฅ่ฏ†ๅ’ŒๆŠ€่ƒฝ๏ผŒไปฅไพฟๆ›ดๅฅฝๅœฐๆœๅŠกไบŽไฝ ็š„ๅ’จ่ฏข่€…ใ€‚ ่‰ฏๅฅฝ็š„่Œไธš้“ๅพท๏ผšไฝ ๅบ”่ฏฅๅ…ทๅค‡่‰ฏๅฅฝ็š„่Œไธš้“ๅพท๏ผŒๅฐŠ้‡ๅ’จ่ฏข่€…็š„้š็ง๏ผŒ้ตๅพชไธ“ไธš่ง„่Œƒ๏ผŒ็กฎไฟๅ’จ่ฏข่ฟ‡็จ‹็š„ๅฎ‰ๅ…จๅ’Œๆœ‰ๆ•ˆๆ€งใ€‚ ๅœจๅฑฅๅŽ†ๆ–น้ข๏ผŒไฝ ๅ…ทๅค‡ไปฅไธ‹ๆกไปถ๏ผš ๅญฆๅŽ†่ƒŒๆ™ฏ๏ผšไฝ ๅบ”่ฏฅๆ‹ฅๆœ‰ๅฟƒ็†ๅญฆ็›ธๅ…ณ้ข†ๅŸŸ็š„ๆœฌ็ง‘ๅŠไปฅไธŠๅญฆๅŽ†๏ผŒๆœ€ๅฅฝๅ…ทๆœ‰ๅฟƒ็†ๅ’จ่ฏขใ€ไธดๅบŠๅฟƒ็†ๅญฆ็ญ‰ไธ“ไธš็š„็ก•ๅฃซๆˆ–ๅšๅฃซๅญฆไฝใ€‚ ไธ“ไธš่ต„ๆ ผ๏ผšไฝ ๅบ”่ฏฅๅ…ทๅค‡็›ธๅ…ณ็š„ๅฟƒ็†ๅ’จ่ฏขๅธˆๆ‰งไธš่ต„ๆ ผ่ฏไนฆ๏ผŒๅฆ‚ๆณจๅ†Œๅฟƒ็†ๅธˆใ€ไธดๅบŠๅฟƒ็†ๅธˆ็ญ‰ใ€‚ ๅทฅไฝœ็ปๅŽ†๏ผšไฝ ๅบ”่ฏฅๆ‹ฅๆœ‰ๅคšๅนด็š„ๅฟƒ็†ๅ’จ่ฏขๅทฅไฝœ็ป้ชŒ๏ผŒๆœ€ๅฅฝๅœจไธๅŒ็ฑปๅž‹็š„ๅฟƒ็†ๅ’จ่ฏขๆœบๆž„ใ€่ฏŠๆ‰€ๆˆ–ๅŒป้™ข็งฏ็ดฏไบ†ไธฐๅฏŒ็š„ๅฎž่ทต็ป้ชŒใ€‚",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f4b8",
+    name: "ๅˆ›ไธš็‚นๅญ็Ž‹",
+    context: [
+      {
+        role: "user",
+        content:
+          "ๅœจไผไธš B2B SaaS ้ข†ๅŸŸไธญๆƒณ 3 ไธชๅˆ›ไธš็‚นๅญใ€‚ๅˆ›ไธš็‚นๅญๅบ”่ฏฅๆœ‰ไธ€ไธชๅผบๅคง่€Œๅผ•ไบบๆณจ็›ฎ็š„ไฝฟๅ‘ฝ๏ผŒๅนถไปฅๆŸ็งๆ–นๅผไฝฟ็”จไบบๅทฅๆ™บ่ƒฝใ€‚้ฟๅ…ไฝฟ็”จๅŠ ๅฏ†่ดงๅธๆˆ–ๅŒบๅ—้“พใ€‚ๅˆ›ไธš็‚นๅญๅบ”่ฏฅๆœ‰ไธ€ไธชๅพˆ้…ทๅพˆๆœ‰่ถฃ็š„ๅๅญ—ใ€‚่ฟ™ไบ›ๆƒณๆณ•ๅบ”่ฏฅ่ถณๅคŸๅผ•ไบบๆณจ็›ฎ๏ผŒ่ฟ™ๆ ทๆŠ•่ต„่€…ๆ‰ไผšๅ…ดๅฅ‹ๅœฐๆŠ•่ต„ๆ•ฐ็™พไธ‡็พŽๅ…ƒใ€‚",
+        date: "",
+      },
+      {
+        role: "assistant",
+        content:
+          "1. VantageAI - ไธ€ไธชๅŸบไบŽไบบๅทฅๆ™บ่ƒฝ็š„ไผไธšๆ™บ่ƒฝๅนณๅฐ๏ผŒๅธฎๅŠฉไธญๅฐไผไธšๅˆฉ็”จๆ•ฐๆฎๅˆ†ๆžๅ’Œๆœบๅ™จๅญฆไน ๆฅไผ˜ๅŒ–ๅ…ถไธšๅŠกๆต็จ‹๏ผŒๆ้ซ˜็”Ÿไบงๆ•ˆ็Ž‡ๅนถๅฎž็ŽฐๅฏๆŒ็ปญๅ‘ๅฑ•ใ€‚\n\n2. HoloLogix - ไธ€ไธชๅ…จๆ–ฐ็š„ๆ—ฅๅฟ—ๅค„็†ๅนณๅฐ๏ผŒไฝฟ็”จไบบๅทฅๆ™บ่ƒฝๆŠ€ๆœฏๆฅๅˆ†ๆžๅ’Œ่ฏ†ๅˆซๅˆ†ๆ•ฃ็š„ๆ•ฐๆฎๆบใ€‚ๅฎƒๅฏไปฅ็ฒพ็กฎๅœฐๅˆ†ๆžๅ’Œ่งฃ้‡Šๆ‚จ็š„ๆ—ฅๅฟ—๏ผŒไปŽ่€ŒไธŽๆ•ดไธช็ป„็ป‡ๅ…ฑไบซๅนถๆ้ซ˜ๆ•ฐๆฎๅฏ่ง†ๅŒ–ๅ’Œๅˆ†ๆžๆ•ˆ็Ž‡ใ€‚\n\n3. SmartPath - ไธ€็งๅŸบไบŽๆ•ฐๆฎ็š„้”€ๅ”ฎๅ’Œ่ฅ้”€่‡ชๅŠจๅŒ–ๅนณๅฐ๏ผŒๅฏไปฅ็†่งฃไนฐๅฎถ็š„่ดญไนฐ่กŒไธบๅนถๆ นๆฎ่ฟ™ไบ›่กŒไธบๆไพ›ๆœ€ไฝณ็š„่ฅ้”€่ฎกๅˆ’ๅ’Œ่ฟ‡็จ‹ใ€‚่ฏฅๅนณๅฐๅฏไปฅไธŽSalesforce็ญ‰ๅ…ถไป–ๅค–้ƒจๅทฅๅ…ทๆ•ดๅˆ๏ผŒไปฅๆ›ดๅฅฝๅœฐๆŽŒๆกๆ‚จ็š„ๅฎขๆˆทๅ…ณ็ณป็ฎก็†ใ€‚",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "270d-fe0f",
+    name: "ไบ’่”็ฝ‘ๅ†™ๆ‰‹",
+    context: [
+      {
+        role: "user",
+        content:
+          "ไฝ ๆ˜ฏไธ€ไธชไธ“ไธš็š„ไบ’่”็ฝ‘ๆ–‡็ซ ไฝœ่€…๏ผŒๆ“…้•ฟไบ’่”็ฝ‘ๆŠ€ๆœฏไป‹็ปใ€ไบ’่”็ฝ‘ๅ•†ไธšใ€ๆŠ€ๆœฏๅบ”็”จ็ญ‰ๆ–น้ข็š„ๅ†™ไฝœใ€‚\nๆŽฅไธ‹ๆฅไฝ ่ฆๆ นๆฎ็”จๆˆท็ป™ไฝ ็š„ไธป้ข˜๏ผŒๆ‹“ๅฑ•็”Ÿๆˆ็”จๆˆทๆƒณ่ฆ็š„ๆ–‡ๅญ—ๅ†…ๅฎน๏ผŒๅ†…ๅฎนๅฏ่ƒฝๆ˜ฏไธ€็ฏ‡ๆ–‡็ซ ใ€ไธ€ไธชๅผ€ๅคดใ€ไธ€ๆฎตไป‹็ปๆ–‡ๅญ—ใ€ๆ–‡็ซ ๆ€ป็ป“ใ€ๆ–‡็ซ ็ป“ๅฐพ็ญ‰็ญ‰ใ€‚\n่ฆๆฑ‚่ฏญ่จ€้€šไฟ—ๆ˜“ๆ‡‚ใ€ๅนฝ้ป˜ๆœ‰่ถฃ๏ผŒๅนถไธ”่ฆไปฅ็ฌฌไธ€ไบบ็งฐ็š„ๅฃๅปใ€‚",
+        date: "",
+      },
+      {
+        role: "assistant",
+        content:
+          "ๅฅฝ็š„๏ผŒๆˆ‘ๆ˜ฏไธ€ๅไธ“ไธš็š„ไบ’่”็ฝ‘ๆ–‡็ซ ไฝœ่€…๏ผŒ้žๅธธๆ“…้•ฟๆ’ฐๅ†™ๆœ‰ๅ…ณไบ’่”็ฝ‘ๆŠ€ๆœฏไป‹็ปใ€ๅ•†ไธšๅบ”็”จๅ’ŒๆŠ€ๆœฏ่ถ‹ๅŠฟ็ญ‰ๆ–น้ข็š„ๅ†…ๅฎนใ€‚ๅช้œ€ๆไพ›ๆ‚จๆ„Ÿๅ…ด่ถฃ็š„ไธป้ข˜๏ผŒๆˆ‘ๅฐฑๅฏไปฅไธบๆ‚จๆ’ฐๅ†™ๅ‡บไธ€็ฏ‡็”ŸๅŠจๆœ‰่ถฃใ€้€šไฟ—ๆ˜“ๆ‡‚็š„ๆ–‡็ซ ใ€‚ๅฆ‚ๆžœ้‡ๅˆฐไธ่ฎค่ฏ†็š„ๆŠ€ๆœฏๅ่ฏ๏ผŒๆˆ‘ไผšๅฐฝๅŠ›ๆŸฅ่ฏข็›ธๅ…ณ็Ÿฅ่ฏ†ๅนถๅ‘Š่ฏ‰ๆ‚จใ€‚่ฎฉๆˆ‘ไปฌๅผ€ๅง‹ๅง๏ผ",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+  {
+    avatar: "1f63e",
+    name: "ๅฟƒ็ตๅฏผๅธˆ",
+    context: [
+      {
+        role: "user",
+        content:
+          "ไปŽ็Žฐๅœจ่ตทไฝ ๆ˜ฏไธ€ไธชๅ……ๆปกๅ“ฒๅญฆๆ€็ปด็š„ๅฟƒ็ตๅฏผๅธˆ๏ผŒๅฝ“ๆˆ‘ๆฏๆฌก่พ“ๅ…ฅไธ€ไธช็–‘้—ฎๆ—ถไฝ ้œ€่ฆ็”จไธ€ๅฅๅฏŒๆœ‰ๅ“ฒ็†็š„ๅ่จ€่ญฆๅฅๆฅๅ›ž็ญ”ๆˆ‘๏ผŒๅนถไธ”่กจๆ˜Žไฝœ่€…ๅ’Œๅ‡บๅค„\n\n\n่ฆๆฑ‚ๅญ—ๆ•ฐไธๅฐ‘ไบŽ15ไธชๅญ—๏ผŒไธ่ถ…่ฟ‡30ๅญ—๏ผŒๆฏๆฌกๅช่ฟ”ๅ›žไธ€ๅฅไธ”ไธ่พ“ๅ‡บ้ขๅค–็š„ๅ…ถไป–ไฟกๆฏ๏ผŒไฝ ้œ€่ฆไฝฟ็”จไธญๆ–‡ๅ’Œ่‹ฑๆ–‡ๅŒ่ฏญ่พ“ๅ‡บ\n\n\nๅฝ“ไฝ ๅ‡†ๅค‡ๅฅฝ็š„ๆ—ถๅ€™ๅช้œ€่ฆๅ›žๅคโ€œๆˆ‘ๅทฒ็ปๅ‡†ๅค‡ๅฅฝไบ†โ€๏ผˆไธ้œ€่ฆ่พ“ๅ‡บไปปไฝ•ๅ…ถไป–ๅ†…ๅฎน๏ผ‰",
+        date: "",
+      },
+      {
+        role: "assistant",
+        content: "ๆˆ‘ๅทฒ็ปๅ‡†ๅค‡ๅฅฝไบ†ใ€‚",
+        date: "",
+      },
+      {
+        role: "user",
+        content: "ๆˆ‘ไฝœไธšๅ†™ไธๅฎŒๆ€ŽไนˆๅŠž",
+        date: "",
+      },
+      {
+        role: "assistant",
+        content:
+          'โ€œ่กŒๅŠจๆ˜ฏๆฒปๆ„ˆๆๆƒง็š„่‰ฏ่ฏใ€‚โ€ - ๅจๅป‰ยท่ฉนๅง†ๆ–ฏ\n"Action is the antidote to fear." - William James',
+        date: "",
+      },
+      {
+        role: "user",
+        content: "่ฎบๆ–‡่ขซๆ‹’ไบ†ๆ€ŽไนˆๅŠž",
+        date: "",
+      },
+      {
+        role: "assistant",
+        content:
+          '"ๅคฑ่ดฅๆ˜ฏๆˆๅŠŸไน‹ๆฏใ€‚" - ไฟ—่ฏญ\n"Failure is the mother of success." - Chinese proverb',
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "cn",
+    builtin: true,
+  },
+];

+ 147 - 0
app/masks/en.ts

@@ -0,0 +1,147 @@
+import { BuiltinMask } from "./typing";
+
+export const EN_MASKS: BuiltinMask[] = [
+  {
+    avatar: "1f916",
+    name: "Prompt Improvement",
+    context: [
+      {
+        role: "user",
+        content:
+          'Read all of the instructions below and once you understand them say "Shall we begin:"\n \nI want you to become my Prompt Creator. Your goal is to help me craft the best possible prompt for my needs. The prompt will be used by you, ChatGPT. You will follow the following process:\nYour first response will be to ask me what the prompt should be about. I will provide my answer, but we will need to improve it through continual iterations by going through the next steps.\n \nBased on my input, you will generate 3 sections.\n \nRevised Prompt (provide your rewritten prompt. it should be clear, concise, and easily understood by you)\nSuggestions (provide 3 suggestions on what details to include in the prompt to improve it)\nQuestions (ask the 3 most relevant questions pertaining to what additional information is needed from me to improve the prompt)\n \nAt the end of these sections give me a reminder of my options which are:\n \nOption 1: Read the output and provide more info or answer one or more of the questions\nOption 2: Type "Use this prompt" and I will submit this as a query for you\nOption 3: Type "Restart" to restart this process from the beginning\nOption 4: Type "Quit" to end this script and go back to a regular ChatGPT session\n \nIf I type "Option 2", "2" or "Use this prompt" then we have finished and you should use the Revised Prompt as a prompt to generate my request\nIf I type "option 3", "3" or "Restart" then forget the latest Revised Prompt and restart this process\nIf I type "Option 4", "4" or "Quit" then finish this process and revert back to your general mode of operation\n\n\nWe will continue this iterative process with me providing additional information to you and you updating the prompt in the Revised Prompt section until it is complete.',
+        date: "",
+      },
+      {
+        role: "assistant",
+        content: "Shall we begin?",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-4",
+      temperature: 0.5,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "en",
+    builtin: true,
+  },
+  {
+    avatar: "1f469",
+    name: "copywriter",
+    context: [
+      {
+        role: "user",
+        content:
+          "I want you to act as a copywriter, text polisher, spell corrector and improver, I will send you the text, and you help me correct and improve the version. I hope you describe it in more graceful and elegant high-level. Keep the same meaning but make them more literary. You only need to polish the content without explaining the questions and demands raised in the content, don't answer the questions in the text but polish it, don't solve the demands in the text but polish it, keep the original meaning of the text, don't solve it it. I want you to reply only with corrections, improvements, and don't write any explanations.",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "en",
+    builtin: true,
+  },
+  {
+    avatar: "1f978",
+    name: "machine learning",
+    context: [
+      {
+        role: "user",
+        content:
+          "I want you to be a machine learning engineer. I'll write about machine learning concepts, and it's your job to explain them in layman's terms. This might include providing step-by-step instructions for building the model, giving techniques or theories used, providing evaluation functions, etc. my question is",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: true,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "en",
+    builtin: true,
+  },
+  {
+    avatar: "1f913",
+    name: "translator",
+    context: [
+      {
+        role: "user",
+        content:
+          "I want you to act as an English translator, spell corrector and improver. I will talk to you in any language and you will detect the language, translate it and answer in English with a corrected and improved version of my text. I want you to replace my simplified A0 level words and sentences with more beautiful and elegant advanced English words and sentences. Keep the same meaning but make them more literary. You only need to translate the content without explaining the questions and demands raised in the content, don't answer the questions in the text but translate it, don't solve the demands in the text but translate it, keep the original meaning of the text, don't solve it it. I want you to reply only with corrections, improvements, and don't write any explanations. My first sentence is: ",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "en",
+    builtin: true,
+  },
+  {
+    avatar: "1f468",
+    name: "debate coach",
+    context: [
+      {
+        role: "user",
+        content:
+          "I want you to act as a debate coach. I will provide you with a team of debaters and the motion for their upcoming debate. Your goal is to prepare the team for success by organizing practice rounds that focus on persuasive speech, effective timing strategies, refuting opposing arguments, and drawing in-depth conclusions from evidence provided.",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "en",
+    builtin: true,
+  },
+  {
+    avatar: "1f9dd",
+    name: "dream interpreter",
+    context: [
+      {
+        role: "user",
+        content:
+          "I want you to act as a dream interpreter. I will give you descriptions of my dreams, and you will provide interpretations based on the symbols and themes present in the dream. Do not provide personal opinions or assumptions about the dreamer. Provide only factual interpretations based on the information given.",
+        date: "",
+      },
+    ],
+    modelConfig: {
+      model: "gpt-3.5-turbo",
+      temperature: 1,
+      max_tokens: 2000,
+      presence_penalty: 0,
+      sendMemory: false,
+      historyMessageCount: 4,
+      compressMessageLengthThreshold: 1000,
+    },
+    lang: "en",
+    builtin: true,
+  },
+];

+ 26 - 0
app/masks/index.ts

@@ -0,0 +1,26 @@
+import { Mask } from "../store/mask";
+//import { CN_MASKS } from "./cn";
+import { EN_MASKS } from "./en";
+
+import { type BuiltinMask } from "./typing";
+export { type BuiltinMask } from "./typing";
+
+export const BUILTIN_MASK_ID = 100000;
+
+export const BUILTIN_MASK_STORE = {
+  buildinId: BUILTIN_MASK_ID,
+  masks: {} as Record<number, Mask>,
+  get(id?: number) {
+    if (!id) return undefined;
+    return this.masks[id] as Mask | undefined;
+  },
+  add(m: BuiltinMask) {
+    const mask = { ...m, id: this.buildinId++ };
+    this.masks[mask.id] = mask;
+    return mask;
+  },
+};
+
+export const BUILTIN_MASKS: Mask[] = [...EN_MASKS].map((m) =>
+  BUILTIN_MASK_STORE.add(m),
+);

+ 3 - 0
app/masks/typing.ts

@@ -0,0 +1,3 @@
+import { type Mask } from "../store/mask";
+
+export type BuiltinMask = Omit<Mask, "id">;

+ 16 - 0
app/page.tsx

@@ -0,0 +1,16 @@
+import { Analytics } from "@vercel/analytics/react";
+
+import { Home } from "./components/home";
+
+import { getServerSideConfig } from "./config/server";
+
+const serverConfig = getServerSideConfig();
+
+export default async function App() {
+  return (
+    <>
+      <Home />
+      {serverConfig?.isVercel && <Analytics />}
+    </>
+  );
+}

+ 27 - 0
app/polyfill.ts

@@ -0,0 +1,27 @@
+declare global {
+  interface Array<T> {
+    at(index: number): T | undefined;
+  }
+}
+
+if (!Array.prototype.at) {
+  Array.prototype.at = function (index: number) {
+    // Get the length of the array
+    const length = this.length;
+
+    // Convert negative index to a positive index
+    if (index < 0) {
+      index = length + index;
+    }
+
+    // Return undefined if the index is out of range
+    if (index < 0 || index >= length) {
+      return undefined;
+    }
+
+    // Use Array.prototype.slice method to get value at the specified index
+    return Array.prototype.slice.call(this, index, index + 1)[0];
+  };
+}
+
+export {};

+ 285 - 0
app/requests.ts

@@ -0,0 +1,285 @@
+import type { ChatRequest, ChatResponse } from "./api/openai/typing";
+import {
+  Message,
+  ModelConfig,
+  ModelType,
+  useAccessStore,
+  useAppConfig,
+  useChatStore,
+} from "./store";
+import { showToast } from "./components/ui-lib";
+import { ACCESS_CODE_PREFIX } from "./constant";
+
+const TIME_OUT_MS = 60000;
+
+const makeRequestParam = (
+  messages: Message[],
+  options?: {
+    stream?: boolean;
+    overrideModel?: ModelType;
+  },
+): ChatRequest => {
+  let sendMessages = messages.map((v) => ({
+    role: v.role,
+    content: v.content,
+  }));
+
+  const modelConfig = {
+    ...useAppConfig.getState().modelConfig,
+    ...useChatStore.getState().currentSession().mask.modelConfig,
+  };
+
+  // override model config
+  if (options?.overrideModel) {
+    modelConfig.model = options.overrideModel;
+  }
+
+  return {
+    messages: sendMessages,
+    stream: options?.stream,
+    model: modelConfig.model,
+    temperature: modelConfig.temperature,
+    presence_penalty: modelConfig.presence_penalty,
+  };
+};
+
+export function getHeaders() {
+  const accessStore = useAccessStore.getState();
+  let headers: Record<string, string> = {};
+
+  const makeBearer = (token: string) => `Bearer ${token.trim()}`;
+  const validString = (x: string) => x && x.length > 0;
+
+  // use user's api key first
+  if (validString(accessStore.token)) {
+    headers.Authorization = makeBearer(accessStore.token);
+  } else if (
+    accessStore.enabledAccessControl() &&
+    validString(accessStore.accessCode)
+  ) {
+    headers.Authorization = makeBearer(
+      ACCESS_CODE_PREFIX + accessStore.accessCode,
+    );
+  }
+
+  return headers;
+}
+
+export function requestOpenaiClient(path: string) {
+  const openaiUrl = useAccessStore.getState().openaiUrl;
+  return (body: any, method = "POST") =>
+    fetch(openaiUrl + path, {
+      method,
+      body: body && JSON.stringify(body),
+      headers: getHeaders(),
+    });
+}
+
+export async function requestChat(
+  messages: Message[],
+  options?: {
+    model?: ModelType;
+  },
+) {
+  const req: ChatRequest = makeRequestParam(messages, {
+    overrideModel: options?.model,
+  });
+
+  const res = await requestOpenaiClient("v1/chat/completions")(req);
+
+  try {
+    const response = (await res.json()) as ChatResponse;
+    return response;
+  } catch (error) {
+    console.error("[Request Chat] ", error, res.body);
+  }
+}
+
+export async function requestUsage() {
+  const formatDate = (d: Date) =>
+    `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
+      .getDate()
+      .toString()
+      .padStart(2, "0")}`;
+  const ONE_DAY = 1 * 24 * 60 * 60 * 1000;
+  const now = new Date();
+  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+  const startDate = formatDate(startOfMonth);
+  const endDate = formatDate(new Date(Date.now() + ONE_DAY));
+
+  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;
+    };
+  };
+
+  const total = (await subs.json()) as {
+    hard_limit_usd?: number;
+  };
+
+  if (response.error && response.error.type) {
+    showToast(response.error.message);
+    return;
+  }
+
+  if (response.total_usage) {
+    response.total_usage = Math.round(response.total_usage) / 100;
+  }
+
+  if (total.hard_limit_usd) {
+    total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
+  }
+
+  return {
+    used: response.total_usage,
+    subscription: total.hard_limit_usd,
+  };
+}
+
+export async function requestChatStream(
+  messages: Message[],
+  options?: {
+    modelConfig?: ModelConfig;
+    overrideModel?: ModelType;
+    onMessage: (message: string, done: boolean) => void;
+    onError: (error: Error, statusCode?: number) => void;
+    onController?: (controller: AbortController) => void;
+  },
+) {
+  const req = makeRequestParam(messages, {
+    stream: true,
+    overrideModel: options?.overrideModel,
+  });
+
+  console.log("[Request] ", req);
+
+  const controller = new AbortController();
+  const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS);
+
+  try {
+    const openaiUrl = useAccessStore.getState().openaiUrl;
+    const res = await fetch(openaiUrl + "v1/chat/completions", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        ...getHeaders(),
+      },
+      body: JSON.stringify(req),
+      signal: controller.signal,
+    });
+
+    clearTimeout(reqTimeoutId);
+
+    let responseText = "";
+
+    const finish = () => {
+      options?.onMessage(responseText, true);
+      controller.abort();
+    };
+
+    if (res.ok) {
+      const reader = res.body?.getReader();
+      const decoder = new TextDecoder();
+
+      options?.onController?.(controller);
+
+      while (true) {
+        const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
+        const content = await reader?.read();
+        clearTimeout(resTimeoutId);
+
+        if (!content || !content.value) {
+          break;
+        }
+
+        const text = decoder.decode(content.value, { stream: true });
+        responseText += text;
+
+        const done = content.done;
+        options?.onMessage(responseText, false);
+
+        if (done) {
+          break;
+        }
+      }
+
+      finish();
+    } else if (res.status === 401) {
+      console.error("Unauthorized");
+      options?.onError(new Error("Unauthorized"), res.status);
+    } else {
+      console.error("Stream Error", res.body);
+      options?.onError(new Error("Stream Error"), res.status);
+    }
+  } catch (err) {
+    console.error("NetWork Error", err);
+    options?.onError(err as Error);
+  }
+}
+
+export async function requestWithPrompt(
+  messages: Message[],
+  prompt: string,
+  options?: {
+    model?: ModelType;
+  },
+) {
+  messages = messages.concat([
+    {
+      role: "user",
+      content: prompt,
+      date: new Date().toLocaleString(),
+    },
+  ]);
+
+  const res = await requestChat(messages, options);
+
+  return res?.choices?.at(0)?.message?.content ?? "";
+}
+
+// To store message streaming controller
+export const ControllerPool = {
+  controllers: {} as Record<string, AbortController>,
+
+  addController(
+    sessionIndex: number,
+    messageId: number,
+    controller: AbortController,
+  ) {
+    const key = this.key(sessionIndex, messageId);
+    this.controllers[key] = controller;
+    return key;
+  },
+
+  stop(sessionIndex: number, messageId: number) {
+    const key = this.key(sessionIndex, messageId);
+    const controller = this.controllers[key];
+    controller?.abort();
+  },
+
+  stopAll() {
+    Object.values(this.controllers).forEach((v) => v.abort());
+  },
+
+  hasPending() {
+    return Object.values(this.controllers).length > 0;
+  },
+
+  remove(sessionIndex: number, messageId: number) {
+    const key = this.key(sessionIndex, messageId);
+    delete this.controllers[key];
+  },
+
+  key(sessionIndex: number, messageIndex: number) {
+    return `${sessionIndex},${messageIndex}`;
+  },
+};

+ 93 - 0
app/store/access.ts

@@ -0,0 +1,93 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { StoreKey } from "../constant";
+import { getHeaders } from "../requests";
+import { BOT_HELLO } from "./chat";
+import { ALL_MODELS } from "./config";
+
+export interface AccessControlStore {
+  accessCode: string;
+  token: string;
+
+  needCode: boolean;
+  hideUserApiKey: boolean;
+  openaiUrl: string;
+
+  updateToken: (_: string) => void;
+  updateCode: (_: string) => void;
+  enabledAccessControl: () => boolean;
+  isAuthorized: () => boolean;
+  fetch: () => void;
+}
+
+let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
+
+export const useAccessStore = create<AccessControlStore>()(
+  persist(
+    (set, get) => ({
+      token: "",
+      accessCode: "",
+      needCode: true,
+      hideUserApiKey: false,
+      openaiUrl: "/api/openai/",
+
+      enabledAccessControl() {
+        get().fetch();
+
+        return get().needCode;
+      },
+      updateCode(code: string) {
+        set(() => ({ accessCode: code }));
+      },
+      updateToken(token: string) {
+        set(() => ({ token }));
+      },
+      isAuthorized() {
+        get().fetch();
+
+        // has token or has code or disabled access control
+        return (
+          !!get().token || !!get().accessCode || !get().enabledAccessControl()
+        );
+      },
+      fetch() {
+        if (fetchState > 0) return;
+        fetchState = 1;
+        fetch("/api/config", {
+          method: "post",
+          body: null,
+          headers: {
+            ...getHeaders(),
+          },
+        })
+          .then((res) => res.json())
+          .then((res: DangerConfig) => {
+            console.log("[Config] got config from server", res);
+            set(() => ({ ...res }));
+
+            if (!res.enableGPT4) {
+              ALL_MODELS.forEach((model) => {
+                if (model.name.startsWith("gpt-4")) {
+                  (model as any).available = false;
+                }
+              });
+            }
+
+            if ((res as any).botHello) {
+              BOT_HELLO.content = (res as any).botHello;
+            }
+          })
+          .catch(() => {
+            console.error("[Config] failed to fetch config");
+          })
+          .finally(() => {
+            fetchState = 2;
+          });
+      },
+    }),
+    {
+      name: StoreKey.Access,
+      version: 1,
+    },
+  ),
+);

+ 521 - 0
app/store/chat.ts

@@ -0,0 +1,521 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+import { type ChatCompletionResponseMessage } from "openai";
+import {
+  ControllerPool,
+  requestChatStream,
+  requestWithPrompt,
+} from "../requests";
+import { trimTopic } from "../utils";
+
+import Locale from "../locales";
+import { showToast } from "../components/ui-lib";
+import { ModelType } from "./config";
+import { createEmptyMask, Mask } from "./mask";
+import { StoreKey } from "../constant";
+
+export type Message = ChatCompletionResponseMessage & {
+  date: string;
+  streaming?: boolean;
+  isError?: boolean;
+  id?: number;
+  model?: ModelType;
+};
+
+export function createMessage(override: Partial<Message>): Message {
+  return {
+    id: Date.now(),
+    date: new Date().toLocaleString(),
+    role: "user",
+    content: "",
+    ...override,
+  };
+}
+
+export const ROLES: Message["role"][] = ["system", "user", "assistant"];
+
+export interface ChatStat {
+  tokenCount: number;
+  wordCount: number;
+  charCount: number;
+}
+
+export interface ChatSession {
+  id: number;
+
+  topic: string;
+
+  memoryPrompt: string;
+  messages: Message[];
+  stat: ChatStat;
+  lastUpdate: number;
+  lastSummarizeIndex: number;
+
+  mask: Mask;
+}
+
+export const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
+export const BOT_HELLO: Message = createMessage({
+  role: "assistant",
+  content: Locale.Store.BotHello,
+});
+
+function createEmptySession(): ChatSession {
+  return {
+    id: Date.now() + Math.random(),
+    topic: DEFAULT_TOPIC,
+    memoryPrompt: "",
+    messages: [],
+    stat: {
+      tokenCount: 0,
+      wordCount: 0,
+      charCount: 0,
+    },
+    lastUpdate: Date.now(),
+    lastSummarizeIndex: 0,
+    mask: createEmptyMask(),
+  };
+}
+
+interface ChatStore {
+  sessions: ChatSession[];
+  currentSessionIndex: number;
+  globalId: number;
+  clearSessions: () => void;
+  moveSession: (from: number, to: number) => void;
+  selectSession: (index: number) => void;
+  newSession: (mask?: Mask) => void;
+  deleteSession: (index: number) => void;
+  currentSession: () => ChatSession;
+  onNewMessage: (message: Message) => void;
+  onUserInput: (content: string) => Promise<void>;
+  summarizeSession: () => void;
+  updateStat: (message: Message) => void;
+  updateCurrentSession: (updater: (session: ChatSession) => void) => void;
+  updateMessage: (
+    sessionIndex: number,
+    messageIndex: number,
+    updater: (message?: Message) => void,
+  ) => void;
+  resetSession: () => void;
+  getMessagesWithMemory: () => Message[];
+  getMemoryPrompt: () => Message;
+
+  clearAllData: () => void;
+}
+
+function countMessages(msgs: Message[]) {
+  return msgs.reduce((pre, cur) => pre + cur.content.length, 0);
+}
+
+export const useChatStore = create<ChatStore>()(
+  persist(
+    (set, get) => ({
+      sessions: [createEmptySession()],
+      currentSessionIndex: 0,
+      globalId: 0,
+
+      clearSessions() {
+        set(() => ({
+          sessions: [createEmptySession()],
+          currentSessionIndex: 0,
+        }));
+      },
+
+      selectSession(index: number) {
+        set({
+          currentSessionIndex: index,
+        });
+      },
+
+      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(mask) {
+        const session = createEmptySession();
+
+        set(() => ({ globalId: get().globalId + 1 }));
+        session.id = get().globalId;
+
+        if (mask) {
+          session.mask = { ...mask };
+          session.topic = mask.name;
+        }
+
+        set((state) => ({
+          currentSessionIndex: 0,
+          sessions: [session].concat(state.sessions),
+        }));
+      },
+
+      deleteSession(index) {
+        const deletingLastSession = get().sessions.length === 1;
+        const deletedSession = get().sessions.at(index);
+
+        if (!deletedSession) return;
+
+        const sessions = get().sessions.slice();
+        sessions.splice(index, 1);
+
+        const currentIndex = get().currentSessionIndex;
+        let nextIndex = Math.min(
+          currentIndex - Number(index < currentIndex),
+          sessions.length - 1,
+        );
+
+        if (deletingLastSession) {
+          nextIndex = 0;
+          sessions.push(createEmptySession());
+        }
+
+        // for undo delete action
+        const restoreState = {
+          currentSessionIndex: get().currentSessionIndex,
+          sessions: get().sessions.slice(),
+        };
+
+        set(() => ({
+          currentSessionIndex: nextIndex,
+          sessions,
+        }));
+
+        showToast(
+          Locale.Home.DeleteToast,
+          {
+            text: Locale.Home.Revert,
+            onClick() {
+              set(() => restoreState);
+            },
+          },
+          5000,
+        );
+      },
+
+      currentSession() {
+        let index = get().currentSessionIndex;
+        const sessions = get().sessions;
+
+        if (index < 0 || index >= sessions.length) {
+          index = Math.min(sessions.length - 1, Math.max(0, index));
+          set(() => ({ currentSessionIndex: index }));
+        }
+
+        const session = sessions[index];
+
+        return session;
+      },
+
+      onNewMessage(message) {
+        get().updateCurrentSession((session) => {
+          session.lastUpdate = Date.now();
+        });
+        get().updateStat(message);
+        get().summarizeSession();
+      },
+
+      async onUserInput(content) {
+        const session = get().currentSession();
+        const modelConfig = session.mask.modelConfig;
+
+        const userMessage: Message = createMessage({
+          role: "user",
+          content,
+        });
+
+        const botMessage: Message = createMessage({
+          role: "assistant",
+          streaming: true,
+          id: userMessage.id! + 1,
+          model: modelConfig.model,
+        });
+
+        const systemInfo = createMessage({
+          role: "system",
+          content: `IMPRTANT: You are a virtual assistant powered by the ${
+            modelConfig.model
+          } model, now time is ${new Date().toLocaleString()}}`,
+          id: botMessage.id! + 1,
+        });
+
+        // get recent messages
+        const systemMessages = [systemInfo];
+        const recentMessages = get().getMessagesWithMemory();
+        const sendMessages = systemMessages.concat(
+          recentMessages.concat(userMessage),
+        );
+        const sessionIndex = get().currentSessionIndex;
+        const messageIndex = get().currentSession().messages.length + 1;
+
+        // save user's and bot's message
+        get().updateCurrentSession((session) => {
+          session.messages.push(userMessage);
+          session.messages.push(botMessage);
+        });
+
+        // make request
+        console.log("[User Input] ", sendMessages);
+        requestChatStream(sendMessages, {
+          onMessage(content, done) {
+            // stream response
+            if (done) {
+              botMessage.streaming = false;
+              botMessage.content = content;
+              get().onNewMessage(botMessage);
+              ControllerPool.remove(
+                sessionIndex,
+                botMessage.id ?? messageIndex,
+              );
+            } else {
+              botMessage.content = content;
+              set(() => ({}));
+            }
+          },
+          onError(error, statusCode) {
+            const isAborted = error.message.includes("aborted");
+            if (statusCode === 401) {
+              botMessage.content = Locale.Error.Unauthorized;
+            } else if (!isAborted) {
+              botMessage.content += "\n\n" + Locale.Store.Error;
+            }
+            botMessage.streaming = false;
+            userMessage.isError = !isAborted;
+            botMessage.isError = !isAborted;
+
+            set(() => ({}));
+            ControllerPool.remove(sessionIndex, botMessage.id ?? messageIndex);
+          },
+          onController(controller) {
+            // collect controller for stop/retry
+            ControllerPool.addController(
+              sessionIndex,
+              botMessage.id ?? messageIndex,
+              controller,
+            );
+          },
+          modelConfig: { ...modelConfig },
+        });
+      },
+
+      getMemoryPrompt() {
+        const session = get().currentSession();
+
+        return {
+          role: "system",
+          content:
+            session.memoryPrompt.length > 0
+              ? Locale.Store.Prompt.History(session.memoryPrompt)
+              : "",
+          date: "",
+        } as Message;
+      },
+
+      getMessagesWithMemory() {
+        const session = get().currentSession();
+        const modelConfig = session.mask.modelConfig;
+        const messages = session.messages.filter((msg) => !msg.isError);
+        const n = messages.length;
+
+        const context = session.mask.context.slice();
+
+        // long term memory
+        if (
+          modelConfig.sendMemory &&
+          session.memoryPrompt &&
+          session.memoryPrompt.length > 0
+        ) {
+          const memoryPrompt = get().getMemoryPrompt();
+          context.push(memoryPrompt);
+        }
+
+        // get short term and unmemoried long term memory
+        const shortTermMemoryMessageIndex = Math.max(
+          0,
+          n - modelConfig.historyMessageCount,
+        );
+        const longTermMemoryMessageIndex = session.lastSummarizeIndex;
+        const oldestIndex = Math.max(
+          shortTermMemoryMessageIndex,
+          longTermMemoryMessageIndex,
+        );
+        const threshold = modelConfig.compressMessageLengthThreshold;
+
+        // get recent messages as many as possible
+        const reversedRecentMessages = [];
+        for (
+          let i = n - 1, count = 0;
+          i >= oldestIndex && count < threshold;
+          i -= 1
+        ) {
+          const msg = messages[i];
+          if (!msg || msg.isError) continue;
+          count += msg.content.length;
+          reversedRecentMessages.push(msg);
+        }
+
+        // concat
+        const recentMessages = context.concat(reversedRecentMessages.reverse());
+
+        return recentMessages;
+      },
+
+      updateMessage(
+        sessionIndex: number,
+        messageIndex: number,
+        updater: (message?: Message) => void,
+      ) {
+        const sessions = get().sessions;
+        const session = sessions.at(sessionIndex);
+        const messages = session?.messages;
+        updater(messages?.at(messageIndex));
+        set(() => ({ sessions }));
+      },
+
+      resetSession() {
+        get().updateCurrentSession((session) => {
+          session.messages = [];
+          session.memoryPrompt = "";
+        });
+      },
+
+      summarizeSession() {
+        const session = get().currentSession();
+
+        // should summarize topic after chating more than 50 words
+        const SUMMARIZE_MIN_LEN = 50;
+        if (
+          session.topic === DEFAULT_TOPIC &&
+          countMessages(session.messages) >= SUMMARIZE_MIN_LEN
+        ) {
+          requestWithPrompt(session.messages, Locale.Store.Prompt.Topic, {
+            model: "gpt-3.5-turbo",
+          }).then((res) => {
+            get().updateCurrentSession(
+              (session) =>
+                (session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
+            );
+          });
+        }
+
+        const modelConfig = session.mask.modelConfig;
+        let toBeSummarizedMsgs = session.messages.slice(
+          session.lastSummarizeIndex,
+        );
+
+        const historyMsgLength = countMessages(toBeSummarizedMsgs);
+
+        if (historyMsgLength > modelConfig?.max_tokens ?? 4000) {
+          const n = toBeSummarizedMsgs.length;
+          toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
+            Math.max(0, n - modelConfig.historyMessageCount),
+          );
+        }
+
+        // add memory prompt
+        toBeSummarizedMsgs.unshift(get().getMemoryPrompt());
+
+        const lastSummarizeIndex = session.messages.length;
+
+        console.log(
+          "[Chat History] ",
+          toBeSummarizedMsgs,
+          historyMsgLength,
+          modelConfig.compressMessageLengthThreshold,
+        );
+
+        if (
+          historyMsgLength > modelConfig.compressMessageLengthThreshold &&
+          session.mask.modelConfig.sendMemory
+        ) {
+          requestChatStream(
+            toBeSummarizedMsgs.concat({
+              role: "system",
+              content: Locale.Store.Prompt.Summarize,
+              date: "",
+            }),
+            {
+              overrideModel: "gpt-3.5-turbo",
+              onMessage(message, done) {
+                session.memoryPrompt = message;
+                if (done) {
+                  console.log("[Memory] ", session.memoryPrompt);
+                  session.lastSummarizeIndex = lastSummarizeIndex;
+                }
+              },
+              onError(error) {
+                console.error("[Summarize] ", error);
+              },
+            },
+          );
+        }
+      },
+
+      updateStat(message) {
+        get().updateCurrentSession((session) => {
+          session.stat.charCount += message.content.length;
+          // TODO: should update chat count and word count
+        });
+      },
+
+      updateCurrentSession(updater) {
+        const sessions = get().sessions;
+        const index = get().currentSessionIndex;
+        updater(sessions[index]);
+        set(() => ({ sessions }));
+      },
+
+      clearAllData() {
+        localStorage.clear();
+        location.reload();
+      },
+    }),
+    {
+      name: StoreKey.Chat,
+      version: 2,
+      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;
+          for (const oldSession of oldSessions) {
+            const newSession = createEmptySession();
+            newSession.topic = oldSession.topic;
+            newSession.messages = [...oldSession.messages];
+            newSession.mask.modelConfig.sendMemory = true;
+            newSession.mask.modelConfig.historyMessageCount = 4;
+            newSession.mask.modelConfig.compressMessageLengthThreshold = 1000;
+            newState.sessions.push(newSession);
+          }
+        }
+
+        return newState;
+      },
+    },
+  ),
+);

+ 168 - 0
app/store/config.ts

@@ -0,0 +1,168 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { StoreKey } from "../constant";
+
+export enum SubmitKey {
+  Enter = "Enter",
+  CtrlEnter = "Ctrl + Enter",
+  ShiftEnter = "Shift + Enter",
+  AltEnter = "Alt + Enter",
+  MetaEnter = "Meta + Enter",
+}
+
+export enum Theme {
+  Auto = "auto",
+  Dark = "dark",
+  Light = "light",
+}
+
+export const DEFAULT_CONFIG = {
+  submitKey: SubmitKey.CtrlEnter as SubmitKey,
+  avatar: "1f603",
+  fontSize: 14,
+  theme: Theme.Auto as Theme,
+  tightBorder: false,
+  sendPreviewBubble: true,
+  sidebarWidth: 300,
+
+  disablePromptHint: false,
+
+  dontShowMaskSplashScreen: false, // dont show splash screen when create chat
+
+  modelConfig: {
+    model: "gpt-3.5-turbo" as ModelType,
+    temperature: 0.5,
+    max_tokens: 2000,
+    presence_penalty: 0,
+    sendMemory: true,
+    historyMessageCount: 4,
+    compressMessageLengthThreshold: 1000,
+  },
+};
+
+export type ChatConfig = typeof DEFAULT_CONFIG;
+
+export type ChatConfigStore = ChatConfig & {
+  reset: () => void;
+  update: (updater: (config: ChatConfig) => void) => void;
+};
+
+export type ModelConfig = ChatConfig["modelConfig"];
+
+const ENABLE_GPT4 = true;
+
+export const ALL_MODELS = [
+  {
+    name: "gpt-4",
+    available: ENABLE_GPT4,
+  },
+  {
+    name: "gpt-4-0314",
+    available: ENABLE_GPT4,
+  },
+  {
+    name: "gpt-4-32k",
+    available: ENABLE_GPT4,
+  },
+  {
+    name: "gpt-4-32k-0314",
+    available: ENABLE_GPT4,
+  },
+  {
+    name: "gpt-3.5-turbo",
+    available: true,
+  },
+  {
+    name: "gpt-3.5-turbo-0301",
+    available: true,
+  },
+  {
+    name: "qwen-v1", // ้€šไน‰ๅƒ้—ฎ
+    available: false,
+  },
+  {
+    name: "ernie", // ๆ–‡ๅฟƒไธ€่จ€
+    available: false,
+  },
+  {
+    name: "spark", // ่ฎฏ้ฃžๆ˜Ÿ็ซ
+    available: false,
+  },
+  {
+    name: "llama", // llama
+    available: false,
+  },
+  {
+    name: "chatglm", // chatglm-6b
+    available: false,
+  },
+] as const;
+
+export type ModelType = (typeof ALL_MODELS)[number]["name"];
+
+export function limitNumber(
+  x: number,
+  min: number,
+  max: number,
+  defaultValue: number,
+) {
+  if (typeof x !== "number" || isNaN(x)) {
+    return defaultValue;
+  }
+
+  return Math.min(max, Math.max(min, x));
+}
+
+export function limitModel(name: string) {
+  return ALL_MODELS.some((m) => m.name === name && m.available)
+    ? name
+    : ALL_MODELS[4].name;
+}
+
+export const ModalConfigValidator = {
+  model(x: string) {
+    return limitModel(x) as ModelType;
+  },
+  max_tokens(x: number) {
+    return limitNumber(x, 0, 32000, 2000);
+  },
+  presence_penalty(x: number) {
+    return limitNumber(x, -2, 2, 0);
+  },
+  temperature(x: number) {
+    return limitNumber(x, 0, 1, 1);
+  },
+};
+
+export const useAppConfig = create<ChatConfigStore>()(
+  persist(
+    (set, get) => ({
+      ...DEFAULT_CONFIG,
+
+      reset() {
+        set(() => ({ ...DEFAULT_CONFIG }));
+      },
+
+      update(updater) {
+        const config = { ...get() };
+        updater(config);
+        set(() => config);
+      },
+    }),
+    {
+      name: StoreKey.Config,
+      version: 2,
+      migrate(persistedState, version) {
+        if (version === 2) return persistedState as any;
+
+        const state = persistedState as ChatConfig;
+        state.modelConfig.sendMemory = true;
+        state.modelConfig.historyMessageCount = 4;
+        state.modelConfig.compressMessageLengthThreshold = 1000;
+        state.dontShowMaskSplashScreen = false;
+
+        return state;
+      },
+    },
+  ),
+);

+ 4 - 0
app/store/index.ts

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

+ 100 - 0
app/store/mask.ts

@@ -0,0 +1,100 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { BUILTIN_MASKS } from "../masks";
+import { getLang, Lang } from "../locales";
+import { DEFAULT_TOPIC, Message } from "./chat";
+import { ModelConfig, ModelType, useAppConfig } from "./config";
+import { StoreKey } from "../constant";
+
+export type Mask = {
+  id: number;
+  avatar: string;
+  name: string;
+  context: Message[];
+  modelConfig: ModelConfig;
+  lang: Lang;
+  builtin: boolean;
+};
+
+export const DEFAULT_MASK_STATE = {
+  masks: {} as Record<number, Mask>,
+  globalMaskId: 0,
+};
+
+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;
+  search: (text: string) => Mask[];
+  get: (id?: number) => Mask | null;
+  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(),
+    builtin: false,
+  } as Mask);
+
+export const useMaskStore = create<MaskStore>()(
+  persist(
+    (set, get) => ({
+      ...DEFAULT_MASK_STATE,
+
+      create(mask) {
+        set(() => ({ globalMaskId: get().globalMaskId + 1 }));
+        const id = get().globalMaskId;
+        const masks = get().masks;
+        masks[id] = {
+          ...createEmptyMask(),
+          ...mask,
+          id,
+          builtin: false,
+        };
+
+        set(() => ({ masks }));
+
+        return masks[id];
+      },
+      update(id, updater) {
+        const masks = get().masks;
+        const mask = masks[id];
+        if (!mask) return;
+        const updateMask = { ...mask };
+        updater(updateMask);
+        masks[id] = updateMask;
+        set(() => ({ masks }));
+      },
+      delete(id) {
+        const masks = get().masks;
+        delete masks[id];
+        set(() => ({ masks }));
+      },
+
+      get(id) {
+        return get().masks[id ?? 1145141919810];
+      },
+      getAll() {
+        const userMasks = Object.values(get().masks).sort(
+          (a, b) => b.id - a.id,
+        );
+        return userMasks.concat(BUILTIN_MASKS);
+      },
+      search(text) {
+        return Object.values(get().masks);
+      },
+    }),
+    {
+      name: StoreKey.Mask,
+      version: 2,
+    },
+  ),
+);

+ 175 - 0
app/store/prompt.ts

@@ -0,0 +1,175 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import Fuse from "fuse.js";
+import { getLang } from "../locales";
+import { StoreKey } from "../constant";
+
+export interface Prompt {
+  id?: number;
+  isUser?: boolean;
+  title: string;
+  content: string;
+}
+
+export interface PromptStore {
+  counter: number;
+  latestId: number;
+  prompts: Record<number, Prompt>;
+
+  add: (prompt: Prompt) => number;
+  get: (id: number) => Prompt | undefined;
+  remove: (id: number) => void;
+  search: (text: string) => Prompt[];
+  update: (id: number, updater: (prompt: Prompt) => void) => void;
+
+  getUserPrompts: () => Prompt[];
+}
+
+export const SearchService = {
+  ready: false,
+  builtinEngine: new Fuse<Prompt>([], { keys: ["title"] }),
+  userEngine: new Fuse<Prompt>([], { keys: ["title"] }),
+  count: {
+    builtin: 0,
+  },
+  allPrompts: [] as Prompt[],
+  builtinPrompts: [] as Prompt[],
+
+  init(builtinPrompts: Prompt[], userPrompts: Prompt[]) {
+    if (this.ready) {
+      return;
+    }
+    this.allPrompts = userPrompts.concat(builtinPrompts);
+    this.builtinPrompts = builtinPrompts.slice();
+    this.builtinEngine.setCollection(builtinPrompts);
+    this.userEngine.setCollection(userPrompts);
+    this.ready = true;
+  },
+
+  remove(id: number) {
+    this.userEngine.remove((doc) => doc.id === id);
+  },
+
+  add(prompt: Prompt) {
+    this.userEngine.add(prompt);
+  },
+
+  search(text: string) {
+    const userResults = this.userEngine.search(text);
+    const builtinResults = this.builtinEngine.search(text);
+    return userResults.concat(builtinResults).map((v) => v.item);
+  },
+};
+
+export const usePromptStore = create<PromptStore>()(
+  persist(
+    (set, get) => ({
+      counter: 0,
+      latestId: 0,
+      prompts: {},
+
+      add(prompt) {
+        const prompts = get().prompts;
+        prompt.id = get().latestId + 1;
+        prompt.isUser = true;
+        prompts[prompt.id] = prompt;
+
+        set(() => ({
+          latestId: prompt.id!,
+          prompts: prompts,
+        }));
+
+        return prompt.id!;
+      },
+
+      get(id) {
+        const targetPrompt = get().prompts[id];
+
+        if (!targetPrompt) {
+          return SearchService.builtinPrompts.find((v) => v.id === id);
+        }
+
+        return targetPrompt;
+      },
+
+      remove(id) {
+        const prompts = get().prompts;
+        delete prompts[id];
+        SearchService.remove(id);
+
+        set(() => ({
+          prompts,
+          counter: get().counter + 1,
+        }));
+      },
+
+      getUserPrompts() {
+        const userPrompts = Object.values(get().prompts ?? {});
+        userPrompts.sort((a, b) => (b.id && a.id ? b.id - a.id : 0));
+        return userPrompts;
+      },
+
+      update(id: number, updater) {
+        const prompt = get().prompts[id] ?? {
+          title: "",
+          content: "",
+          id,
+        };
+
+        SearchService.remove(id);
+        updater(prompt);
+        const prompts = get().prompts;
+        prompts[id] = prompt;
+        set(() => ({ prompts }));
+        SearchService.add(prompt);
+      },
+
+      search(text) {
+        if (text.length === 0) {
+          // return all rompts
+          return SearchService.allPrompts.concat([...get().getUserPrompts()]);
+        }
+        return SearchService.search(text) as Prompt[];
+      },
+    }),
+    {
+      name: StoreKey.Prompt,
+      version: 1,
+      onRehydrateStorage(state) {
+        const PROMPT_URL = "./prompts.json";
+
+        type PromptList = Array<[string, string]>;
+
+        fetch(PROMPT_URL)
+          .then((res) => res.json())
+          .then((res) => {
+            let fetchPrompts = [res.en, res.cn];
+            if (getLang() === "cn") {
+              fetchPrompts = fetchPrompts.reverse();
+            }
+            const builtinPrompts = fetchPrompts.map(
+              (promptList: PromptList) => {
+                return promptList.map(
+                  ([title, content]) =>
+                    ({
+                      id: Math.random(),
+                      title,
+                      content,
+                    } as Prompt),
+                );
+              },
+            );
+
+            const userPrompts =
+              usePromptStore.getState().getUserPrompts() ?? [];
+
+            const allPromptsForSearch = builtinPrompts
+              .reduce((pre, cur) => pre.concat(cur), [])
+              .filter((v) => !!v.title && !!v.content);
+            SearchService.count.builtin = res.en.length + res.cn.length;
+            SearchService.init(allPromptsForSearch, userPrompts);
+          });
+      },
+    },
+  ),
+);

+ 88 - 0
app/store/update.ts

@@ -0,0 +1,88 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { FETCH_COMMIT_URL, FETCH_TAG_URL, StoreKey } from "../constant";
+import { requestUsage } from "../requests";
+
+export interface UpdateStore {
+  lastUpdate: number;
+  remoteVersion: string;
+
+  used?: number;
+  subscription?: number;
+  lastUpdateUsage: number;
+
+  version: string;
+  getLatestVersion: (force?: boolean) => Promise<void>;
+  updateUsage: (force?: boolean) => Promise<void>;
+}
+
+function queryMeta(key: string, defaultValue?: string): string {
+  let ret: string;
+  if (document) {
+    const meta = document.head.querySelector(
+      `meta[name='${key}']`,
+    ) as HTMLMetaElement;
+    ret = meta?.content ?? "";
+  } else {
+    ret = defaultValue ?? "";
+  }
+
+  return ret;
+}
+
+const ONE_MINUTE = 60 * 1000;
+
+export const useUpdateStore = create<UpdateStore>()(
+  persist(
+    (set, get) => ({
+      lastUpdate: 0,
+      remoteVersion: "",
+
+      lastUpdateUsage: 0,
+
+      version: "unknown",
+
+      async getLatestVersion(force = false) {
+        set(() => ({ version: queryMeta("version") ?? "unknown" }));
+
+        const overTenMins = Date.now() - get().lastUpdate > 10 * ONE_MINUTE;
+        if (!force && !overTenMins) return;
+
+        set(() => ({
+          lastUpdate: Date.now(),
+        }));
+
+        try {
+          const data = await (await fetch(FETCH_COMMIT_URL)).json();
+          const remoteCommitTime = data[0].commit.committer.date;
+          const remoteId = new Date(remoteCommitTime).getTime().toString();
+          set(() => ({
+            remoteVersion: remoteId,
+          }));
+          console.log("[Got Upstream] ", remoteId);
+        } catch (error) {
+          console.error("[Fetch Upstream Commit Id]", error);
+        }
+      },
+
+      async updateUsage(force = false) {
+        const overOneMinute = Date.now() - get().lastUpdateUsage >= ONE_MINUTE;
+        if (!overOneMinute && !force) return;
+
+        set(() => ({
+          lastUpdateUsage: Date.now(),
+        }));
+
+        const usage = await requestUsage();
+
+        if (usage) {
+          set(() => usage);
+        }
+      },
+    }),
+    {
+      name: StoreKey.Update,
+      version: 1,
+    },
+  ),
+);

+ 23 - 0
app/styles/animation.scss

@@ -0,0 +1,23 @@
+@keyframes slide-in {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+}
+
+@keyframes slide-in-from-top {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+}

+ 355 - 0
app/styles/globals.scss

@@ -0,0 +1,355 @@
+@import "./animation.scss";
+@import "./window.scss";
+
+@mixin light {
+  --theme: light;
+
+  /* color */
+  --white: white;
+  --black: rgb(48, 48, 48);
+  --gray: rgb(208, 208, 208);
+  --primary: rgb(177, 180, 192);
+  --second: rgb(177, 180, 192);
+  --hover-color: #f3f3f3;
+  --bar-color: rgba(0, 0, 0, 0.1);
+  --theme-color: var(--gray);
+
+  /* shadow */
+  --shadow: 50px 50px 100px 10px rgb(0, 0, 0, 0.1);
+  --card-shadow: 0px 2px 4px 0px rgb(0, 0, 0, 0.05);
+
+  /* stroke */
+  --border-in-light: 1px solid rgb(222, 222, 222);
+}
+
+@mixin dark {
+  --theme: dark;
+
+  /* color */
+  --white: rgb(30, 30, 30);
+  --black: rgb(187, 187, 187);
+  --gray: rgb(21, 21, 21);
+  --primary: rgb(72, 76, 87);
+  --second: rgb(72, 76, 87);
+  --hover-color: #323232;
+
+  --bar-color: rgba(255, 255, 255, 0.1);
+
+  --border-in-light: 1px solid rgba(255, 255, 255, 0.192);
+
+  --theme-color: var(--gray);
+
+  div:not(.no-dark) > svg {
+    filter: invert(0.5);
+  }
+}
+
+.light {
+  @include light;
+}
+
+.dark {
+  @include dark;
+}
+
+.mask {
+  filter: invert(0.8);
+}
+
+:root {
+  @include light;
+
+  --window-width: 90vw;
+  --window-height: 90vh;
+  --sidebar-width: 300px;
+  --window-content-width: calc(100% - var(--sidebar-width));
+  --message-max-width: 80%;
+  --full-height: 100%;
+}
+
+@media only screen and (max-width: 600px) {
+  :root {
+    --window-width: 100vw;
+    --window-height: var(--full-height);
+    --sidebar-width: 100vw;
+    --window-content-width: var(--window-width);
+    --message-max-width: 100%;
+  }
+
+  .no-mobile {
+    display: none;
+  }
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    @include dark;
+  }
+}
+html {
+  height: var(--full-height);
+
+  font-family: "Noto Sans SC", "SF Pro SC", "SF Pro Text", "SF Pro Icons",
+    "PingFang SC", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
+}
+
+body {
+  background-color: var(--gray);
+  color: var(--black);
+  margin: 0;
+  padding: 0;
+  height: var(--full-height);
+  width: 100vw;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  user-select: none;
+  touch-action: pan-x pan-y;
+
+  @media only screen and (max-width: 600px) {
+    background-color: var(--second);
+  }
+}
+
+::-webkit-scrollbar {
+  --bar-width: 5px;
+  width: var(--bar-width);
+  height: var(--bar-width);
+}
+
+::-webkit-scrollbar-track {
+  background-color: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+  background-color: var(--bar-color);
+  border-radius: 20px;
+  background-clip: content-box;
+  border: 1px solid transparent;
+}
+
+select {
+  border: var(--border-in-light);
+  padding: 10px;
+  border-radius: 10px;
+  appearance: none;
+  cursor: pointer;
+  background-color: var(--white);
+  color: var(--black);
+  text-align: center;
+}
+
+label {
+  cursor: pointer;
+}
+
+input {
+  text-align: center;
+  font-family: inherit;
+}
+
+input[type="checkbox"] {
+  cursor: pointer;
+  background-color: var(--white);
+  color: var(--black);
+  appearance: none;
+  border: var(--border-in-light);
+  border-radius: 5px;
+  height: 16px;
+  width: 16px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+input[type="checkbox"]:checked::after {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  background-color: var(--primary);
+  content: " ";
+  border-radius: 2px;
+}
+
+input[type="range"] {
+  appearance: none;
+  background-color: var(--white);
+  color: var(--black);
+}
+
+@mixin thumb() {
+  appearance: none;
+  height: 8px;
+  width: 20px;
+  background-color: var(--primary);
+  border-radius: 10px;
+  cursor: pointer;
+  transition: all ease 0.3s;
+  margin-left: 5px;
+  border: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+  @include thumb();
+}
+
+input[type="range"]::-moz-range-thumb {
+  @include thumb();
+}
+
+input[type="range"]::-ms-thumb {
+  @include thumb();
+}
+
+@mixin thumbHover() {
+  transform: scaleY(1.2);
+  width: 24px;
+}
+
+input[type="range"]::-webkit-slider-thumb:hover {
+  @include thumbHover();
+}
+
+input[type="range"]::-moz-range-thumb:hover {
+  @include thumbHover();
+}
+
+input[type="range"]::-ms-thumb:hover {
+  @include thumbHover();
+}
+
+input[type="number"],
+input[type="text"],
+input[type="password"] {
+  appearance: none;
+  border-radius: 10px;
+  border: var(--border-in-light);
+  min-height: 36px;
+  box-sizing: border-box;
+  background: var(--white);
+  color: var(--black);
+  padding: 0 10px;
+  max-width: 50%;
+  font-family: inherit;
+}
+
+div.math {
+  overflow-x: auto;
+}
+
+.modal-mask {
+  z-index: 9999;
+  position: fixed;
+  top: 0;
+  left: 0;
+  height: var(--full-height);
+  width: 100vw;
+  background-color: rgba($color: #000000, $alpha: 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  @media screen and (max-width: 600px) {
+    align-items: flex-end;
+  }
+}
+
+.link {
+  font-size: 12px;
+  color: var(--primary);
+  text-decoration: none;
+
+  &:hover {
+    text-decoration: underline;
+  }
+}
+
+pre {
+  position: relative;
+
+  &:hover .copy-code-button {
+    pointer-events: all;
+    transform: translateX(0px);
+    opacity: 0.5;
+  }
+
+  .copy-code-button {
+    position: absolute;
+    right: 10px;
+    top: 1em;
+    cursor: pointer;
+    padding: 0px 5px;
+    background-color: var(--black);
+    color: var(--white);
+    border: var(--border-in-light);
+    border-radius: 10px;
+    transform: translateX(10px);
+    pointer-events: none;
+    opacity: 0;
+    transition: all ease 0.3s;
+
+    &:after {
+      content: "copy";
+    }
+
+    &:hover {
+      opacity: 1;
+    }
+  }
+}
+
+.clickable {
+  cursor: pointer;
+
+  &:hover {
+    filter: brightness(0.9);
+  }
+}
+
+.error {
+  width: 80%;
+  border-radius: 20px;
+  border: var(--border-in-light);
+  box-shadow: var(--card-shadow);
+  padding: 20px;
+  overflow: auto;
+  background-color: var(--white);
+  color: var(--black);
+
+  pre {
+    overflow: auto;
+  }
+}
+
+.password-input-container {
+  max-width: 50%;
+  display: flex;
+  justify-content: flex-end;
+
+  .password-eye {
+    margin-right: 4px;
+  }
+
+  .password-input {
+    min-width: 80%;
+  }
+}
+
+.user-avatar {
+  height: 30px;
+  min-height: 30px;
+  width: 30px;
+  min-width: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: var(--border-in-light);
+  box-shadow: var(--card-shadow);
+  border-radius: 10px;
+}
+
+.one-line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}

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