From 16028795f91bb65c84362475b977271ac0df3243 Mon Sep 17 00:00:00 2001 From: Yifei Zhang Date: Sun, 2 Apr 2023 12:28:18 +0000 Subject: [PATCH 1/9] fix: #203 pwa installation problem --- public/serviceWorker.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/serviceWorker.js b/public/serviceWorker.js index 028c79a8..f5a24b70 100644 --- a/public/serviceWorker.js +++ b/public/serviceWorker.js @@ -11,3 +11,5 @@ self.addEventListener("install", function (event) { }), ); }); + +self.addEventListener("fetch", (e) => {}); From 37587f6f717eb5092f1c5e5fb5eabedd40f12c94 Mon Sep 17 00:00:00 2001 From: Yifei Zhang Date: Sun, 2 Apr 2023 13:56:34 +0000 Subject: [PATCH 2/9] fix: #244 optimize polyfill --- app/components/home.tsx | 33 ++++++++++++++++++++------------- app/page.tsx | 3 +++ app/requests.ts | 4 ---- app/store/app.ts | 17 ++++++----------- package.json | 2 +- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/app/components/home.tsx b/app/components/home.tsx index 7ed35dfb..8e4013e2 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -21,7 +21,13 @@ 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 { + Message, + SubmitKey, + useChatStore, + ChatSession, + BOT_HELLO, +} from "../store"; import { showModal, showToast } from "./ui-lib"; import { copyToClipboard, @@ -307,18 +313,19 @@ export function Chat(props: { }, ] : [], - ).concat( - userInput.length > 0 && config.sendPreviewBubble - ? [ - { - role: "user", - content: userInput, - date: new Date().toLocaleString(), - preview: false, - }, - ] - : [], - ); + ) + .concat( + userInput.length > 0 && config.sendPreviewBubble + ? [ + { + role: "user", + content: userInput, + date: new Date().toLocaleString(), + preview: false, + }, + ] + : [], + ); // auto scroll useLayoutEffect(() => { diff --git a/app/page.tsx b/app/page.tsx index 54300e71..2ad763ce 100644 --- a/app/page.tsx +++ b/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() { diff --git a/app/requests.ts b/app/requests.ts index a8ba4e9f..0be9dbf7 100644 --- a/app/requests.ts +++ b/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 = ( diff --git a/app/store/app.ts b/app/store/app.ts index e6327723..7c2b57f1 100644 --- a/app/store/app.ts +++ b/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; @@ -162,6 +158,11 @@ export interface ChatSession { } const DEFAULT_TOPIC = Locale.Store.DefaultTopic; +export const BOT_HELLO = { + role: "assistant", + content: Locale.Store.BotHello, + date: "", +}; function createEmptySession(): ChatSession { const createDate = new Date().toLocaleString(); @@ -170,13 +171,7 @@ function createEmptySession(): ChatSession { id: Date.now(), topic: DEFAULT_TOPIC, memoryPrompt: "", - messages: [ - { - role: "assistant", - content: Locale.Store.BotHello, - date: createDate, - }, - ], + messages: [], stat: { tokenCount: 0, wordCount: 0, diff --git a/package.json b/package.json index 7c6832ed..b67d7b0d 100644 --- a/package.json +++ b/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", @@ -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", From 7b5af271d501b2c8d85f438dfa358913b8da81ac Mon Sep 17 00:00:00 2001 From: Yifei Zhang Date: Sun, 2 Apr 2023 14:22:06 +0000 Subject: [PATCH 3/9] fix: #367 failed to fetch account usage --- app/components/settings.tsx | 12 ++++-------- app/locales/cn.ts | 4 ++-- app/locales/en.ts | 4 ++-- app/locales/es.ts | 4 ++-- app/locales/tw.ts | 4 ++-- app/requests.ts | 19 ++++++++++++++----- 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 8f015006..43959698 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -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), ) } > @@ -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 ? ( diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 66436e12..62be467b 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -103,8 +103,8 @@ const cn = { }, Usage: { Title: "账户余额", - SubTitle(granted: any, used: any) { - return `总共 $${granted},已使用 $${used}`; + SubTitle(used: any) { + return `本月已使用 $${used}`; }, IsChecking: "正在检查…", Check: "重新检查", diff --git a/app/locales/en.ts b/app/locales/en.ts index 55884308..98fa7404 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -105,8 +105,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", diff --git a/app/locales/es.ts b/app/locales/es.ts index a78bf1aa..fca7202d 100644 --- a/app/locales/es.ts +++ b/app/locales/es.ts @@ -105,8 +105,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", diff --git a/app/locales/tw.ts b/app/locales/tw.ts index 7137e884..27156283 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -103,8 +103,8 @@ const tw: LocaleType = { }, Usage: { Title: "帳戶餘額", - SubTitle(granted: any, used: any) { - return `總共 $${granted},已使用 $${used}`; + SubTitle(used: any) { + return `本月已使用 $${used}`; }, IsChecking: "正在檢查…", Check: "重新檢查", diff --git a/app/requests.ts b/app/requests.ts index 0be9dbf7..cf2ac7f7 100644 --- a/app/requests.ts +++ b/app/requests.ts @@ -48,6 +48,7 @@ export function requestOpenaiClient(path: string) { method, headers: { "Content-Type": "application/json", + "Cache-Control": "no-cache", path, ...getHeaders(), }, @@ -69,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); } From 4f0108b0eaa3fb1f06e3227c7f3ae9d22306621a Mon Sep 17 00:00:00 2001 From: Yifei Zhang Date: Sun, 2 Apr 2023 14:48:18 +0000 Subject: [PATCH 4/9] fix: #289 use highlight.js instead of prism --- app/components/markdown.tsx | 38 ++++++++++- app/layout.tsx | 2 +- app/styles/globals.scss | 1 + app/styles/highlight.scss | 114 +++++++++++++++++++++++++++++++++ app/styles/prism.scss | 122 ------------------------------------ package.json | 2 +- yarn.lock | 39 +++++++++++- 7 files changed, 190 insertions(+), 128 deletions(-) create mode 100644 app/styles/highlight.scss delete mode 100644 app/styles/prism.scss diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 6d3cd0bf..89492612 100644 --- a/app/components/markdown.tsx +++ b/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): boolean => { + const [isIntersecting, setIntersecting] = useState(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 ( + 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; + } +} diff --git a/app/styles/prism.scss b/app/styles/prism.scss deleted file mode 100644 index 65ee8b5f..00000000 --- a/app/styles/prism.scss +++ /dev/null @@ -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; - } -} diff --git a/package.json b/package.json index b67d7b0d..2e018647 100644 --- a/package.json +++ b/package.json @@ -24,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", diff --git a/yarn.lock b/yarn.lock index 246b818b..fd26bb00 100644 --- a/yarn.lock +++ b/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" From 6c1862797bb6d27c271d3cf0a3f80937e6f0c361 Mon Sep 17 00:00:00 2001 From: Yifei Zhang Date: Sun, 2 Apr 2023 15:05:54 +0000 Subject: [PATCH 5/9] refactor: split homt.tsx components --- app/components/chat-list.tsx | 69 +++++ app/components/chat.tsx | 499 ++++++++++++++++++++++++++++++++ app/components/home.tsx | 533 +---------------------------------- app/components/settings.tsx | 2 +- 4 files changed, 572 insertions(+), 531 deletions(-) create mode 100644 app/components/chat-list.tsx create mode 100644 app/components/chat.tsx diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx new file mode 100644 index 00000000..5a74ff15 --- /dev/null +++ b/app/components/chat-list.tsx @@ -0,0 +1,69 @@ +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"; + +export function ChatItem(props: { + onClick?: () => void; + onDelete?: () => void; + title: string; + count: number; + time: string; + selected: boolean; +}) { + return ( +
+
{props.title}
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
{props.time}
+
+
+ +
+
+ ); +} + +export function ChatList() { + const [sessions, selectedIndex, selectSession, removeSession] = useChatStore( + (state) => [ + state.sessions, + state.currentSessionIndex, + state.selectSession, + state.removeSession, + ], + ); + + return ( +
+ {sessions.map((item, i) => ( + selectSession(i)} + onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)} + /> + ))} +
+ ); +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx new file mode 100644 index 00000000..348fe2ea --- /dev/null +++ b/app/components/chat.tsx @@ -0,0 +1,499 @@ +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 { + Message, + SubmitKey, + useChatStore, + ChatSession, + BOT_HELLO, +} 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 { showModal, showToast } from "./ui-lib"; + +const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { + loading: () => , +}); + +const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { + loading: () => , +}); + +export function Avatar(props: { role: Message["role"] }) { + const config = useChatStore((state) => state.config); + + if (props.role === "assistant") { + return ; + } + + return ( +
+ +
+ ); +} + +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: ( +
+
{mdText}
+
+ ), + actions: [ + } + bordered + text={Locale.Export.Copy} + onClick={() => copyToClipboard(mdText)} + />, + } + 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: ( +
+
+          {session.memoryPrompt || Locale.Memory.EmptyContent}
+        
+
+ ), + actions: [ + } + bordered + text={Locale.Memory.Copy} + onClick={() => copyToClipboard(session.memoryPrompt)} + />, + ], + }); +} + +function useSubmitHandler() { + const config = useChatStore((state) => state.config); + const submitKey = config.submitKey; + + const shouldSubmit = (e: React.KeyboardEvent) => { + 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 ( +
+ {props.prompts.map((prompt, i) => ( +
props.onPromptSelect(prompt)} + > +
{prompt.title}
+
{prompt.content}
+
+ ))} +
+ ); +} + +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(null); + const [userInput, setUserInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const { submitKey, shouldSubmit } = useSubmitHandler(); + + // prompt hints + const promptStore = usePromptStore(); + const [promptHints, setPromptHints] = useState([]); + 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) => { + 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(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 ( +
+
+
+
{ + const newTopic = prompt(Locale.Chat.Rename, session.topic); + if (newTopic && newTopic !== session.topic) { + chatStore.updateCurrentSession( + (session) => (session.topic = newTopic!), + ); + } + }} + > + {session.topic} +
+
+ {Locale.Chat.SubTitle(session.messages.length)} +
+
+
+
+ } + bordered + title={Locale.Chat.Actions.ChatList} + onClick={props?.showSideBar} + /> +
+
+ } + bordered + title={Locale.Chat.Actions.CompressedHistory} + onClick={() => { + showMemoryPrompt(session); + }} + /> +
+
+ } + bordered + title={Locale.Chat.Actions.Export} + onClick={() => { + exportMessages(session.messages, session.topic); + }} + /> +
+
+
+ +
+ {messages.map((message, i) => { + const isUser = message.role === "user"; + + return ( +
+
+
+ +
+ {(message.preview || message.streaming) && ( +
+ {Locale.Chat.Typing} +
+ )} +
+ {!isUser && + !(message.preview || message.content.length === 0) && ( +
+ {message.streaming ? ( +
onUserStop(i)} + > + {Locale.Chat.Actions.Stop} +
+ ) : ( +
onResend(i)} + > + {Locale.Chat.Actions.Retry} +
+ )} + +
copyToClipboard(message.content)} + > + {Locale.Chat.Actions.Copy} +
+
+ )} + {(message.preview || message.content.length === 0) && + !isUser ? ( + + ) : ( +
onRightClick(e, message)} + onDoubleClickCapture={() => { + if (!isMobileScreen()) return; + setUserInput(message.content); + }} + > + +
+ )} +
+ {!isUser && !message.preview && ( +
+
+ {message.date.toLocaleString()} +
+
+ )} +
+
+ ); + })} +
+ - +
+
+ +
+ +
+