Browse Source

Merge branch 'main' into main

Ilario Scandurra 1 year ago
parent
commit
c98517fc48

+ 5 - 21
app/components/button.module.scss

@@ -6,19 +6,21 @@
   justify-content: center;
   padding: 10px;
 
-  box-shadow: var(--card-shadow);
   cursor: pointer;
   transition: all 0.3s ease;
   overflow: hidden;
   user-select: none;
 }
 
+.shadow {
+  box-shadow: var(--card-shadow);
+}
+
 .border {
   border: var(--border-in-light);
 }
 
 .icon-button:hover {
-  filter: brightness(0.9);
   border-color: var(--primary);
 }
 
@@ -36,25 +38,7 @@
   }
 }
 
-@mixin dark-button {
-  div:not(:global(.no-dark))>.icon-button-icon {
-    filter: invert(0.5);
-  }
-
-  .icon-button:hover {
-    filter: brightness(1.2);
-  }
-}
-
-:global(.dark) {
-  @include dark-button;
-}
-
-@media (prefers-color-scheme: dark) {
-  @include dark-button;
-}
-
 .icon-button-text {
   margin-left: 5px;
   font-size: 12px;
-}
+}

+ 5 - 1
app/components/button.tsx

@@ -7,6 +7,7 @@ export function IconButton(props: {
   icon: JSX.Element;
   text?: string;
   bordered?: boolean;
+  shadow?: boolean;
   className?: string;
   title?: string;
 }) {
@@ -14,10 +15,13 @@ export function IconButton(props: {
     <div
       className={
         styles["icon-button"] +
-        ` ${props.bordered && styles.border} ${props.className ?? ""}`
+        ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
+          props.className ?? ""
+        } clickable`
       }
       onClick={props.onClick}
       title={props.title}
+      role="button"
     >
       <div className={styles["icon-button-icon"]}>{props.icon}</div>
       {props.text && (

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

@@ -0,0 +1,73 @@
+import { useState, useRef, useEffect, useLayoutEffect } from "react";
+import DeleteIcon from "../icons/delete.svg";
+import styles from "./home.module.scss";
+
+import {
+  Message,
+  SubmitKey,
+  useChatStore,
+  ChatSession,
+  BOT_HELLO,
+} from "../store";
+
+import Locale from "../locales";
+import { isMobileScreen } from "../utils";
+
+export function ChatItem(props: {
+  onClick?: () => void;
+  onDelete?: () => void;
+  title: string;
+  count: number;
+  time: string;
+  selected: boolean;
+}) {
+  return (
+    <div
+      className={`${styles["chat-item"]} ${
+        props.selected && styles["chat-item-selected"]
+      }`}
+      onClick={props.onClick}
+    >
+      <div className={styles["chat-item-title"]}>{props.title}</div>
+      <div className={styles["chat-item-info"]}>
+        <div className={styles["chat-item-count"]}>
+          {Locale.ChatItem.ChatItemCount(props.count)}
+        </div>
+        <div className={styles["chat-item-date"]}>{props.time}</div>
+      </div>
+      <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
+        <DeleteIcon />
+      </div>
+    </div>
+  );
+}
+
+export function ChatList() {
+  const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
+    (state) => [
+      state.sessions,
+      state.currentSessionIndex,
+      state.selectSession,
+      state.removeSession,
+    ],
+  );
+
+  return (
+    <div className={styles["chat-list"]}>
+      {sessions.map((item, i) => (
+        <ChatItem
+          title={item.topic}
+          time={item.lastUpdate}
+          count={item.messages.length}
+          key={i}
+          selected={i === selectedIndex}
+          onClick={() => selectSession(i)}
+          onDelete={() =>
+            (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
+            removeSession(i)
+          }
+        />
+      ))}
+    </div>
+  );
+}

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

@@ -0,0 +1,71 @@
+.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;
+
+    .prompt-toast-content {
+      margin-left: 10px;
+    }
+  }
+}
+
+.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-top: 20px;
+
+  .memory-prompt-title {
+    font-size: 12px;
+    font-weight: bold;
+    margin-bottom: 10px;
+  }
+
+  .memory-prompt-content {
+    background-color: var(--gray);
+    border-radius: 6px;
+    padding: 10px;
+    font-size: 12px;
+    user-select: text;
+  }
+}

+ 622 - 0
app/components/chat.tsx

@@ -0,0 +1,622 @@
+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 ExportIcon from "../icons/export.svg";
+import MenuIcon from "../icons/menu.svg";
+import CopyIcon from "../icons/copy.svg";
+import DownloadIcon from "../icons/download.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import BotIcon from "../icons/bot.svg";
+import AddIcon from "../icons/add.svg";
+import DeleteIcon from "../icons/delete.svg";
+
+import {
+  Message,
+  SubmitKey,
+  useChatStore,
+  ChatSession,
+  BOT_HELLO,
+  ROLES,
+} from "../store";
+
+import {
+  copyToClipboard,
+  downloadAs,
+  isMobileScreen,
+  selectOrCopy,
+} 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 { Modal, showModal, showToast } from "./ui-lib";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
+
+const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
+  loading: () => <LoadingIcon />,
+});
+
+export function Avatar(props: { role: Message["role"] }) {
+  const config = useChatStore((state) => state.config);
+
+  if (props.role === "assistant") {
+    return <BotIcon className={styles["user-avtar"]} />;
+  }
+
+  return (
+    <div className={styles["user-avtar"]}>
+      <Emoji unified={config.avatar} size={18} />
+    </div>
+  );
+}
+
+function exportMessages(messages: Message[], topic: string) {
+  const mdText =
+    `# ${topic}\n\n` +
+    messages
+      .map((m) => {
+        return m.role === "user" ? `## ${m.content}` : 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)}
+      />,
+    ],
+  });
+}
+
+function PromptToast(props: {
+  showModal: boolean;
+  setShowModal: (_: boolean) => void;
+}) {
+  const chatStore = useChatStore();
+  const session = chatStore.currentSession();
+  const context = session.context;
+
+  const addContextPrompt = (prompt: Message) => {
+    chatStore.updateCurrentSession((session) => {
+      session.context.push(prompt);
+    });
+  };
+
+  const removeContextPrompt = (i: number) => {
+    chatStore.updateCurrentSession((session) => {
+      session.context.splice(i, 1);
+    });
+  };
+
+  const updateContextPrompt = (i: number, prompt: Message) => {
+    chatStore.updateCurrentSession((session) => {
+      session.context[i] = prompt;
+    });
+  };
+
+  return (
+    <div className={chatStyle["prompt-toast"]} key="prompt-toast">
+      <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 && (
+        <div className="modal-mask">
+          <Modal
+            title={Locale.Context.Edit}
+            onClose={() => props.setShowModal(false)}
+            actions={[
+              <IconButton
+                key="copy"
+                icon={<CopyIcon />}
+                bordered
+                text={Locale.Memory.Copy}
+                onClick={() => copyToClipboard(session.memoryPrompt)}
+              />,
+            ]}
+          >
+            <>
+              {" "}
+              <div className={chatStyle["context-prompt"]}>
+                {context.map((c, i) => (
+                  <div className={chatStyle["context-prompt-row"]} key={i}>
+                    <select
+                      value={c.role}
+                      className={chatStyle["context-role"]}
+                      onChange={(e) =>
+                        updateContextPrompt(i, {
+                          ...c,
+                          role: e.target.value as any,
+                        })
+                      }
+                    >
+                      {ROLES.map((r) => (
+                        <option key={r} value={r}>
+                          {r}
+                        </option>
+                      ))}
+                    </select>
+                    <input
+                      value={c.content}
+                      type="text"
+                      className={chatStyle["context-content"]}
+                      onChange={(e) =>
+                        updateContextPrompt(i, {
+                          ...c,
+                          content: e.target.value as any,
+                        })
+                      }
+                    ></input>
+                    <IconButton
+                      icon={<DeleteIcon />}
+                      className={chatStyle["context-delete-button"]}
+                      onClick={() => removeContextPrompt(i)}
+                    />
+                  </div>
+                ))}
+
+                <div className={chatStyle["context-prompt-row"]}>
+                  <IconButton
+                    icon={<AddIcon />}
+                    text={Locale.Context.Add}
+                    bordered
+                    className={chatStyle["context-prompt-button"]}
+                    onClick={() =>
+                      addContextPrompt({
+                        role: "system",
+                        content: "",
+                        date: "",
+                      })
+                    }
+                  />
+                </div>
+              </div>
+              <div className={chatStyle["memory-prompt"]}>
+                <div className={chatStyle["memory-prompt-title"]}>
+                  {Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
+                  {session.messages.length})
+                </div>
+                <div className={chatStyle["memory-prompt-content"]}>
+                  {session.memoryPrompt || Locale.Memory.EmptyContent}
+                </div>
+              </div>
+            </>
+          </Modal>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function useSubmitHandler() {
+  const config = useChatStore((state) => state.config);
+  const submitKey = config.submitKey;
+
+  const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key !== "Enter") return false;
+    if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
+    return (
+      (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
+      (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
+      (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
+      (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
+      (config.submitKey === SubmitKey.Enter &&
+        !e.altKey &&
+        !e.ctrlKey &&
+        !e.shiftKey &&
+        !e.metaKey)
+    );
+  };
+
+  return {
+    submitKey,
+    shouldSubmit,
+  };
+}
+
+export function PromptHints(props: {
+  prompts: Prompt[];
+  onPromptSelect: (prompt: Prompt) => void;
+}) {
+  if (props.prompts.length === 0) return null;
+
+  return (
+    <div className={styles["prompt-hints"]}>
+      {props.prompts.map((prompt, i) => (
+        <div
+          className={styles["prompt-hint"]}
+          key={prompt.title + i.toString()}
+          onClick={() => props.onPromptSelect(prompt)}
+        >
+          <div className={styles["hint-title"]}>{prompt.title}</div>
+          <div className={styles["hint-content"]}>{prompt.content}</div>
+        </div>
+      ))}
+    </div>
+  );
+}
+
+function useScrollToBottom() {
+  // for auto-scroll
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const [autoScroll, setAutoScroll] = useState(true);
+
+  // auto scroll
+  useLayoutEffect(() => {
+    const dom = scrollRef.current;
+    if (dom && autoScroll) {
+      setTimeout(() => (dom.scrollTop = dom.scrollHeight), 500);
+    }
+  });
+
+  return {
+    scrollRef,
+    autoScroll,
+    setAutoScroll,
+  };
+}
+
+export function Chat(props: {
+  showSideBar?: () => void;
+  sideBarShowing?: boolean;
+}) {
+  type RenderMessage = Message & { preview?: boolean };
+
+  const chatStore = useChatStore();
+  const [session, sessionIndex] = useChatStore((state) => [
+    state.currentSession(),
+    state.currentSessionIndex,
+  ]);
+  const fontSize = useChatStore((state) => state.config.fontSize);
+
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const [userInput, setUserInput] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const { submitKey, shouldSubmit } = useSubmitHandler();
+  const { scrollRef, setAutoScroll } = useScrollToBottom();
+
+  // 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) => {
+    setUserInput(prompt.content);
+    setPromptHints([]);
+    inputRef.current?.focus();
+  };
+
+  const scrollInput = () => {
+    const dom = inputRef.current;
+    if (!dom) return;
+    const paddingBottomNum: number = parseInt(
+      window.getComputedStyle(dom).paddingBottom,
+      10,
+    );
+    dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
+  };
+
+  // only search prompts when user input is short
+  const SEARCH_TEXT_LIMIT = 30;
+  const onInput = (text: string) => {
+    scrollInput();
+    setUserInput(text);
+    const n = text.trim().length;
+
+    // clear search results
+    if (n === 0) {
+      setPromptHints([]);
+    } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
+      // check if need to trigger auto completion
+      if (text.startsWith("/")) {
+        let searchText = text.slice(1);
+        if (searchText.length === 0) {
+          searchText = " ";
+        }
+        onSearch(searchText);
+      }
+    }
+  };
+
+  // submit user input
+  const onUserSubmit = () => {
+    if (userInput.length <= 0) return;
+    setIsLoading(true);
+    chatStore.onUserInput(userInput).then(() => setIsLoading(false));
+    setUserInput("");
+    setPromptHints([]);
+    inputRef.current?.focus();
+  };
+
+  // stop response
+  const onUserStop = (messageIndex: number) => {
+    ControllerPool.stop(sessionIndex, messageIndex);
+  };
+
+  // check if should send message
+  const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (shouldSubmit(e)) {
+      onUserSubmit();
+      e.preventDefault();
+    }
+  };
+  const onRightClick = (e: any, message: Message) => {
+    // auto fill user input
+    if (message.role === "user") {
+      setUserInput(message.content);
+    }
+
+    // copy to clipboard
+    if (selectOrCopy(e.currentTarget, message.content)) {
+      e.preventDefault();
+    }
+  };
+
+  const onResend = (botIndex: number) => {
+    // find last user input message and resend
+    for (let i = botIndex; i >= 0; i -= 1) {
+      if (messages[i].role === "user") {
+        setIsLoading(true);
+        chatStore
+          .onUserInput(messages[i].content)
+          .then(() => setIsLoading(false));
+        inputRef.current?.focus();
+        return;
+      }
+    }
+  };
+
+  const config = useChatStore((state) => state.config);
+
+  const context: RenderMessage[] = session.context.slice();
+
+  if (
+    context.length === 0 &&
+    session.messages.at(0)?.content !== BOT_HELLO.content
+  ) {
+    context.push(BOT_HELLO);
+  }
+
+  // preview messages
+  const messages = context
+    .concat(session.messages as RenderMessage[])
+    .concat(
+      isLoading
+        ? [
+            {
+              role: "assistant",
+              content: "……",
+              date: new Date().toLocaleString(),
+              preview: true,
+            },
+          ]
+        : [],
+    )
+    .concat(
+      userInput.length > 0 && config.sendPreviewBubble
+        ? [
+            {
+              role: "user",
+              content: userInput,
+              date: new Date().toLocaleString(),
+              preview: false,
+            },
+          ]
+        : [],
+    );
+
+  const [showPromptModal, setShowPromptModal] = useState(false);
+
+  return (
+    <div className={styles.chat} key={session.id}>
+      <div className={styles["window-header"]}>
+        <div
+          className={styles["window-header-title"]}
+          onClick={props?.showSideBar}
+        >
+          <div
+            className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
+            onClick={() => {
+              const newTopic = prompt(Locale.Chat.Rename, session.topic);
+              if (newTopic && newTopic !== session.topic) {
+                chatStore.updateCurrentSession(
+                  (session) => (session.topic = newTopic!),
+                );
+              }
+            }}
+          >
+            {session.topic}
+          </div>
+          <div className={styles["window-header-sub-title"]}>
+            {Locale.Chat.SubTitle(session.messages.length)}
+          </div>
+        </div>
+        <div className={styles["window-actions"]}>
+          <div className={styles["window-action-button"] + " " + styles.mobile}>
+            <IconButton
+              icon={<MenuIcon />}
+              bordered
+              title={Locale.Chat.Actions.ChatList}
+              onClick={props?.showSideBar}
+            />
+          </div>
+          <div className={styles["window-action-button"]}>
+            <IconButton
+              icon={<BrainIcon />}
+              bordered
+              title={Locale.Chat.Actions.CompressedHistory}
+              onClick={() => {
+                setShowPromptModal(true);
+              }}
+            />
+          </div>
+          <div className={styles["window-action-button"]}>
+            <IconButton
+              icon={<ExportIcon />}
+              bordered
+              title={Locale.Chat.Actions.Export}
+              onClick={() => {
+                exportMessages(session.messages, session.topic);
+              }}
+            />
+          </div>
+        </div>
+
+        <PromptToast
+          showModal={showPromptModal}
+          setShowModal={setShowPromptModal}
+        />
+      </div>
+
+      <div className={styles["chat-body"]} ref={scrollRef}>
+        {messages.map((message, i) => {
+          const isUser = message.role === "user";
+
+          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"]}>
+                  <Avatar role={message.role} />
+                </div>
+                {(message.preview || message.streaming) && (
+                  <div className={styles["chat-message-status"]}>
+                    {Locale.Chat.Typing}
+                  </div>
+                )}
+                <div
+                  className={styles["chat-message-item"]}
+                  onMouseOver={() => inputRef.current?.blur()}
+                >
+                  {!isUser &&
+                    !(message.preview || message.content.length === 0) && (
+                      <div className={styles["chat-message-top-actions"]}>
+                        {message.streaming ? (
+                          <div
+                            className={styles["chat-message-top-action"]}
+                            onClick={() => onUserStop(i)}
+                          >
+                            {Locale.Chat.Actions.Stop}
+                          </div>
+                        ) : (
+                          <div
+                            className={styles["chat-message-top-action"]}
+                            onClick={() => onResend(i)}
+                          >
+                            {Locale.Chat.Actions.Retry}
+                          </div>
+                        )}
+
+                        <div
+                          className={styles["chat-message-top-action"]}
+                          onClick={() => copyToClipboard(message.content)}
+                        >
+                          {Locale.Chat.Actions.Copy}
+                        </div>
+                      </div>
+                    )}
+                  {(message.preview || message.content.length === 0) &&
+                  !isUser ? (
+                    <LoadingIcon />
+                  ) : (
+                    <div
+                      className="markdown-body"
+                      style={{ fontSize: `${fontSize}px` }}
+                      onContextMenu={(e) => onRightClick(e, message)}
+                      onDoubleClickCapture={() => {
+                        if (!isMobileScreen()) return;
+                        setUserInput(message.content);
+                      }}
+                    >
+                      <Markdown content={message.content} />
+                    </div>
+                  )}
+                </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} />
+        <div className={styles["chat-input-panel-inner"]}>
+          <textarea
+            ref={inputRef}
+            className={styles["chat-input"]}
+            placeholder={Locale.Chat.Input(submitKey)}
+            rows={2}
+            onInput={(e) => onInput(e.currentTarget.value)}
+            value={userInput}
+            onKeyDown={onInputKeyDown}
+            onFocus={() => setAutoScroll(true)}
+            onBlur={() => {
+              setAutoScroll(false);
+              setTimeout(() => setPromptHints([]), 500);
+            }}
+            autoFocus={!props?.sideBarShowing}
+          />
+          <IconButton
+            icon={<SendWhiteIcon />}
+            text={Locale.Chat.Send}
+            className={styles["chat-input-send"] + " no-dark"}
+            onClick={onUserSubmit}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -218,6 +218,7 @@
   flex: 1;
   overflow: auto;
   padding: 20px;
+  position: relative;
 }
 
 .chat-body-title {

+ 13 - 531
app/components/home.tsx

@@ -1,7 +1,6 @@
 "use client";
 
 import { useState, useRef, useEffect, useLayoutEffect } from "react";
-import { useDebouncedCallback } from "use-debounce";
 
 import { IconButton } from "./button";
 import styles from "./home.module.scss";
@@ -9,33 +8,31 @@ import styles from "./home.module.scss";
 import SettingsIcon from "../icons/settings.svg";
 import GithubIcon from "../icons/github.svg";
 import ChatGptIcon from "../icons/chatgpt.svg";
-import SendWhiteIcon from "../icons/send-white.svg";
-import BrainIcon from "../icons/brain.svg";
-import ExportIcon from "../icons/export.svg";
+
 import BotIcon from "../icons/bot.svg";
 import AddIcon from "../icons/add.svg";
-import DeleteIcon from "../icons/delete.svg";
 import LoadingIcon from "../icons/three-dots.svg";
-import MenuIcon from "../icons/menu.svg";
 import CloseIcon from "../icons/close.svg";
-import CopyIcon from "../icons/copy.svg";
-import DownloadIcon from "../icons/download.svg";
 
-import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
-import { showModal, showToast } from "./ui-lib";
+import {
+  Message,
+  SubmitKey,
+  useChatStore,
+  ChatSession,
+  BOT_HELLO,
+} from "../store";
 import {
   copyToClipboard,
   downloadAs,
-  isIOS,
   isMobileScreen,
   selectOrCopy,
 } from "../utils";
 import Locale from "../locales";
+import { ChatList } from "./chat-list";
+import { Chat } from "./chat";
 
 import dynamic from "next/dynamic";
 import { REPO_URL } from "../constant";
-import { ControllerPool } from "../requests";
-import { Prompt, usePromptStore } from "../store/prompt";
 
 export function Loading(props: { noLogo?: boolean }) {
   return (
@@ -46,469 +43,10 @@ export function Loading(props: { noLogo?: boolean }) {
   );
 }
 
-const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
-  loading: () => <LoadingIcon />,
-});
-
 const Settings = dynamic(async () => (await import("./settings")).Settings, {
   loading: () => <Loading noLogo />,
 });
 
-const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
-  loading: () => <LoadingIcon />,
-});
-
-export function Avatar(props: { role: Message["role"] }) {
-  const config = useChatStore((state) => state.config);
-
-  if (props.role === "assistant") {
-    return <BotIcon className={styles["user-avtar"]} />;
-  }
-
-  return (
-    <div className={styles["user-avtar"]}>
-      <Emoji unified={config.avatar} size={18} />
-    </div>
-  );
-}
-
-export function ChatItem(props: {
-  onClick?: () => void;
-  onDelete?: () => void;
-  title: string;
-  count: number;
-  time: string;
-  selected: boolean;
-}) {
-  return (
-    <div
-      className={`${styles["chat-item"]} ${
-        props.selected && styles["chat-item-selected"]
-      }`}
-      onClick={props.onClick}
-    >
-      <div className={styles["chat-item-title"]}>{props.title}</div>
-      <div className={styles["chat-item-info"]}>
-        <div className={styles["chat-item-count"]}>
-          {Locale.ChatItem.ChatItemCount(props.count)}
-        </div>
-        <div className={styles["chat-item-date"]}>{props.time}</div>
-      </div>
-      <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
-        <DeleteIcon />
-      </div>
-    </div>
-  );
-}
-
-export function ChatList() {
-  const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
-    (state) => [
-      state.sessions,
-      state.currentSessionIndex,
-      state.selectSession,
-      state.removeSession,
-    ],
-  );
-
-  return (
-    <div className={styles["chat-list"]}>
-      {sessions.map((item, i) => (
-        <ChatItem
-          title={item.topic}
-          time={item.lastUpdate}
-          count={item.messages.length}
-          key={i}
-          selected={i === selectedIndex}
-          onClick={() => selectSession(i)}
-          onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)}
-        />
-      ))}
-    </div>
-  );
-}
-
-function useSubmitHandler() {
-  const config = useChatStore((state) => state.config);
-  const submitKey = config.submitKey;
-
-  const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
-    if (e.key !== "Enter") return false;
-    if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
-    return (
-      (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
-      (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
-      (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
-      (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
-      (config.submitKey === SubmitKey.Enter &&
-        !e.altKey &&
-        !e.ctrlKey &&
-        !e.shiftKey &&
-        !e.metaKey)
-    );
-  };
-
-  return {
-    submitKey,
-    shouldSubmit,
-  };
-}
-
-export function PromptHints(props: {
-  prompts: Prompt[];
-  onPromptSelect: (prompt: Prompt) => void;
-}) {
-  if (props.prompts.length === 0) return null;
-
-  return (
-    <div className={styles["prompt-hints"]}>
-      {props.prompts.map((prompt, i) => (
-        <div
-          className={styles["prompt-hint"]}
-          key={prompt.title + i.toString()}
-          onClick={() => props.onPromptSelect(prompt)}
-        >
-          <div className={styles["hint-title"]}>{prompt.title}</div>
-          <div className={styles["hint-content"]}>{prompt.content}</div>
-        </div>
-      ))}
-    </div>
-  );
-}
-
-export function Chat(props: {
-  showSideBar?: () => void;
-  sideBarShowing?: boolean;
-}) {
-  type RenderMessage = Message & { preview?: boolean };
-
-  const chatStore = useChatStore();
-  const [session, sessionIndex] = useChatStore((state) => [
-    state.currentSession(),
-    state.currentSessionIndex,
-  ]);
-  const fontSize = useChatStore((state) => state.config.fontSize);
-
-  const inputRef = useRef<HTMLTextAreaElement>(null);
-  const [userInput, setUserInput] = useState("");
-  const [isLoading, setIsLoading] = useState(false);
-  const { submitKey, shouldSubmit } = useSubmitHandler();
-
-  // 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) => {
-    setUserInput(prompt.content);
-    setPromptHints([]);
-    inputRef.current?.focus();
-  };
-
-  const scrollInput = () => {
-    const dom = inputRef.current;
-    if (!dom) return;
-    const paddingBottomNum: number = parseInt(
-      window.getComputedStyle(dom).paddingBottom,
-      10,
-    );
-    dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
-  };
-
-  // only search prompts when user input is short
-  const SEARCH_TEXT_LIMIT = 30;
-  const onInput = (text: string) => {
-    scrollInput();
-    setUserInput(text);
-    const n = text.trim().length;
-
-    // clear search results
-    if (n === 0) {
-      setPromptHints([]);
-    } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
-      // check if need to trigger auto completion
-      if (text.startsWith("/") && text.length > 1) {
-        onSearch(text.slice(1));
-      }
-    }
-  };
-
-  // submit user input
-  const onUserSubmit = () => {
-    if (userInput.length <= 0) return;
-    setIsLoading(true);
-    chatStore.onUserInput(userInput).then(() => setIsLoading(false));
-    setUserInput("");
-    setPromptHints([]);
-    inputRef.current?.focus();
-  };
-
-  // stop response
-  const onUserStop = (messageIndex: number) => {
-    console.log(ControllerPool, sessionIndex, messageIndex);
-    ControllerPool.stop(sessionIndex, messageIndex);
-  };
-
-  // check if should send message
-  const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
-    if (shouldSubmit(e)) {
-      onUserSubmit();
-      e.preventDefault();
-    }
-  };
-  const onRightClick = (e: any, message: Message) => {
-    // auto fill user input
-    if (message.role === "user") {
-      setUserInput(message.content);
-    }
-
-    // copy to clipboard
-    if (selectOrCopy(e.currentTarget, message.content)) {
-      e.preventDefault();
-    }
-  };
-
-  const onResend = (botIndex: number) => {
-    // find last user input message and resend
-    for (let i = botIndex; i >= 0; i -= 1) {
-      if (messages[i].role === "user") {
-        setIsLoading(true);
-        chatStore
-          .onUserInput(messages[i].content)
-          .then(() => setIsLoading(false));
-        inputRef.current?.focus();
-        return;
-      }
-    }
-  };
-
-  // for auto-scroll
-  const latestMessageRef = useRef<HTMLDivElement>(null);
-  const [autoScroll, setAutoScroll] = useState(true);
-
-  const config = useChatStore((state) => state.config);
-
-  // preview messages
-  const messages = (session.messages as RenderMessage[])
-    .concat(
-      isLoading
-        ? [
-            {
-              role: "assistant",
-              content: "……",
-              date: new Date().toLocaleString(),
-              preview: true,
-            },
-          ]
-        : [],
-    ).concat(
-        userInput.length > 0 && config.sendPreviewBubble
-          ? [
-              {
-                role: "user",
-                content: userInput,
-                date: new Date().toLocaleString(),
-                preview: false,
-              },
-            ]
-          : [],
-    ); 
-
-  // auto scroll
-  useLayoutEffect(() => {
-    setTimeout(() => {
-      const dom = latestMessageRef.current;
-      const inputDom = inputRef.current;
-
-      // only scroll when input overlaped message body
-      let shouldScroll = true;
-      if (dom && inputDom) {
-        const domRect = dom.getBoundingClientRect();
-        const inputRect = inputDom.getBoundingClientRect();
-        shouldScroll = domRect.top > inputRect.top;
-      }
-
-      if (dom && autoScroll && shouldScroll) {
-        dom.scrollIntoView({
-          block: "end",
-        });
-      }
-    }, 500);
-  });
-
-  return (
-    <div className={styles.chat} key={session.id}>
-      <div className={styles["window-header"]}>
-        <div
-          className={styles["window-header-title"]}
-          onClick={props?.showSideBar}
-        >
-          <div
-            className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
-            onClick={() => {
-              const newTopic = prompt(Locale.Chat.Rename, session.topic);
-              if (newTopic && newTopic !== session.topic) {
-                chatStore.updateCurrentSession(
-                  (session) => (session.topic = newTopic!),
-                );
-              }
-            }}
-          >
-            {session.topic}
-          </div>
-          <div className={styles["window-header-sub-title"]}>
-            {Locale.Chat.SubTitle(session.messages.length)}
-          </div>
-        </div>
-        <div className={styles["window-actions"]}>
-          <div className={styles["window-action-button"] + " " + styles.mobile}>
-            <IconButton
-              icon={<MenuIcon />}
-              bordered
-              title={Locale.Chat.Actions.ChatList}
-              onClick={props?.showSideBar}
-            />
-          </div>
-          <div className={styles["window-action-button"]}>
-            <IconButton
-              icon={<BrainIcon />}
-              bordered
-              title={Locale.Chat.Actions.CompressedHistory}
-              onClick={() => {
-                showMemoryPrompt(session);
-              }}
-            />
-          </div>
-          <div className={styles["window-action-button"]}>
-            <IconButton
-              icon={<ExportIcon />}
-              bordered
-              title={Locale.Chat.Actions.Export}
-              onClick={() => {
-                exportMessages(session.messages, session.topic);
-              }}
-            />
-          </div>
-        </div>
-      </div>
-
-      <div className={styles["chat-body"]}>
-        {messages.map((message, i) => {
-          const isUser = message.role === "user";
-
-          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"]}>
-                  <Avatar role={message.role} />
-                </div>
-                {(message.preview || message.streaming) && (
-                  <div className={styles["chat-message-status"]}>
-                    {Locale.Chat.Typing}
-                  </div>
-                )}
-                <div className={styles["chat-message-item"]}>
-                  {!isUser &&
-                    !(message.preview || message.content.length === 0) && (
-                      <div className={styles["chat-message-top-actions"]}>
-                        {message.streaming ? (
-                          <div
-                            className={styles["chat-message-top-action"]}
-                            onClick={() => onUserStop(i)}
-                          >
-                            {Locale.Chat.Actions.Stop}
-                          </div>
-                        ) : (
-                          <div
-                            className={styles["chat-message-top-action"]}
-                            onClick={() => onResend(i)}
-                          >
-                            {Locale.Chat.Actions.Retry}
-                          </div>
-                        )}
-
-                        <div
-                          className={styles["chat-message-top-action"]}
-                          onClick={() => copyToClipboard(message.content)}
-                        >
-                          {Locale.Chat.Actions.Copy}
-                        </div>
-                      </div>
-                    )}
-                  {(message.preview || message.content.length === 0) &&
-                  !isUser ? (
-                    <LoadingIcon />
-                  ) : (
-                    <div
-                      className="markdown-body"
-                      style={{ fontSize: `${fontSize}px` }}
-                      onContextMenu={(e) => onRightClick(e, message)}
-                      onDoubleClickCapture={() => {
-                        if (!isMobileScreen()) return;
-                        setUserInput(message.content);
-                      }}
-                    >
-                      <Markdown content={message.content} />
-                    </div>
-                  )}
-                </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 ref={latestMessageRef} style={{ opacity: 0, height: "1px" }}>
-          -
-        </div>
-      </div>
-
-      <div className={styles["chat-input-panel"]}>
-        <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
-        <div className={styles["chat-input-panel-inner"]}>
-          <textarea
-            ref={inputRef}
-            className={styles["chat-input"]}
-            placeholder={Locale.Chat.Input(submitKey)}
-            rows={4}
-            onInput={(e) => onInput(e.currentTarget.value)}
-            value={userInput}
-            onKeyDown={onInputKeyDown}
-            onFocus={() => setAutoScroll(true)}
-            onBlur={() => {
-              setAutoScroll(false);
-              setTimeout(() => setPromptHints([]), 500);
-            }}
-            autoFocus={!props?.sideBarShowing}
-          />
-          <IconButton
-            icon={<SendWhiteIcon />}
-            text={Locale.Chat.Send}
-            className={styles["chat-input-send"] + " no-dark"}
-            onClick={onUserSubmit}
-          />
-        </div>
-      </div>
-    </div>
-  );
-}
-
 function useSwitchTheme() {
   const config = useChatStore((state) => state.config);
 
@@ -530,64 +68,6 @@ function useSwitchTheme() {
   }, [config.theme]);
 }
 
-function exportMessages(messages: Message[], topic: string) {
-  const mdText =
-    `# ${topic}\n\n` +
-    messages
-      .map((m) => {
-        return m.role === "user" ? `## ${m.content}` : 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)}
-      />,
-    ],
-  });
-}
-
-function showMemoryPrompt(session: ChatSession) {
-  showModal({
-    title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
-    children: (
-      <div className="markdown-body">
-        <pre className={styles["export-content"]}>
-          {session.memoryPrompt || Locale.Memory.EmptyContent}
-        </pre>
-      </div>
-    ),
-    actions: [
-      <IconButton
-        key="copy"
-        icon={<CopyIcon />}
-        bordered
-        text={Locale.Memory.Copy}
-        onClick={() => copyToClipboard(session.memoryPrompt)}
-      />,
-    ],
-  });
-}
-
 const useHasHydrated = () => {
   const [hasHydrated, setHasHydrated] = useState<boolean>(false);
 
@@ -669,11 +149,12 @@ export function Home() {
                   setOpenSettings(true);
                   setShowSideBar(false);
                 }}
+                shadow
               />
             </div>
             <div className={styles["sidebar-action"]}>
               <a href={REPO_URL} target="_blank">
-                <IconButton icon={<GithubIcon />} />
+                <IconButton icon={<GithubIcon />} shadow />
               </a>
             </div>
           </div>
@@ -685,6 +166,7 @@ export function Home() {
                 createNewSession();
                 setShowSideBar(false);
               }}
+              shadow
             />
           </div>
         </div>

+ 35 - 3
app/components/markdown.tsx

@@ -4,8 +4,8 @@ import RemarkMath from "remark-math";
 import RemarkBreaks from "remark-breaks";
 import RehypeKatex from "rehype-katex";
 import RemarkGfm from "remark-gfm";
-import RehypePrsim from "rehype-prism-plus";
-import { useRef } from "react";
+import RehypeHighlight from "rehype-highlight";
+import { useRef, useState, RefObject, useEffect } from "react";
 import { copyToClipboard } from "../utils";
 
 export function PreCode(props: { children: any }) {
@@ -27,11 +27,43 @@ export function PreCode(props: { children: any }) {
   );
 }
 
+const useLazyLoad = (ref: RefObject<Element>): boolean => {
+  const [isIntersecting, setIntersecting] = useState<boolean>(false);
+
+  useEffect(() => {
+    const observer = new IntersectionObserver(([entry]) => {
+      if (entry.isIntersecting) {
+        setIntersecting(true);
+        observer.disconnect();
+      }
+    });
+
+    if (ref.current) {
+      observer.observe(ref.current);
+    }
+
+    return () => {
+      observer.disconnect();
+    };
+  }, [ref]);
+
+  return isIntersecting;
+};
+
 export function Markdown(props: { content: string }) {
   return (
     <ReactMarkdown
       remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
-      rehypePlugins={[RehypeKatex, [RehypePrsim, { ignoreMissing: true }]]}
+      rehypePlugins={[
+        RehypeKatex,
+        [
+          RehypeHighlight,
+          {
+            detect: true,
+            ignoreMissing: true,
+          },
+        ],
+      ]}
       components={{
         pre: PreCode,
       }}

+ 5 - 9
app/components/settings.tsx

@@ -20,7 +20,7 @@ import {
   useUpdateStore,
   useAccessStore,
 } from "../store";
-import { Avatar, PromptHints } from "./home";
+import { Avatar } from "./chat";
 
 import Locale, { AllLangs, changeLang, getLang } from "../locales";
 import { getCurrentVersion } from "../utils";
@@ -72,7 +72,6 @@ export function Settings(props: { closeSettings: () => void }) {
   }
 
   const [usage, setUsage] = useState<{
-    granted?: number;
     used?: number;
   }>();
   const [loadingUsage, setLoadingUsage] = useState(false);
@@ -81,8 +80,7 @@ export function Settings(props: { closeSettings: () => void }) {
     requestUsage()
       .then((res) =>
         setUsage({
-          granted: res?.total_granted,
-          used: res?.total_used,
+          used: res,
         }),
       )
       .finally(() => {
@@ -285,7 +283,8 @@ export function Settings(props: { closeSettings: () => void }) {
               checked={config.sendPreviewBubble}
               onChange={(e) =>
                 updateConfig(
-                  (config) => (config.sendPreviewBubble = e.currentTarget.checked),
+                  (config) =>
+                    (config.sendPreviewBubble = e.currentTarget.checked),
                 )
               }
             ></input>
@@ -360,10 +359,7 @@ export function Settings(props: { closeSettings: () => void }) {
             subTitle={
               loadingUsage
                 ? Locale.Settings.Usage.IsChecking
-                : Locale.Settings.Usage.SubTitle(
-                    usage?.granted ?? "[?]",
-                    usage?.used ?? "[?]",
-                  )
+                : Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]")
             }
           >
             {loadingUsage ? (

+ 2 - 1
app/components/window.scss

@@ -1,6 +1,7 @@
 .window-header {
   padding: 14px 20px;
   border-bottom: rgba(0, 0, 0, 0.1) 1px solid;
+  position: relative;
 
   display: flex;
   justify-content: space-between;
@@ -32,4 +33,4 @@
 
 .window-action-button {
   margin-left: 10px;
-}
+}

+ 1 - 1
app/layout.tsx

@@ -1,7 +1,7 @@
 /* eslint-disable @next/next/no-page-custom-font */
 import "./styles/globals.scss";
 import "./styles/markdown.scss";
-import "./styles/prism.scss";
+import "./styles/highlight.scss";
 import process from "child_process";
 import { ACCESS_CODES, IS_IN_DOCKER } from "./api/access";
 

+ 8 - 3
app/locales/cn.ts

@@ -104,8 +104,8 @@ const cn = {
     },
     Usage: {
       Title: "账户余额",
-      SubTitle(granted: any, used: any) {
-        return `总共 $${granted},已使用 $${used}`;
+      SubTitle(used: any) {
+        return `本月已使用 $${used}`;
       },
       IsChecking: "正在检查…",
       Check: "重新检查",
@@ -139,7 +139,7 @@ const cn = {
       Topic:
         "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
       Summarize:
-        "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内",
+        "简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内",
     },
     ConfirmClearAll: "确认清除所有聊天、设置数据?",
   },
@@ -147,6 +147,11 @@ const cn = {
     Success: "已写入剪切板",
     Failed: "复制失败,请赋予剪切板权限",
   },
+  Context: {
+    Toast: (x: any) => `已设置 ${x} 条前置上下文`,
+    Edit: "前置上下文和历史记忆",
+    Add: "新增一条",
+  },
 };
 
 export type LocaleType = typeof cn;

+ 9 - 4
app/locales/en.ts

@@ -54,7 +54,7 @@ const en: LocaleType = {
       Close: "Close",
     },
     Lang: {
-      Name: "Language",
+      Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
       Options: {
         cn: "简体中文",
         en: "English",
@@ -106,8 +106,8 @@ const en: LocaleType = {
     },
     Usage: {
       Title: "Account Balance",
-      SubTitle(granted: any, used: any) {
-        return `Total $${granted}, Used $${used}`;
+      SubTitle(used: any) {
+        return `Used this month $${used}`;
       },
       IsChecking: "Checking...",
       Check: "Check Again",
@@ -143,7 +143,7 @@ const en: LocaleType = {
       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 50 characters or less to use as a prompt for future context.",
+        "Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
     },
     ConfirmClearAll: "Confirm to clear all chat and setting data?",
   },
@@ -151,6 +151,11 @@ const en: LocaleType = {
     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 One",
+  },
 };
 
 export default en;

+ 9 - 4
app/locales/es.ts

@@ -79,7 +79,7 @@ const es: LocaleType = {
     SendKey: "Tecla de envío",
     Theme: "Tema",
     TightBorder: "Borde ajustado",
-    SendPreviewBubble: "Send preview bubble",
+    SendPreviewBubble: "Enviar burbuja de vista previa",
     Prompt: {
       Disable: {
         Title: "Desactivar autocompletado",
@@ -106,8 +106,8 @@ const es: LocaleType = {
     },
     Usage: {
       Title: "Saldo de la cuenta",
-      SubTitle(granted: any, used: any) {
-        return `Total $${granted}, Usado $${used}`;
+      SubTitle(used: any) {
+        return `Usado $${used}`;
       },
       IsChecking: "Comprobando...",
       Check: "Comprobar de nuevo",
@@ -143,7 +143,7 @@ const es: LocaleType = {
       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 50 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
+        "Resuma nuestra discusión brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
     },
     ConfirmClearAll:
       "¿Confirmar para borrar todos los datos de chat y configuración?",
@@ -153,6 +153,11 @@ const es: LocaleType = {
     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",
+  },
 };
 
 export default es;

+ 8 - 3
app/locales/tw.ts

@@ -104,8 +104,8 @@ const tw: LocaleType = {
     },
     Usage: {
       Title: "帳戶餘額",
-      SubTitle(granted: any, used: any) {
-        return `總共 $${granted},已使用 $${used}`;
+      SubTitle(used: any) {
+        return `本月已使用 $${used}`;
       },
       IsChecking: "正在檢查…",
       Check: "重新檢查",
@@ -138,7 +138,7 @@ const tw: LocaleType = {
         "這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
       Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
       Summarize:
-        "簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 50 字以內",
+        "簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 200 字以內",
     },
     ConfirmClearAll: "確認清除所有對話、設定數據?",
   },
@@ -146,6 +146,11 @@ const tw: LocaleType = {
     Success: "已複製到剪貼簿中",
     Failed: "複製失敗,請賦予剪貼簿權限",
   },
+  Context: {
+    Toast: (x: any) => `已設置 ${x} 條前置上下文`,
+    Edit: "前置上下文和歷史記憶",
+    Add: "新增壹條",
+  },
 };
 
 export default tw;

+ 3 - 0
app/page.tsx

@@ -1,4 +1,7 @@
 import { Analytics } from "@vercel/analytics/react";
+
+import "array.prototype.at";
+
 import { Home } from "./components/home";
 
 export default function App() {

+ 14 - 9
app/requests.ts

@@ -2,10 +2,6 @@ import type { ChatRequest, ChatReponse } from "./api/openai/typing";
 import { filterConfig, Message, ModelConfig, useAccessStore } from "./store";
 import Locale from "./locales";
 
-if (!Array.prototype.at) {
-  require("array.prototype.at/auto");
-}
-
 const TIME_OUT_MS = 30000;
 
 const makeRequestParam = (
@@ -52,6 +48,7 @@ export function requestOpenaiClient(path: string) {
       method,
       headers: {
         "Content-Type": "application/json",
+        "Cache-Control": "no-cache",
         path,
         ...getHeaders(),
       },
@@ -73,17 +70,25 @@ export async function requestChat(messages: Message[]) {
 }
 
 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 = 24 * 60 * 60 * 1000;
+  const now = new Date(Date.now() + ONE_DAY);
+  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+  const startDate = formatDate(startOfMonth);
+  const endDate = formatDate(now);
   const res = await requestOpenaiClient(
-    "dashboard/billing/credit_grants?_vercel_no_cache=1",
+    `dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
   )(null, "GET");
 
   try {
     const response = (await res.json()) as {
-      total_available: number;
-      total_granted: number;
-      total_used: number;
+      total_usage: number;
     };
-    return response;
+    return Math.round(response.total_usage) / 100;
   } catch (error) {
     console.error("[Request usage] ", error, res.body);
   }

+ 32 - 20
app/store/app.ts

@@ -11,10 +11,6 @@ import { trimTopic } from "../utils";
 
 import Locale from "../locales";
 
-if (!Array.prototype.at) {
-  require("array.prototype.at/auto");
-}
-
 export type Message = ChatCompletionResponseMessage & {
   date: string;
   streaming?: boolean;
@@ -57,6 +53,8 @@ export interface ChatConfig {
 
 export type ModelConfig = ChatConfig["modelConfig"];
 
+export const ROLES: Message["role"][] = ["system", "user", "assistant"];
+
 const ENABLE_GPT4 = true;
 
 export const ALL_MODELS = [
@@ -155,6 +153,7 @@ export interface ChatSession {
   id: number;
   topic: string;
   memoryPrompt: string;
+  context: Message[];
   messages: Message[];
   stat: ChatStat;
   lastUpdate: string;
@@ -162,6 +161,11 @@ export interface ChatSession {
 }
 
 const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
+export const BOT_HELLO: Message = {
+  role: "assistant",
+  content: Locale.Store.BotHello,
+  date: "",
+};
 
 function createEmptySession(): ChatSession {
   const createDate = new Date().toLocaleString();
@@ -170,13 +174,8 @@ function createEmptySession(): ChatSession {
     id: Date.now(),
     topic: DEFAULT_TOPIC,
     memoryPrompt: "",
-    messages: [
-      {
-        role: "assistant",
-        content: Locale.Store.BotHello,
-        date: createDate,
-      },
-    ],
+    context: [],
+    messages: [],
     stat: {
       tokenCount: 0,
       wordCount: 0,
@@ -385,16 +384,18 @@ export const useChatStore = create<ChatStore>()(
         const session = get().currentSession();
         const config = get().config;
         const n = session.messages.length;
-        const recentMessages = session.messages.slice(
-          Math.max(0, n - config.historyMessageCount),
-        );
 
-        const memoryPrompt = get().getMemoryPrompt();
+        const context = session.context.slice();
 
-        if (session.memoryPrompt) {
-          recentMessages.unshift(memoryPrompt);
+        if (session.memoryPrompt && session.memoryPrompt.length > 0) {
+          const memoryPrompt = get().getMemoryPrompt();
+          context.push(memoryPrompt);
         }
 
+        const recentMessages = context.concat(
+          session.messages.slice(Math.max(0, n - config.historyMessageCount)),
+        );
+
         return recentMessages;
       },
 
@@ -432,11 +433,13 @@ export const useChatStore = create<ChatStore>()(
         let toBeSummarizedMsgs = session.messages.slice(
           session.lastSummarizeIndex,
         );
+
         const historyMsgLength = countMessages(toBeSummarizedMsgs);
 
-        if (historyMsgLength > 4000) {
+        if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
+          const n = toBeSummarizedMsgs.length;
           toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
-            -config.historyMessageCount,
+            Math.max(0, n - config.historyMessageCount),
           );
         }
 
@@ -499,7 +502,16 @@ export const useChatStore = create<ChatStore>()(
     }),
     {
       name: LOCAL_KEY,
-      version: 1,
+      version: 1.1,
+      migrate(persistedState, version) {
+        const state = persistedState as ChatStore;
+
+        if (version === 1) {
+          state.sessions.forEach((s) => (s.context = []));
+        }
+
+        return state;
+      },
     },
   ),
 );

+ 15 - 2
app/styles/globals.scss

@@ -117,7 +117,7 @@ body {
 
 select {
   border: var(--border-in-light);
-  padding: 8px 10px;
+  padding: 10px;
   border-radius: 10px;
   appearance: none;
   cursor: pointer;
@@ -188,7 +188,7 @@ input[type="text"] {
   appearance: none;
   border-radius: 10px;
   border: var(--border-in-light);
-  height: 32px;
+  height: 36px;
   box-sizing: border-box;
   background: var(--white);
   color: var(--black);
@@ -235,6 +235,7 @@ pre {
   .copy-code-button {
     position: absolute;
     right: 10px;
+    top: 1em;
     cursor: pointer;
     padding: 0px 5px;
     background-color: var(--black);
@@ -255,3 +256,15 @@ pre {
     }
   }
 }
+
+.clickable {
+  cursor: pointer;
+
+  div:not(.no-dark) > svg {
+    filter: invert(0.5);
+  }
+
+  &:hover {
+    filter: brightness(0.9);
+  }
+}

+ 114 - 0
app/styles/highlight.scss

@@ -0,0 +1,114 @@
+.markdown-body {
+  pre {
+    padding: 0;
+  }
+
+  pre,
+  code {
+    font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
+  }
+
+  pre code.hljs {
+    display: block;
+    overflow-x: auto;
+    padding: 1em;
+  }
+
+  code.hljs {
+    padding: 3px 5px;
+  }
+
+  /*!
+  Theme: Tokyo-night-Dark
+  origin: https://github.com/enkia/tokyo-night-vscode-theme
+  Description: Original highlight.js style
+  Author: (c) Henri Vandersleyen <hvandersleyen@gmail.com>
+  License: see project LICENSE
+  Touched: 2022
+*/
+  .hljs-comment,
+  .hljs-meta {
+    color: #565f89;
+  }
+
+  .hljs-deletion,
+  .hljs-doctag,
+  .hljs-regexp,
+  .hljs-selector-attr,
+  .hljs-selector-class,
+  .hljs-selector-id,
+  .hljs-selector-pseudo,
+  .hljs-tag,
+  .hljs-template-tag,
+  .hljs-variable.language_ {
+    color: #f7768e;
+  }
+
+  .hljs-link,
+  .hljs-literal,
+  .hljs-number,
+  .hljs-params,
+  .hljs-template-variable,
+  .hljs-type,
+  .hljs-variable {
+    color: #ff9e64;
+  }
+
+  .hljs-attribute,
+  .hljs-built_in {
+    color: #e0af68;
+  }
+
+  .hljs-keyword,
+  .hljs-property,
+  .hljs-subst,
+  .hljs-title,
+  .hljs-title.class_,
+  .hljs-title.class_.inherited__,
+  .hljs-title.function_ {
+    color: #7dcfff;
+  }
+
+  .hljs-selector-tag {
+    color: #73daca;
+  }
+
+  .hljs-addition,
+  .hljs-bullet,
+  .hljs-quote,
+  .hljs-string,
+  .hljs-symbol {
+    color: #9ece6a;
+  }
+
+  .hljs-code,
+  .hljs-formula,
+  .hljs-section {
+    color: #7aa2f7;
+  }
+
+  .hljs-attr,
+  .hljs-char.escape_,
+  .hljs-keyword,
+  .hljs-name,
+  .hljs-operator {
+    color: #bb9af7;
+  }
+
+  .hljs-punctuation {
+    color: #c0caf5;
+  }
+
+  .hljs {
+    background: #1a1b26;
+    color: #9aa5ce;
+  }
+
+  .hljs-emphasis {
+    font-style: italic;
+  }
+
+  .hljs-strong {
+    font-weight: 700;
+  }
+}

+ 0 - 122
app/styles/prism.scss

@@ -1,122 +0,0 @@
-.markdown-body {
-  pre {
-    background: #282a36;
-    color: #f8f8f2;
-  }
-
-  code[class*="language-"],
-  pre[class*="language-"] {
-    color: #f8f8f2;
-    background: none;
-    text-shadow: 0 1px rgba(0, 0, 0, 0.3);
-    font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
-    text-align: left;
-    white-space: pre;
-    word-spacing: normal;
-    word-break: normal;
-    word-wrap: normal;
-    line-height: 1.5;
-    -moz-tab-size: 4;
-    -o-tab-size: 4;
-    tab-size: 4;
-    -webkit-hyphens: none;
-    -moz-hyphens: none;
-    -ms-hyphens: none;
-    hyphens: none;
-  }
-
-  /* Code blocks */
-  pre[class*="language-"] {
-    padding: 1em;
-    margin: 0.5em 0;
-    overflow: auto;
-    border-radius: 0.3em;
-  }
-
-  :not(pre) > code[class*="language-"],
-  pre[class*="language-"] {
-    background: #282a36;
-  }
-
-  /* Inline code */
-  :not(pre) > code[class*="language-"] {
-    padding: 0.1em;
-    border-radius: 0.3em;
-    white-space: normal;
-  }
-
-  .token.comment,
-  .token.prolog,
-  .token.doctype,
-  .token.cdata {
-    color: #6272a4;
-  }
-
-  .token.punctuation {
-    color: #f8f8f2;
-  }
-
-  .namespace {
-    opacity: 0.7;
-  }
-
-  .token.property,
-  .token.tag,
-  .token.constant,
-  .token.symbol,
-  .token.deleted {
-    color: #ff79c6;
-  }
-
-  .token.boolean,
-  .token.number {
-    color: #bd93f9;
-  }
-
-  .token.selector,
-  .token.attr-name,
-  .token.string,
-  .token.char,
-  .token.builtin,
-  .token.inserted {
-    color: #50fa7b;
-  }
-
-  .token.operator,
-  .token.entity,
-  .token.url,
-  .language-css .token.string,
-  .style .token.string,
-  .token.variable {
-    color: #f8f8f2;
-  }
-
-  .token.atrule,
-  .token.attr-value,
-  .token.function,
-  .token.class-name {
-    color: #f1fa8c;
-  }
-
-  .token.keyword {
-    color: #8be9fd;
-  }
-
-  .token.regex,
-  .token.important {
-    color: #ffb86c;
-  }
-
-  .token.important,
-  .token.bold {
-    font-weight: bold;
-  }
-
-  .token.italic {
-    font-style: italic;
-  }
-
-  .token.entity {
-    cursor: help;
-  }
-}

+ 2 - 2
package.json

@@ -14,6 +14,7 @@
   "dependencies": {
     "@svgr/webpack": "^6.5.1",
     "@vercel/analytics": "^0.1.11",
+    "array.prototype.at": "^1.1.1",
     "emoji-picker-react": "^4.4.7",
     "eventsource-parser": "^0.1.0",
     "fuse.js": "^6.6.2",
@@ -23,8 +24,8 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-markdown": "^8.0.5",
+    "rehype-highlight": "^6.0.0",
     "rehype-katex": "^6.0.2",
-    "rehype-prism-plus": "^1.5.1",
     "remark-breaks": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
@@ -39,7 +40,6 @@
     "@types/react-dom": "^18.0.11",
     "@types/react-katex": "^3.0.0",
     "@types/spark-md5": "^3.0.2",
-    "array.prototype.at": "^1.1.1",
     "cross-env": "^7.0.3",
     "eslint": "^8.36.0",
     "eslint-config-next": "13.2.3",

+ 2 - 0
public/serviceWorker.js

@@ -11,3 +11,5 @@ self.addEventListener("install", function (event) {
     }),
   );
 });
+
+self.addEventListener("fetch", (e) => {});

+ 38 - 1
yarn.lock

@@ -2548,6 +2548,13 @@ fastq@^1.6.0:
   dependencies:
     reusify "^1.0.4"
 
+fault@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c"
+  integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==
+  dependencies:
+    format "^0.2.0"
+
 fetch-blob@^3.1.2, fetch-blob@^3.1.4:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
@@ -2612,6 +2619,11 @@ form-data@^4.0.0:
     combined-stream "^1.0.8"
     mime-types "^2.1.12"
 
+format@^0.2.0:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
+  integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==
+
 formdata-polyfill@^4.0.10:
   version "4.0.10"
   resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
@@ -2874,7 +2886,7 @@ hast-util-to-string@^2.0.0:
   dependencies:
     "@types/hast" "^2.0.0"
 
-hast-util-to-text@^3.1.0:
+hast-util-to-text@^3.0.0, hast-util-to-text@^3.1.0:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-3.1.2.tgz#ecf30c47141f41e91a5d32d0b1e1859fd2ac04f2"
   integrity sha512-tcllLfp23dJJ+ju5wCCZHVpzsQQ43+moJbqVX3jNWPB7z/KFC4FyZD6R7y94cHL6MQ33YtMZL8Z0aIXXI4XFTw==
@@ -2900,6 +2912,11 @@ hastscript@^7.0.0:
     property-information "^6.0.0"
     space-separated-tokens "^2.0.0"
 
+highlight.js@~11.7.0:
+  version "11.7.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.7.0.tgz#3ff0165bc843f8c9bce1fd89e2fda9143d24b11e"
+  integrity sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==
+
 human-signals@^4.3.0:
   version "4.3.1"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"
@@ -3385,6 +3402,15 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
+lowlight@^2.0.0:
+  version "2.8.1"
+  resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-2.8.1.tgz#5f54016ebd1b2f66b3d0b94d10ef6dd5df4f2e42"
+  integrity sha512-HCaGL61RKc1MYzEYn3rFoGkK0yslzCVDFJEanR19rc2L0mb8i58XM55jSRbzp9jcQrFzschPlwooC0vuNitk8Q==
+  dependencies:
+    "@types/hast" "^2.0.0"
+    fault "^2.0.0"
+    highlight.js "~11.7.0"
+
 lru-cache@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -4374,6 +4400,17 @@ regjsparser@^0.9.1:
   dependencies:
     jsesc "~0.5.0"
 
+rehype-highlight@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/rehype-highlight/-/rehype-highlight-6.0.0.tgz#8097219d8813b51f4c2b6d92db27dac6cbc9a641"
+  integrity sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==
+  dependencies:
+    "@types/hast" "^2.0.0"
+    hast-util-to-text "^3.0.0"
+    lowlight "^2.0.0"
+    unified "^10.0.0"
+    unist-util-visit "^4.0.0"
+
 rehype-katex@^6.0.2:
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/rehype-katex/-/rehype-katex-6.0.2.tgz#20197bbc10bdf79f6b999bffa6689d7f17226c35"