├── .docker ├── builder.dockerfile ├── nginx.conf └── release.dockerfile ├── .eslintrc.cjs ├── .github └── FUNDING.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .stackblitzrc ├── LICENSE ├── Makefile ├── README.md ├── README.zh-cn.md ├── electron ├── main.ts ├── preload.ts └── utils │ └── store.ts ├── i18n.config.ts ├── netlify.toml ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── scripts └── genLocaleKey.ts ├── server ├── api │ └── pageview.ts └── tsconfig.json ├── src ├── app.vue ├── assets │ ├── css │ │ └── globals.css │ └── scss │ │ ├── highlight.scss │ │ ├── index.scss │ │ └── markdown.scss ├── components │ ├── HeadIconButton.tsx │ ├── IconButton.tsx │ ├── MarkdownPreview.tsx │ ├── MaskCard.tsx │ ├── VChatList.tsx │ ├── VChatListCard.tsx │ ├── VChatMessage.tsx │ ├── VComposeView.tsx │ ├── VDetailHeader.tsx │ ├── VEmojiAvatar.tsx │ ├── VEmojiPicker.tsx │ ├── VSharePreview.tsx │ ├── VSidebar.tsx │ ├── VSvgIcon.tsx │ └── ui │ │ └── UIAvatar.vue ├── composables │ ├── access.ts │ ├── chat.ts │ ├── index.ts │ ├── locales.ts │ ├── mask.ts │ ├── prompt.ts │ ├── settings.ts │ ├── storage.ts │ ├── update.ts │ ├── useChatBot.ts │ ├── useGlobalCss.ts │ ├── useSidebar.ts │ └── user.ts ├── config │ └── pwa.ts ├── constants │ ├── index.ts │ └── typing.ts ├── layouts │ ├── README.md │ ├── custom.vue │ └── default.vue ├── locales │ ├── en.ts │ ├── schema.ts │ └── zh_CN.ts ├── pages │ ├── [...all].tsx │ ├── chat.tsx │ ├── chat │ │ ├── index.vue │ │ ├── masks │ │ │ ├── MasksHeader.tsx │ │ │ └── index.tsx │ │ ├── new.tsx │ │ ├── plugins.tsx │ │ ├── session.tsx │ │ ├── session │ │ │ └── [sid].tsx │ │ └── settings │ │ │ └── index.tsx │ ├── index.vue │ └── suspension.vue ├── public │ ├── apple-touch-icon.png │ ├── assets │ │ └── logo.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── icon-512.png │ ├── maskable-icon.png │ ├── nuxt.svg │ ├── prompts.json │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ └── robots.txt ├── typing │ ├── openai.d.ts │ └── vue-shim.d.ts └── utils │ ├── clipboard.ts │ ├── date.ts │ └── emoji.ts ├── tailwind.config.cjs └── tsconfig.json /.docker/builder.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16.1-slim 2 | WORKDIR /app 3 | RUN apt-get update \ 4 | && apt-get install -yq git curl gnupg wget build-essential make \ 5 | && apt-get clean \ 6 | && rm -rf /var/lib/apt/lists/* 7 | RUN npm i -g pnpm && pnpm install nuxi 8 | CMD ["make", "build"] -------------------------------------------------------------------------------- /.docker/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | root /app; 4 | index index.html; 5 | location / { 6 | try_files $uri $uri/ =404; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.docker/release.dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | ADD .docker/nginx.conf /etc/nginx/conf.d/default.conf 3 | ADD .output/public /app 4 | WORKDIR /app 5 | EXPOSE 80 6 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // require("@rushstack/eslint-patch/modern-module-resolution") 2 | 3 | module.exports = { 4 | extends: [ 5 | "@nuxtjs/eslint-config-typescript", 6 | ], 7 | rules: { 8 | quotes: ["error", "double", { "allowTemplateLiterals": true }], 9 | "comma-dangle": ["error", "always-multiline"], 10 | "space-before-function-paren": "off", 11 | "arrow-parens": "off", 12 | "vue/valid-template-root": "off", 13 | "vue/no-multiple-template-root": "off", 14 | "no-console": "off", 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hylarucoder 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist 4 | .output 5 | .nuxt 6 | .env 7 | .idea/ 8 | .DS_Store 9 | .vscode/ 10 | .pnpm-store/ 11 | dist-electron/ 12 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | shell-emulator=true 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.svg 2 | package.json 3 | .umi 4 | .umi-production 5 | /dist 6 | .dockerignore 7 | .DS_Store 8 | .eslintignore 9 | *.png 10 | *.toml 11 | docker 12 | .editorconfig 13 | Dockerfile* 14 | .gitignore 15 | .prettierignore 16 | LICENSE 17 | .eslintcache 18 | *.lock 19 | yarn-error.log 20 | .history 21 | CNAME 22 | /build 23 | /public 24 | .nuxt 25 | .output/ 26 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss" 4 | ], 5 | "printWidth": 120, 6 | "tabWidth": 2, 7 | "semi": false, 8 | "singleQuote": false, 9 | "trailingComma": "all" 10 | } 11 | -------------------------------------------------------------------------------- /.stackblitzrc: -------------------------------------------------------------------------------- 1 | { 2 | "installDependencies": true, 3 | "startCommand": "npm run dev" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-PRESENT Hylarucoder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RELEASE_IMAGE_TAG = "nuxt-release:latest" 2 | 3 | BUILDER_TARGET_IMAGE_TAG = "nuxt-builder" 4 | 5 | .PHONY: builder 6 | builder: 7 | docker build -t $(BUILDER_TARGET_IMAGE_TAG) --progress plain -f .docker/builder.dockerfile . 8 | #docker buildx build -t $(BUILDER_TARGET_IMAGE_TAG) --push --progress plain -f .docker/builder.dockerfile . 9 | 10 | build: 11 | pnpm install && pnpm build 12 | 13 | .PHONY: package 14 | package: 15 | docker run -i --rm -v $(CURDIR):/app $(BUILDER_TARGET_IMAGE_TAG) 16 | 17 | 18 | .PHONY: release 19 | release: 20 | docker build -t $(RELEASE_IMAGE_TAG) --progress plain -f .docker/release.dockerfile . 21 | #docker buildx build -t $(RELEASE_IMAGE_TAG) --push --progress plain -f .docker/release.dockerfile . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | image 3 |

4 | 5 |

ChatGPT Nuxt

6 | 7 |
8 | English 9 | 简体中文 10 |
11 | 12 | One-Click to get well-designed cross-platform ChatGPT web UI. 13 | 14 | > Still in rapid development, the interfaces and project structure are constantly being adjusted. There are no 15 | > documentation at this stage, and the code is relatively rough. Please make sure that you can understand the code. 16 | > For serious use, please wait for version v0.1. 17 | 18 | ## Features 19 | 20 | - [x] Nuxt 3 + Vite + TailwindCSS + PWA 21 | - [x] i18n support, including English and 简体中文 22 | - Well-designed UI inspired by ChatGPT-Next-Web 23 | - TODO: Inherits most features from ChatGPT-Next-Web, but much more extensible and opinionated. 24 | - TODO: Local storage for privacy concerns, but offers the option to use cloud services and embrace langchain ecosystem. 25 | - TODO: Mobile-friendly interface 26 | - TODO: One-click deployment and automatic updating. 27 | - TODO: Support POE 28 | 29 | ## ScreenShot 30 | 31 | image 32 | image 33 | image 34 | 35 | ## TODO 36 | 37 | - [ ] v0.1 🔥 Complete the migration of all features from ChatGPT-Next-Web 38 | - [ ] v0.2 Improve code quality, implement one-click deployment and one-click updates 39 | - [ ] v0.3 Complete responsiveness, adapt to mobile devices 40 | - [ ] v0.9 Support hybrid storage, support browser local storage (localStorage) and cloud storage for chat messages 41 | 42 | ## Tech Tack 43 | 44 | - 💚 [Nuxt 3](https://nuxt.com/) - SSR, ESR, File-based routing, components auto importing, modules, etc. 45 | - ⚡️ Vite - Instant HMR. 46 | - 🎨 [TailwindCSS](https://github.com/tailwindlabs/tailwindcss) - The CSS engine. 47 | - 😃 Use icons from any icon sets [Nuxt Icon](https://github.com/nuxt-modules/icon). 48 | - 🔥 The ` 37 | 38 | 44 | 45 | 84 | -------------------------------------------------------------------------------- /src/assets/css/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/assets/scss/highlight.scss: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | /*! Theme: Tokyo-night-Dark origin: https://github.com/enkia/tokyo-night-vscode-theme Description: Original highlight.js style Author: (c) Henri Vandersleyen License: see project LICENSE Touched: 2022 */ 3 | 4 | pre { 5 | padding: 0; 6 | } 7 | 8 | pre, 9 | code { 10 | font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; 11 | } 12 | 13 | pre code { 14 | display: block; 15 | overflow-x: auto; 16 | padding: 1em; 17 | } 18 | 19 | code { 20 | padding: 3px 5px; 21 | } 22 | 23 | .hljs, 24 | pre { 25 | background: #1a1b26; 26 | color: #cbd2ea; 27 | } 28 | 29 | .hljs-comment, 30 | .hljs-meta { 31 | color: #565f89; 32 | } 33 | 34 | .hljs-deletion, 35 | .hljs-doctag, 36 | .hljs-regexp, 37 | .hljs-selector-attr, 38 | .hljs-selector-class, 39 | .hljs-selector-id, 40 | .hljs-selector-pseudo, 41 | .hljs-tag, 42 | .hljs-template-tag, 43 | .hljs-variable.language_ { 44 | color: #f7768e; 45 | } 46 | 47 | .hljs-link, 48 | .hljs-literal, 49 | .hljs-number, 50 | .hljs-params, 51 | .hljs-template-variable, 52 | .hljs-type, 53 | .hljs-variable { 54 | color: #ff9e64; 55 | } 56 | 57 | .hljs-attribute, 58 | .hljs-built_in { 59 | color: #e0af68; 60 | } 61 | 62 | .hljs-keyword, 63 | .hljs-property, 64 | .hljs-subst, 65 | .hljs-title, 66 | .hljs-title.class_, 67 | .hljs-title.class_.inherited__, 68 | .hljs-title.function_ { 69 | color: #7dcfff; 70 | } 71 | 72 | .hljs-selector-tag { 73 | color: #73daca; 74 | } 75 | 76 | .hljs-addition, 77 | .hljs-bullet, 78 | .hljs-quote, 79 | .hljs-string, 80 | .hljs-symbol { 81 | color: #9ece6a; 82 | } 83 | 84 | .hljs-code, 85 | .hljs-formula, 86 | .hljs-section { 87 | color: #7aa2f7; 88 | } 89 | 90 | .hljs-attr, 91 | .hljs-char.escape_, 92 | .hljs-keyword, 93 | .hljs-name, 94 | .hljs-operator { 95 | color: #bb9af7; 96 | } 97 | 98 | .hljs-punctuation { 99 | color: #c0caf5; 100 | } 101 | 102 | .hljs-emphasis { 103 | font-style: italic; 104 | } 105 | 106 | .hljs-strong { 107 | font-weight: 700; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/assets/scss/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --theme: light; 3 | --white: #fff; 4 | --black: #303030; 5 | --gray: #fafafa; 6 | --primary: #42b883; 7 | --second: #e7f8ff; 8 | --hover-color: #f3f3f3; 9 | --bar-color: rgba(0, 0, 0, 0.1); 10 | --theme-color: var(--gray); 11 | --shadow: 50px 50px 100px 10px rgba(0, 0, 0, 0.1); 12 | --card-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.05); 13 | --border-in-light: 1px solid #dedede; 14 | --window-width: 90vw; 15 | --window-height: 90vh; 16 | --sidebar-width: 300px; 17 | --window-content-width: calc(100% - var(--sidebar-width)); 18 | --message-max-width: 80%; 19 | --full-height: 100%; 20 | --markdown-font-size: 12px; 21 | --body-display: flex; 22 | } 23 | 24 | html { 25 | font-family: 26 | ui-sans-serif, 27 | system-ui, 28 | -apple-system, 29 | BlinkMacSystemFont, 30 | Segoe UI, 31 | Roboto, 32 | Helvetica Neue, 33 | Arial, 34 | Noto Sans, 35 | sans-serif, 36 | Apple Color Emoji, 37 | Segoe UI Emoji, 38 | Segoe UI Symbol, 39 | Noto Color Emoji; 40 | font-feature-settings: normal; 41 | font-variation-settings: normal; 42 | } 43 | 44 | pre .copy-code-button { 45 | right: 10px; 46 | top: 15px; 47 | cursor: pointer; 48 | padding: 0 5px; 49 | text-align: right; 50 | background-color: var(--black); 51 | color: var(--white); 52 | border: var(--border-in-light); 53 | border-radius: 10px; 54 | transform: translateX(10px); 55 | pointer-events: none; 56 | opacity: 0.8; 57 | transition: all 0.3s ease; 58 | } 59 | 60 | @import "markdown"; 61 | @import "highlight"; 62 | -------------------------------------------------------------------------------- /src/assets/scss/markdown.scss: -------------------------------------------------------------------------------- 1 | // adapt from https://github.com/sindresorhus/github-markdown-css 2 | @media (prefers-color-scheme: dark) { 3 | .markdown-body { 4 | color-scheme: dark; 5 | --color-prettylights-syntax-comment: #8b949e; 6 | --color-prettylights-syntax-constant: #79c0ff; 7 | --color-prettylights-syntax-entity: #d2a8ff; 8 | --color-prettylights-syntax-storage-modifier-import: #c9d1d9; 9 | --color-prettylights-syntax-entity-tag: #7ee787; 10 | --color-prettylights-syntax-keyword: #ff7b72; 11 | --color-prettylights-syntax-string: #a5d6ff; 12 | --color-prettylights-syntax-variable: #ffa657; 13 | --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; 14 | --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; 15 | --color-prettylights-syntax-invalid-illegal-bg: #8e1519; 16 | --color-prettylights-syntax-carriage-return-text: #f0f6fc; 17 | --color-prettylights-syntax-carriage-return-bg: #b62324; 18 | --color-prettylights-syntax-string-regexp: #7ee787; 19 | --color-prettylights-syntax-markup-list: #f2cc60; 20 | --color-prettylights-syntax-markup-heading: #1f6feb; 21 | --color-prettylights-syntax-markup-italic: #c9d1d9; 22 | --color-prettylights-syntax-markup-bold: #c9d1d9; 23 | --color-prettylights-syntax-markup-deleted-text: #ffdcd7; 24 | --color-prettylights-syntax-markup-deleted-bg: #67060c; 25 | --color-prettylights-syntax-markup-inserted-text: #aff5b4; 26 | --color-prettylights-syntax-markup-inserted-bg: #033a16; 27 | --color-prettylights-syntax-markup-changed-text: #ffdfb6; 28 | --color-prettylights-syntax-markup-changed-bg: #5a1e02; 29 | --color-prettylights-syntax-markup-ignored-text: #c9d1d9; 30 | --color-prettylights-syntax-markup-ignored-bg: #1158c7; 31 | --color-prettylights-syntax-meta-diff-range: #d2a8ff; 32 | --color-prettylights-syntax-brackethighlighter-angle: #8b949e; 33 | --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; 34 | --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; 35 | --color-fg-default: #c9d1d9; 36 | --color-fg-muted: #8b949e; 37 | --color-fg-subtle: #6e7681; 38 | --color-canvas-default: #0d1117; 39 | --color-canvas-subtle: #161b22; 40 | --color-border-default: #30363d; 41 | --color-border-muted: #21262d; 42 | --color-neutral-muted: rgba(110, 118, 129, 0.4); 43 | --color-accent-fg: #58a6ff; 44 | --color-accent-emphasis: #1f6feb; 45 | --color-attention-subtle: rgba(187, 128, 9, 0.15); 46 | --color-danger-fg: #f85149; 47 | } 48 | } 49 | 50 | @media (prefers-color-scheme: light) { 51 | .markdown-body { 52 | color-scheme: light; 53 | --color-prettylights-syntax-comment: #6e7781; 54 | --color-prettylights-syntax-constant: #0550ae; 55 | --color-prettylights-syntax-entity: #8250df; 56 | --color-prettylights-syntax-storage-modifier-import: #24292f; 57 | --color-prettylights-syntax-entity-tag: #116329; 58 | --color-prettylights-syntax-keyword: #cf222e; 59 | --color-prettylights-syntax-string: #0a3069; 60 | --color-prettylights-syntax-variable: #953800; 61 | --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; 62 | --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; 63 | --color-prettylights-syntax-invalid-illegal-bg: #82071e; 64 | --color-prettylights-syntax-carriage-return-text: #f6f8fa; 65 | --color-prettylights-syntax-carriage-return-bg: #cf222e; 66 | --color-prettylights-syntax-string-regexp: #116329; 67 | --color-prettylights-syntax-markup-list: #3b2300; 68 | --color-prettylights-syntax-markup-heading: #0550ae; 69 | --color-prettylights-syntax-markup-italic: #24292f; 70 | --color-prettylights-syntax-markup-bold: #24292f; 71 | --color-prettylights-syntax-markup-deleted-text: #82071e; 72 | --color-prettylights-syntax-markup-deleted-bg: #ffebe9; 73 | --color-prettylights-syntax-markup-inserted-text: #116329; 74 | --color-prettylights-syntax-markup-inserted-bg: #dafbe1; 75 | --color-prettylights-syntax-markup-changed-text: #953800; 76 | --color-prettylights-syntax-markup-changed-bg: #ffd8b5; 77 | --color-prettylights-syntax-markup-ignored-text: #eaeef2; 78 | --color-prettylights-syntax-markup-ignored-bg: #0550ae; 79 | --color-prettylights-syntax-meta-diff-range: #8250df; 80 | --color-prettylights-syntax-brackethighlighter-angle: #57606a; 81 | --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; 82 | --color-prettylights-syntax-constant-other-reference-link: #0a3069; 83 | --color-fg-default: #24292f; 84 | --color-fg-muted: #57606a; 85 | --color-fg-subtle: #6e7781; 86 | --color-canvas-default: #ffffff; 87 | --color-canvas-subtle: #f6f8fa; 88 | --color-border-default: #d0d7de; 89 | --color-border-muted: hsla(210, 18%, 87%, 1); 90 | --color-neutral-muted: rgba(175, 184, 193, 0.2); 91 | --color-accent-fg: #0969da; 92 | --color-accent-emphasis: #0969da; 93 | --color-attention-subtle: #fff8c5; 94 | --color-danger-fg: #cf222e; 95 | } 96 | } 97 | 98 | .markdown-body { 99 | -ms-text-size-adjust: 100%; 100 | -webkit-text-size-adjust: 100%; 101 | margin: 0; 102 | color: var(--color-fg-default); 103 | //background-color: var(--color-canvas-default); 104 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, 105 | "Apple Color Emoji", "Segoe UI Emoji"; 106 | font-size: var(--markdown-font-size); 107 | line-height: 1.5; 108 | word-wrap: break-word; 109 | word-break: break-all; 110 | //white-space: pre-wrap; 111 | overflow: hidden; 112 | } 113 | 114 | .markdown-body .octicon { 115 | display: inline-block; 116 | fill: currentColor; 117 | vertical-align: text-bottom; 118 | } 119 | 120 | .markdown-body h1:hover .anchor .octicon-link:before, 121 | .markdown-body h2:hover .anchor .octicon-link:before, 122 | .markdown-body h3:hover .anchor .octicon-link:before, 123 | .markdown-body h4:hover .anchor .octicon-link:before, 124 | .markdown-body h5:hover .anchor .octicon-link:before, 125 | .markdown-body h6:hover .anchor .octicon-link:before { 126 | width: 16px; 127 | height: 16px; 128 | content: " "; 129 | display: inline-block; 130 | background-color: currentColor; 131 | -webkit-mask-image: url("data:image/svg+xml,"); 132 | mask-image: url("data:image/svg+xml,"); 133 | } 134 | 135 | .markdown-body details, 136 | .markdown-body figcaption, 137 | .markdown-body figure { 138 | display: block; 139 | } 140 | 141 | .markdown-body summary { 142 | display: list-item; 143 | } 144 | 145 | .markdown-body [hidden] { 146 | display: none !important; 147 | } 148 | 149 | .markdown-body a { 150 | background-color: transparent; 151 | color: var(--color-accent-fg); 152 | text-decoration: none; 153 | } 154 | 155 | .markdown-body abbr[title] { 156 | border-bottom: none; 157 | text-decoration: underline dotted; 158 | } 159 | 160 | .markdown-body b, 161 | .markdown-body strong { 162 | font-weight: var(--base-text-weight-semibold, 600); 163 | } 164 | 165 | .markdown-body dfn { 166 | font-style: italic; 167 | } 168 | 169 | .markdown-body h1 { 170 | margin: 0.67em 0; 171 | font-weight: var(--base-text-weight-semibold, 600); 172 | padding-bottom: 0.3em; 173 | font-size: 2em; 174 | border-bottom: 1px solid var(--color-border-muted); 175 | } 176 | 177 | .markdown-body mark { 178 | background-color: var(--color-attention-subtle); 179 | color: var(--color-fg-default); 180 | } 181 | 182 | .markdown-body small { 183 | font-size: 90%; 184 | } 185 | 186 | .markdown-body sub, 187 | .markdown-body sup { 188 | font-size: 75%; 189 | line-height: 0; 190 | position: relative; 191 | vertical-align: baseline; 192 | } 193 | 194 | .markdown-body sub { 195 | bottom: -0.25em; 196 | } 197 | 198 | .markdown-body sup { 199 | top: -0.5em; 200 | } 201 | 202 | .markdown-body img { 203 | border-style: none; 204 | max-width: 100%; 205 | box-sizing: content-box; 206 | background-color: var(--color-canvas-default); 207 | } 208 | 209 | .markdown-body code, 210 | .markdown-body kbd, 211 | .markdown-body pre, 212 | .markdown-body samp { 213 | font-family: monospace; 214 | font-size: 1em; 215 | } 216 | 217 | .markdown-body figure { 218 | margin: 1em 40px; 219 | } 220 | 221 | .markdown-body hr { 222 | box-sizing: content-box; 223 | overflow: hidden; 224 | background: transparent; 225 | border-bottom: 1px solid var(--color-border-muted); 226 | height: 0.25em; 227 | padding: 0; 228 | margin: 24px 0; 229 | background-color: var(--color-border-default); 230 | border: 0; 231 | } 232 | 233 | .markdown-body input { 234 | font: inherit; 235 | margin: 0; 236 | overflow: visible; 237 | font-family: inherit; 238 | font-size: inherit; 239 | line-height: inherit; 240 | } 241 | 242 | .markdown-body [type="button"], 243 | .markdown-body [type="reset"], 244 | .markdown-body [type="submit"] { 245 | -webkit-appearance: button; 246 | } 247 | 248 | .markdown-body [type="checkbox"], 249 | .markdown-body [type="radio"] { 250 | box-sizing: border-box; 251 | padding: 0; 252 | } 253 | 254 | .markdown-body [type="number"]::-webkit-inner-spin-button, 255 | .markdown-body [type="number"]::-webkit-outer-spin-button { 256 | height: auto; 257 | } 258 | 259 | .markdown-body [type="search"]::-webkit-search-cancel-button, 260 | .markdown-body [type="search"]::-webkit-search-decoration { 261 | -webkit-appearance: none; 262 | } 263 | 264 | .markdown-body ::-webkit-input-placeholder { 265 | color: inherit; 266 | opacity: 0.54; 267 | } 268 | 269 | .markdown-body ::-webkit-file-upload-button { 270 | -webkit-appearance: button; 271 | font: inherit; 272 | } 273 | 274 | .markdown-body a:hover { 275 | text-decoration: underline; 276 | } 277 | 278 | .markdown-body ::placeholder { 279 | color: var(--color-fg-subtle); 280 | opacity: 1; 281 | } 282 | 283 | .markdown-body hr::before { 284 | display: table; 285 | content: ""; 286 | } 287 | 288 | .markdown-body hr::after { 289 | display: table; 290 | clear: both; 291 | content: ""; 292 | } 293 | 294 | .markdown-body table { 295 | border-spacing: 0; 296 | border-collapse: collapse; 297 | display: block; 298 | width: max-content; 299 | max-width: 100%; 300 | overflow: auto; 301 | } 302 | 303 | .markdown-body td, 304 | .markdown-body th { 305 | padding: 0; 306 | } 307 | 308 | .markdown-body details summary { 309 | cursor: pointer; 310 | } 311 | 312 | .markdown-body details:not([open]) > *:not(summary) { 313 | display: none !important; 314 | } 315 | 316 | .markdown-body a:focus, 317 | .markdown-body [role="button"]:focus, 318 | .markdown-body input[type="radio"]:focus, 319 | .markdown-body input[type="checkbox"]:focus { 320 | outline: 2px solid var(--color-accent-fg); 321 | outline-offset: -2px; 322 | box-shadow: none; 323 | } 324 | 325 | .markdown-body a:focus:not(:focus-visible), 326 | .markdown-body [role="button"]:focus:not(:focus-visible), 327 | .markdown-body input[type="radio"]:focus:not(:focus-visible), 328 | .markdown-body input[type="checkbox"]:focus:not(:focus-visible) { 329 | outline: solid 1px transparent; 330 | } 331 | 332 | .markdown-body a:focus-visible, 333 | .markdown-body [role="button"]:focus-visible, 334 | .markdown-body input[type="radio"]:focus-visible, 335 | .markdown-body input[type="checkbox"]:focus-visible { 336 | outline: 2px solid var(--color-accent-fg); 337 | outline-offset: -2px; 338 | box-shadow: none; 339 | } 340 | 341 | .markdown-body a:not([class]):focus, 342 | .markdown-body a:not([class]):focus-visible, 343 | .markdown-body input[type="radio"]:focus, 344 | .markdown-body input[type="radio"]:focus-visible, 345 | .markdown-body input[type="checkbox"]:focus, 346 | .markdown-body input[type="checkbox"]:focus-visible { 347 | outline-offset: 0; 348 | } 349 | 350 | .markdown-body kbd { 351 | display: inline-block; 352 | padding: 3px 5px; 353 | font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 354 | line-height: 10px; 355 | color: var(--color-fg-default); 356 | vertical-align: middle; 357 | background-color: var(--color-canvas-subtle); 358 | border: solid 1px var(--color-neutral-muted); 359 | border-bottom-color: var(--color-neutral-muted); 360 | border-radius: 6px; 361 | box-shadow: inset 0 -1px 0 var(--color-neutral-muted); 362 | } 363 | 364 | .markdown-body h1, 365 | .markdown-body h2, 366 | .markdown-body h3, 367 | .markdown-body h4, 368 | .markdown-body h5, 369 | .markdown-body h6 { 370 | margin-top: 24px; 371 | margin-bottom: 16px; 372 | font-weight: var(--base-text-weight-semibold, 600); 373 | line-height: 1.25; 374 | } 375 | 376 | .markdown-body h2 { 377 | font-weight: var(--base-text-weight-semibold, 600); 378 | padding-bottom: 0.3em; 379 | font-size: 1.5em; 380 | border-bottom: 1px solid var(--color-border-muted); 381 | } 382 | 383 | .markdown-body h3 { 384 | font-weight: var(--base-text-weight-semibold, 600); 385 | font-size: 1.25em; 386 | } 387 | 388 | .markdown-body h4 { 389 | font-weight: var(--base-text-weight-semibold, 600); 390 | font-size: 1em; 391 | } 392 | 393 | .markdown-body h5 { 394 | font-weight: var(--base-text-weight-semibold, 600); 395 | font-size: 0.875em; 396 | } 397 | 398 | .markdown-body h6 { 399 | font-weight: var(--base-text-weight-semibold, 600); 400 | font-size: 0.85em; 401 | color: var(--color-fg-muted); 402 | } 403 | 404 | .markdown-body p { 405 | margin-top: 0; 406 | margin-bottom: 10px; 407 | word-wrap: break-word; 408 | word-break: break-all; 409 | white-space: pre-wrap; 410 | } 411 | 412 | .markdown-body blockquote { 413 | margin: 0; 414 | padding: 0 1em; 415 | color: var(--color-fg-muted); 416 | border-left: 0.25em solid var(--color-border-default); 417 | } 418 | 419 | .markdown-body ol { 420 | list-style: decimal; 421 | margin-top: 0; 422 | margin-bottom: 0; 423 | padding-left: 1.2em; 424 | } 425 | 426 | 427 | .markdown-body ul { 428 | list-style: disc; 429 | margin-top: 0; 430 | margin-bottom: 0; 431 | padding-left: 1.2em; 432 | } 433 | 434 | // media query sm padding left 2em 435 | @media (min-width: 640px) { 436 | .markdown-body ol { 437 | padding-left: 2em; 438 | } 439 | .markdown-body ul { 440 | padding-left: 2em; 441 | } 442 | } 443 | 444 | .markdown-body ol ol, 445 | .markdown-body ul ol { 446 | list-style-type: lower-roman; 447 | } 448 | 449 | .markdown-body ul ul ol, 450 | .markdown-body ul ol ol, 451 | .markdown-body ol ul ol, 452 | .markdown-body ol ol ol { 453 | list-style-type: lower-alpha; 454 | } 455 | 456 | .markdown-body dd { 457 | margin-left: 0; 458 | } 459 | 460 | .markdown-body tt, 461 | .markdown-body code, 462 | .markdown-body samp { 463 | font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 464 | font-size: 12px; 465 | } 466 | 467 | .markdown-body pre { 468 | margin-top: 0; 469 | margin-bottom: 0; 470 | font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 471 | font-size: 12px; 472 | word-wrap: normal; 473 | } 474 | 475 | .markdown-body .octicon { 476 | display: inline-block; 477 | overflow: visible !important; 478 | vertical-align: text-bottom; 479 | fill: currentColor; 480 | } 481 | 482 | .markdown-body input::-webkit-outer-spin-button, 483 | .markdown-body input::-webkit-inner-spin-button { 484 | margin: 0; 485 | -webkit-appearance: none; 486 | appearance: none; 487 | } 488 | 489 | .markdown-body::before { 490 | display: table; 491 | content: ""; 492 | } 493 | 494 | .markdown-body::after { 495 | display: table; 496 | clear: both; 497 | content: ""; 498 | } 499 | 500 | .markdown-body > *:first-child { 501 | margin-top: 0 !important; 502 | } 503 | 504 | .markdown-body > *:last-child { 505 | margin-bottom: 0 !important; 506 | } 507 | 508 | .markdown-body a:not([href]) { 509 | color: inherit; 510 | text-decoration: none; 511 | } 512 | 513 | .markdown-body .absent { 514 | color: var(--color-danger-fg); 515 | } 516 | 517 | .markdown-body .anchor { 518 | float: left; 519 | padding-right: 4px; 520 | margin-left: -20px; 521 | line-height: 1; 522 | } 523 | 524 | .markdown-body .anchor:focus { 525 | outline: none; 526 | } 527 | 528 | .markdown-body p, 529 | .markdown-body blockquote, 530 | .markdown-body ul, 531 | .markdown-body ol, 532 | .markdown-body dl, 533 | .markdown-body table, 534 | .markdown-body pre, 535 | .markdown-body details { 536 | margin-top: 0; 537 | margin-bottom: 16px; 538 | } 539 | 540 | .markdown-body blockquote > :first-child { 541 | margin-top: 0; 542 | } 543 | 544 | .markdown-body blockquote > :last-child { 545 | margin-bottom: 0; 546 | } 547 | 548 | .markdown-body h1 .octicon-link, 549 | .markdown-body h2 .octicon-link, 550 | .markdown-body h3 .octicon-link, 551 | .markdown-body h4 .octicon-link, 552 | .markdown-body h5 .octicon-link, 553 | .markdown-body h6 .octicon-link { 554 | color: var(--color-fg-default); 555 | vertical-align: middle; 556 | visibility: hidden; 557 | } 558 | 559 | .markdown-body h1:hover .anchor, 560 | .markdown-body h2:hover .anchor, 561 | .markdown-body h3:hover .anchor, 562 | .markdown-body h4:hover .anchor, 563 | .markdown-body h5:hover .anchor, 564 | .markdown-body h6:hover .anchor { 565 | text-decoration: none; 566 | } 567 | 568 | .markdown-body h1:hover .anchor .octicon-link, 569 | .markdown-body h2:hover .anchor .octicon-link, 570 | .markdown-body h3:hover .anchor .octicon-link, 571 | .markdown-body h4:hover .anchor .octicon-link, 572 | .markdown-body h5:hover .anchor .octicon-link, 573 | .markdown-body h6:hover .anchor .octicon-link { 574 | visibility: visible; 575 | } 576 | 577 | .markdown-body h1 tt, 578 | .markdown-body h1 code, 579 | .markdown-body h2 tt, 580 | .markdown-body h2 code, 581 | .markdown-body h3 tt, 582 | .markdown-body h3 code, 583 | .markdown-body h4 tt, 584 | .markdown-body h4 code, 585 | .markdown-body h5 tt, 586 | .markdown-body h5 code, 587 | .markdown-body h6 tt, 588 | .markdown-body h6 code { 589 | padding: 0 0.2em; 590 | font-size: inherit; 591 | } 592 | 593 | .markdown-body summary h1, 594 | .markdown-body summary h2, 595 | .markdown-body summary h3, 596 | .markdown-body summary h4, 597 | .markdown-body summary h5, 598 | .markdown-body summary h6 { 599 | display: inline-block; 600 | } 601 | 602 | .markdown-body summary h1 .anchor, 603 | .markdown-body summary h2 .anchor, 604 | .markdown-body summary h3 .anchor, 605 | .markdown-body summary h4 .anchor, 606 | .markdown-body summary h5 .anchor, 607 | .markdown-body summary h6 .anchor { 608 | margin-left: -40px; 609 | } 610 | 611 | .markdown-body summary h1, 612 | .markdown-body summary h2 { 613 | padding-bottom: 0; 614 | border-bottom: 0; 615 | } 616 | 617 | .markdown-body ul.no-list, 618 | .markdown-body ol.no-list { 619 | padding: 0; 620 | list-style-type: none; 621 | } 622 | 623 | .markdown-body ol[type="a"] { 624 | list-style-type: lower-alpha; 625 | } 626 | 627 | .markdown-body ol[type="A"] { 628 | list-style-type: upper-alpha; 629 | } 630 | 631 | .markdown-body ol[type="i"] { 632 | list-style-type: lower-roman; 633 | } 634 | 635 | .markdown-body ol[type="I"] { 636 | list-style-type: upper-roman; 637 | } 638 | 639 | .markdown-body ol[type="1"] { 640 | list-style-type: decimal; 641 | } 642 | 643 | .markdown-body div > ol:not([type]) { 644 | list-style-type: decimal; 645 | } 646 | 647 | .markdown-body ul ul, 648 | .markdown-body ul ol, 649 | .markdown-body ol ol, 650 | .markdown-body ol ul { 651 | margin-top: 0; 652 | margin-bottom: 0; 653 | } 654 | 655 | .markdown-body li > p { 656 | margin-top: 16px; 657 | } 658 | 659 | .markdown-body li + li { 660 | margin-top: 0.25em; 661 | } 662 | 663 | .markdown-body dl { 664 | padding: 0; 665 | } 666 | 667 | .markdown-body dl dt { 668 | padding: 0; 669 | margin-top: 16px; 670 | font-size: 1em; 671 | font-style: italic; 672 | font-weight: var(--base-text-weight-semibold, 600); 673 | } 674 | 675 | .markdown-body dl dd { 676 | padding: 0 16px; 677 | margin-bottom: 16px; 678 | } 679 | 680 | .markdown-body table th { 681 | font-weight: var(--base-text-weight-semibold, 600); 682 | } 683 | 684 | .markdown-body table th, 685 | .markdown-body table td { 686 | padding: 6px 13px; 687 | border: 1px solid var(--color-border-default); 688 | } 689 | 690 | .markdown-body table tr { 691 | background-color: var(--color-canvas-default); 692 | border-top: 1px solid var(--color-border-muted); 693 | } 694 | 695 | .markdown-body table tr:nth-child(2n) { 696 | background-color: var(--color-canvas-subtle); 697 | } 698 | 699 | .markdown-body table img { 700 | background-color: transparent; 701 | } 702 | 703 | .markdown-body img[align="right"] { 704 | padding-left: 20px; 705 | } 706 | 707 | .markdown-body img[align="left"] { 708 | padding-right: 20px; 709 | } 710 | 711 | .markdown-body .emoji { 712 | max-width: none; 713 | vertical-align: text-top; 714 | background-color: transparent; 715 | } 716 | 717 | .markdown-body span.frame { 718 | display: block; 719 | overflow: hidden; 720 | } 721 | 722 | .markdown-body span.frame > span { 723 | display: block; 724 | float: left; 725 | width: auto; 726 | padding: 7px; 727 | margin: 13px 0 0; 728 | overflow: hidden; 729 | border: 1px solid var(--color-border-default); 730 | } 731 | 732 | .markdown-body span.frame span img { 733 | display: block; 734 | float: left; 735 | } 736 | 737 | .markdown-body span.frame span span { 738 | display: block; 739 | padding: 5px 0 0; 740 | clear: both; 741 | color: var(--color-fg-default); 742 | } 743 | 744 | .markdown-body span.align-center { 745 | display: block; 746 | overflow: hidden; 747 | clear: both; 748 | } 749 | 750 | .markdown-body span.align-center > span { 751 | display: block; 752 | margin: 13px auto 0; 753 | overflow: hidden; 754 | text-align: center; 755 | } 756 | 757 | .markdown-body span.align-center span img { 758 | margin: 0 auto; 759 | text-align: center; 760 | } 761 | 762 | .markdown-body span.align-right { 763 | display: block; 764 | overflow: hidden; 765 | clear: both; 766 | } 767 | 768 | .markdown-body span.align-right > span { 769 | display: block; 770 | margin: 13px 0 0; 771 | overflow: hidden; 772 | text-align: right; 773 | } 774 | 775 | .markdown-body span.align-right span img { 776 | margin: 0; 777 | text-align: right; 778 | } 779 | 780 | .markdown-body span.float-left { 781 | display: block; 782 | float: left; 783 | margin-right: 13px; 784 | overflow: hidden; 785 | } 786 | 787 | .markdown-body span.float-left span { 788 | margin: 13px 0 0; 789 | } 790 | 791 | .markdown-body span.float-right { 792 | display: block; 793 | float: right; 794 | margin-left: 13px; 795 | overflow: hidden; 796 | } 797 | 798 | .markdown-body span.float-right > span { 799 | display: block; 800 | margin: 13px auto 0; 801 | overflow: hidden; 802 | text-align: right; 803 | } 804 | 805 | .markdown-body code, 806 | .markdown-body tt { 807 | padding: 0.2em 0.4em; 808 | margin: 0; 809 | font-size: 85%; 810 | white-space: break-spaces; 811 | background-color: var(--color-neutral-muted); 812 | border-radius: 6px; 813 | } 814 | 815 | .markdown-body code br, 816 | .markdown-body tt br { 817 | display: none; 818 | } 819 | 820 | .markdown-body del code { 821 | text-decoration: inherit; 822 | } 823 | 824 | .markdown-body samp { 825 | font-size: 85%; 826 | } 827 | 828 | .markdown-body pre code { 829 | font-size: 100%; 830 | } 831 | 832 | .markdown-body pre > code { 833 | padding: 0; 834 | margin: 0; 835 | word-break: normal; 836 | white-space: pre; 837 | background: transparent; 838 | border: 0; 839 | } 840 | 841 | .markdown-body .highlight { 842 | margin-bottom: 16px; 843 | } 844 | 845 | .markdown-body .highlight pre { 846 | margin-bottom: 0; 847 | word-break: normal; 848 | } 849 | 850 | .markdown-body .highlight pre, 851 | .markdown-body pre { 852 | padding: 16px; 853 | overflow: auto; 854 | font-size: 85%; 855 | line-height: 1.45; 856 | background-color: var(--color-canvas-subtle); 857 | border-radius: 6px; 858 | } 859 | 860 | .markdown-body pre code, 861 | .markdown-body pre tt { 862 | display: inline; 863 | max-width: auto; 864 | padding: 0; 865 | margin: 0; 866 | overflow: visible; 867 | line-height: inherit; 868 | word-wrap: normal; 869 | background-color: transparent; 870 | border: 0; 871 | } 872 | 873 | .markdown-body .csv-data td, 874 | .markdown-body .csv-data th { 875 | padding: 5px; 876 | overflow: hidden; 877 | font-size: 12px; 878 | line-height: 1; 879 | text-align: left; 880 | white-space: nowrap; 881 | } 882 | 883 | .markdown-body .csv-data .blob-num { 884 | padding: 10px 8px 9px; 885 | text-align: right; 886 | background: var(--color-canvas-default); 887 | border: 0; 888 | } 889 | 890 | .markdown-body .csv-data tr { 891 | border-top: 0; 892 | } 893 | 894 | .markdown-body .csv-data th { 895 | font-weight: var(--base-text-weight-semibold, 600); 896 | background: var(--color-canvas-subtle); 897 | border-top: 0; 898 | } 899 | 900 | .markdown-body [data-footnote-ref]::before { 901 | content: "["; 902 | } 903 | 904 | .markdown-body [data-footnote-ref]::after { 905 | content: "]"; 906 | } 907 | 908 | .markdown-body .footnotes { 909 | font-size: 12px; 910 | color: var(--color-fg-muted); 911 | border-top: 1px solid var(--color-border-default); 912 | } 913 | 914 | .markdown-body .footnotes ol { 915 | padding-left: 16px; 916 | } 917 | 918 | .markdown-body .footnotes ol ul { 919 | display: inline-block; 920 | padding-left: 16px; 921 | margin-top: 16px; 922 | } 923 | 924 | .markdown-body .footnotes li { 925 | position: relative; 926 | } 927 | 928 | .markdown-body .footnotes li:target::before { 929 | position: absolute; 930 | top: -8px; 931 | right: -8px; 932 | bottom: -8px; 933 | left: -24px; 934 | pointer-events: none; 935 | content: ""; 936 | border: 2px solid var(--color-accent-emphasis); 937 | border-radius: 6px; 938 | } 939 | 940 | .markdown-body .footnotes li:target { 941 | color: var(--color-fg-default); 942 | } 943 | 944 | .markdown-body .footnotes .data-footnote-backref g-emoji { 945 | font-family: monospace; 946 | } 947 | 948 | .markdown-body .pl-c { 949 | color: var(--color-prettylights-syntax-comment); 950 | } 951 | 952 | .markdown-body .pl-c1, 953 | .markdown-body .pl-s .pl-v { 954 | color: var(--color-prettylights-syntax-constant); 955 | } 956 | 957 | .markdown-body .pl-e, 958 | .markdown-body .pl-en { 959 | color: var(--color-prettylights-syntax-entity); 960 | } 961 | 962 | .markdown-body .pl-smi, 963 | .markdown-body .pl-s .pl-s1 { 964 | color: var(--color-prettylights-syntax-storage-modifier-import); 965 | } 966 | 967 | .markdown-body .pl-ent { 968 | color: var(--color-prettylights-syntax-entity-tag); 969 | } 970 | 971 | .markdown-body .pl-k { 972 | color: var(--color-prettylights-syntax-keyword); 973 | } 974 | 975 | .markdown-body .pl-s, 976 | .markdown-body .pl-pds, 977 | .markdown-body .pl-s .pl-pse .pl-s1, 978 | .markdown-body .pl-sr, 979 | .markdown-body .pl-sr .pl-cce, 980 | .markdown-body .pl-sr .pl-sre, 981 | .markdown-body .pl-sr .pl-sra { 982 | color: var(--color-prettylights-syntax-string); 983 | } 984 | 985 | .markdown-body .pl-v, 986 | .markdown-body .pl-smw { 987 | color: var(--color-prettylights-syntax-variable); 988 | } 989 | 990 | .markdown-body .pl-bu { 991 | color: var(--color-prettylights-syntax-brackethighlighter-unmatched); 992 | } 993 | 994 | .markdown-body .pl-ii { 995 | color: var(--color-prettylights-syntax-invalid-illegal-text); 996 | background-color: var(--color-prettylights-syntax-invalid-illegal-bg); 997 | } 998 | 999 | .markdown-body .pl-c2 { 1000 | color: var(--color-prettylights-syntax-carriage-return-text); 1001 | background-color: var(--color-prettylights-syntax-carriage-return-bg); 1002 | } 1003 | 1004 | .markdown-body .pl-sr .pl-cce { 1005 | font-weight: bold; 1006 | color: var(--color-prettylights-syntax-string-regexp); 1007 | } 1008 | 1009 | .markdown-body .pl-ml { 1010 | color: var(--color-prettylights-syntax-markup-list); 1011 | } 1012 | 1013 | .markdown-body .pl-mh, 1014 | .markdown-body .pl-mh .pl-en, 1015 | .markdown-body .pl-ms { 1016 | font-weight: bold; 1017 | color: var(--color-prettylights-syntax-markup-heading); 1018 | } 1019 | 1020 | .markdown-body .pl-mi { 1021 | font-style: italic; 1022 | color: var(--color-prettylights-syntax-markup-italic); 1023 | } 1024 | 1025 | .markdown-body .pl-mb { 1026 | font-weight: bold; 1027 | color: var(--color-prettylights-syntax-markup-bold); 1028 | } 1029 | 1030 | .markdown-body .pl-md { 1031 | color: var(--color-prettylights-syntax-markup-deleted-text); 1032 | background-color: var(--color-prettylights-syntax-markup-deleted-bg); 1033 | } 1034 | 1035 | .markdown-body .pl-mi1 { 1036 | color: var(--color-prettylights-syntax-markup-inserted-text); 1037 | background-color: var(--color-prettylights-syntax-markup-inserted-bg); 1038 | } 1039 | 1040 | .markdown-body .pl-mc { 1041 | color: var(--color-prettylights-syntax-markup-changed-text); 1042 | background-color: var(--color-prettylights-syntax-markup-changed-bg); 1043 | } 1044 | 1045 | .markdown-body .pl-mi2 { 1046 | color: var(--color-prettylights-syntax-markup-ignored-text); 1047 | background-color: var(--color-prettylights-syntax-markup-ignored-bg); 1048 | } 1049 | 1050 | .markdown-body .pl-mdr { 1051 | font-weight: bold; 1052 | color: var(--color-prettylights-syntax-meta-diff-range); 1053 | } 1054 | 1055 | .markdown-body .pl-ba { 1056 | color: var(--color-prettylights-syntax-brackethighlighter-angle); 1057 | } 1058 | 1059 | .markdown-body .pl-sg { 1060 | color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); 1061 | } 1062 | 1063 | .markdown-body .pl-corl { 1064 | text-decoration: underline; 1065 | color: var(--color-prettylights-syntax-constant-other-reference-link); 1066 | } 1067 | 1068 | .markdown-body g-emoji { 1069 | display: inline-block; 1070 | min-width: 1ch; 1071 | font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 1072 | font-size: 1em; 1073 | font-style: normal !important; 1074 | font-weight: var(--base-text-weight-normal, 400); 1075 | line-height: 1; 1076 | vertical-align: -0.075em; 1077 | } 1078 | 1079 | .markdown-body g-emoji img { 1080 | width: 1em; 1081 | height: 1em; 1082 | } 1083 | 1084 | .markdown-body .task-list-item { 1085 | list-style-type: none; 1086 | } 1087 | 1088 | .markdown-body .task-list-item label { 1089 | font-weight: var(--base-text-weight-normal, 400); 1090 | } 1091 | 1092 | .markdown-body .task-list-item.enabled label { 1093 | cursor: pointer; 1094 | } 1095 | 1096 | .markdown-body .task-list-item + .task-list-item { 1097 | margin-top: 4px; 1098 | } 1099 | 1100 | .markdown-body .task-list-item .handle { 1101 | display: none; 1102 | } 1103 | 1104 | .markdown-body .task-list-item-checkbox { 1105 | margin: 0 0.2em 0.25em -1.4em; 1106 | vertical-align: middle; 1107 | } 1108 | 1109 | .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { 1110 | margin: 0 -1.6em 0.25em 0.2em; 1111 | } 1112 | 1113 | .markdown-body .contains-task-list { 1114 | position: relative; 1115 | } 1116 | 1117 | .markdown-body .contains-task-list:hover .task-list-item-convert-container, 1118 | .markdown-body .contains-task-list:focus-within .task-list-item-convert-container { 1119 | display: block; 1120 | width: auto; 1121 | height: 24px; 1122 | overflow: visible; 1123 | clip: auto; 1124 | } 1125 | 1126 | .markdown-body ::-webkit-calendar-picker-indicator { 1127 | filter: invert(50%); 1128 | } 1129 | -------------------------------------------------------------------------------- /src/components/HeadIconButton.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | icon: string 3 | size: string 4 | ping?: boolean 5 | } 6 | 7 | export default defineComponent({ 8 | props: { 9 | icon: String, 10 | size: String, 11 | ping: Boolean, 12 | }, 13 | emits: ["click"], 14 | setup(props: Props, { emit }) { 15 | return () => ( 16 | 27 | ) 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | icon: string 3 | text?: string 4 | size?: string 5 | } 6 | 7 | export default defineComponent({ 8 | props: { 9 | icon: String, 10 | text: String, 11 | size: String, 12 | }, 13 | setup(props: Props) { 14 | const hasText = Boolean(props.text) 15 | 16 | return () => ( 17 | 28 | ) 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /src/components/MarkdownPreview.tsx: -------------------------------------------------------------------------------- 1 | // MarkdownRenderer.tsx 2 | import { unified } from "unified" 3 | import * as remarkParse from "remark-parse" 4 | import * as remarkGfm from "remark-gfm" 5 | // import remarkSqueezeParagraphs from "remark-squeeze-paragraphs" 6 | import * as remarkRehype from "remark-rehype" 7 | import * as rehypeHighlight from "rehype-highlight" 8 | import * as rehypeStringify from "rehype-stringify" 9 | import * as rehypeSanitize from "rehype-sanitize" 10 | import type { PropType } from "vue" 11 | import { ClientOnly } from "#components" 12 | 13 | const processor = unified() 14 | .use(remarkParse) 15 | .use(remarkGfm) 16 | .use(remarkRehype) 17 | // .use(remarkEmoji) 18 | // .use(remarkSqueezeParagraphs) 19 | .use(rehypeHighlight) 20 | .use(rehypeSanitize) 21 | .use(rehypeStringify) 22 | 23 | export default defineComponent({ 24 | props: { 25 | md: { 26 | type: String as PropType, 27 | required: true, 28 | }, 29 | }, 30 | setup(props) { 31 | const parsedMarkdown = ref(props.md) 32 | 33 | const throttleParse = useThrottleFn( 34 | (s: string) => { 35 | const a = processor.processSync(s) 36 | parsedMarkdown.value = String(a) 37 | }, 38 | 80, 39 | true, 40 | true, 41 | ) 42 | 43 | watchEffect(async () => { 44 | try { 45 | await throttleParse(props.md || "...") 46 | } catch (err) { 47 | parsedMarkdown.value = "" 48 | } 49 | }) 50 | 51 | return () => ( 52 | 53 |
54 |
55 | ) 56 | }, 57 | }) 58 | -------------------------------------------------------------------------------- /src/components/MaskCard.tsx: -------------------------------------------------------------------------------- 1 | import { PropType } from "vue" 2 | import { getRandomEmoji } from "~/utils/emoji" 3 | 4 | export default defineComponent({ 5 | props: { 6 | icon: { 7 | type: String as PropType, 8 | required: true, 9 | }, 10 | text: { 11 | type: String as PropType, 12 | required: true, 13 | }, 14 | }, 15 | setup(props) { 16 | return () => ( 17 |
18 |
19 | {getRandomEmoji(props.text)} 20 |
21 |
{props.text}
22 |
23 | ) 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/VChatList.tsx: -------------------------------------------------------------------------------- 1 | import { VChatListCard } from "#components" 2 | import { useSidebarChatSessions } from "~/composables/chat" 3 | 4 | export default defineComponent({ 5 | components: { 6 | VChatListCard, 7 | }, 8 | setup() { 9 | const router = useRouter() 10 | 11 | const chatStore = useSidebarChatSessions() 12 | const sessions = chatStore.sessions 13 | const onDeleteSession = (id: string) => { 14 | if (sessions.length === 0) { 15 | return 16 | } 17 | if (sessions.length === 1) { 18 | return 19 | } 20 | const newSession = sessions.filter((session) => session.id !== id)[0] 21 | router.push("/chat/session/" + newSession.id) 22 | chatStore.deleteSession(id) 23 | } 24 | const draggingItem = ref(null) 25 | 26 | const onDragStart = (index: number) => { 27 | draggingItem.value = sessions[index] 28 | } 29 | 30 | const onDrop = (dropIndex: number) => { 31 | const dragIndex = sessions.indexOf(draggingItem.value) 32 | if (dragIndex !== dropIndex) { 33 | sessions.splice(dragIndex, 1) 34 | sessions.splice(dropIndex, 0, draggingItem.value) 35 | } 36 | draggingItem.value = null 37 | } 38 | 39 | return () => ( 40 |
41 | {sessions.map((session, index) => ( 42 | onDeleteSession(session.id)} 47 | onDragstart={() => onDragStart(index)} 48 | onDragover={(e: DragEvent) => e.preventDefault()} 49 | onDrop={() => onDrop(index)} 50 | draggable="true" 51 | /> 52 | ))} 53 |
54 | ) 55 | }, 56 | }) 57 | -------------------------------------------------------------------------------- /src/components/VChatListCard.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType, ref } from "vue" 2 | import { NuxtLink } from "#components" 3 | import { useTrans } from "~/composables/locales" 4 | import { useSidebar } from "~/composables/useSidebar" 5 | import { TChatSession } from "~/constants/typing" 6 | import { formatDateString } from "~/utils/date" 7 | 8 | export default defineComponent({ 9 | props: { 10 | session: { 11 | type: Object as PropType, 12 | required: true, 13 | }, 14 | }, 15 | emits: ["deleteSession"], 16 | setup(props, { emit }) { 17 | const { t } = useTrans() 18 | const sidebarUsed = useSidebar() 19 | const route = useRoute() 20 | const upHere = ref(false) 21 | const onDeleteSession = () => { 22 | emit("deleteSession", props.session.id) 23 | } 24 | 25 | const isActive = computed(() => { 26 | return route.path !== `/chat/session/${props.session.id}` 27 | }) 28 | 29 | return () => ( 30 | (upHere.value = true)} 39 | onMouseleave={() => (upHere.value = false)} 40 | onClick={sidebarUsed.hideIfMobile} 41 | > 42 |
{props.session.topic}
43 |
44 |
{t("ChatItem.ChatItemCount", { count: props.session.messagesCount })}
45 |
{formatDateString(props.session.lastUpdate)}
46 |
47 | {upHere.value && ( 48 | 55 | )} 56 |
57 | ) 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /src/components/VChatMessage.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType, ref } from "vue" 2 | import { MarkdownPreview } from "#components" 3 | import { useRoutedChatSession } from "~/composables/chat" 4 | import { useSettingStore } from "~/composables/settings" 5 | import { TChatDirection, TChatMessage } from "~/constants/typing" 6 | import { copyToClipboard } from "~/utils/clipboard" 7 | import { formatDateString } from "~/utils/date" 8 | 9 | export default defineComponent({ 10 | props: { 11 | message: { 12 | type: Object as PropType, 13 | required: true, 14 | }, 15 | }, 16 | setup(props) { 17 | const { settings } = useSettingStore() 18 | const isSend = props.message.direction === TChatDirection.SEND 19 | const currentSession = useRoutedChatSession() 20 | const messageRef = ref() 21 | const isHovered = useElementHover(messageRef) 22 | 23 | return () => ( 24 |
30 |
37 |
38 |
39 | {isSend ? settings.avatar : currentSession.session.avatar} 40 |
41 |
42 |
56 |
57 | {!isSend && ( 58 | <> 59 | {/* animation show when hover */} 60 | 66 | {isHovered.value && ( 67 |
71 |
copyToClipboard(props.message.content)} 74 | > 75 | Copy 76 |
77 |
currentSession.deleteMessage(props.message.id!)} 80 | > 81 | Delete 82 |
83 | {/*
Retry
*/} 84 |
85 | )} 86 |
87 | 93 | {isHovered.value && ( 94 |
95 | {formatDateString(props.message.date)} 96 |
97 | )} 98 |
99 | 100 | )} 101 | 102 |
103 |
104 |
105 |
106 | ) 107 | }, 108 | }) 109 | -------------------------------------------------------------------------------- /src/components/VComposeView.tsx: -------------------------------------------------------------------------------- 1 | import { UButton, UTextarea } from "#components" 2 | import { useRoutedChatSession } from "~/composables/chat" 3 | import { useTrans } from "~/composables/locales" 4 | import { keyMaps, useSettingStore } from "~/composables/settings" 5 | 6 | export default defineComponent({ 7 | name: "ChatComposer", 8 | setup() { 9 | const settingStore = useSettingStore() 10 | const setting = settingStore.settings 11 | const chatSession = useRoutedChatSession() 12 | const { t } = useTrans() 13 | 14 | const handleKeyDown = (event: any) => { 15 | const targetKeyMap = keyMaps.find((keyMap) => keyMap.label === setting.sendKey) 16 | 17 | if (targetKeyMap) { 18 | const allKeysMatched = targetKeyMap.keys.every((key) => event.getModifierState(key) || event.key === key) 19 | 20 | if (allKeysMatched) { 21 | event.preventDefault() 22 | composeNewMessage() 23 | } 24 | } 25 | } 26 | 27 | const isEmptyInput = (composeInput: string) => { 28 | return composeInput.trim().length === 0 29 | } 30 | 31 | const doMessaging = ref(false) 32 | const composeNewMessage = () => { 33 | const input = chatSession.session.composeInput 34 | if (isEmptyInput(input)) { 35 | return 36 | } 37 | doMessaging.value = true 38 | chatSession.onNewMessage(input) 39 | chatSession.session.composeInput = "" 40 | doMessaging.value = false 41 | } 42 | 43 | return () => ( 44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 | 58 |
59 |
60 | 66 | {t("Chat.Send")} 67 | 68 |
69 |
70 |
71 | ) 72 | }, 73 | }) 74 | -------------------------------------------------------------------------------- /src/components/VDetailHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useSidebar } from "~/composables/useSidebar" 2 | 3 | export default defineComponent({ 4 | props: { 5 | title: String, 6 | subtitle: String, 7 | }, 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | setup(props, { slots }) { 10 | const { show: showSidebar, visible } = useSidebar() 11 | const { isMobile } = useDevice() 12 | return () => ( 13 |
14 |
15 | {isMobile && ( 16 |
17 | { 24 | showSidebar() 25 | }} 26 | /> 27 |
28 | )} 29 |
30 |
{props.title}
31 | {props.subtitle &&
{props.subtitle}
} 32 |
33 | {!isMobile && slots?.rightIcons?.()} 34 | {isMobile && ( 35 |
36 | { 42 | showSidebar() 43 | }} 44 | /> 45 |
46 | )} 47 |
48 |
49 | ) 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /src/components/VEmojiAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { VEmojiPicker } from "#components" 2 | 3 | export default defineComponent({ 4 | name: "EmojiPicker", 5 | props: { 6 | modelValue: { 7 | type: String, 8 | required: true, 9 | }, 10 | }, 11 | emits: ["update:modelValue"], 12 | setup(props, { emit }) { 13 | const showEmojiPicker = ref(false) 14 | const selectedEmoji = ref(props.modelValue) 15 | 16 | const selectEmoji = (emoji: string) => { 17 | selectedEmoji.value = emoji 18 | showEmojiPicker.value = false 19 | emit("update:modelValue", emoji) 20 | } 21 | const elementRef = ref(null) 22 | 23 | onClickOutside(elementRef, (event) => { 24 | if (!showEmojiPicker.value) { 25 | return 26 | } 27 | if (elementRef.value && !elementRef.value.contains(event.target as Node)) { 28 | showEmojiPicker.value = false 29 | } 30 | }) 31 | 32 | return () => ( 33 |
34 | (showEmojiPicker.value = !showEmojiPicker.value)}> 35 | {selectedEmoji.value} 36 | 37 | {showEmojiPicker.value && ( 38 | 42 | )} 43 |
44 | ) 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /src/components/VEmojiPicker.tsx: -------------------------------------------------------------------------------- 1 | type EmojiCategory = { 2 | title: string 3 | icon: string 4 | emojis: string[] 5 | } 6 | 7 | const emojiCategories: EmojiCategory[] = [ 8 | { 9 | icon: "i-mdi-recent", 10 | title: "Recently", 11 | emojis: ["🐶", "🐱", "🐭", "🐹", "🐰", "🐻", "🐼", "🐨"], 12 | }, 13 | { 14 | icon: "i-mdi-emoticon-outline", 15 | title: "Smileys & Emotion", 16 | emojis: ["😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😉", "😍", "😘", "😜", "😝", "😋", "😛"], 17 | }, 18 | { 19 | icon: "i-mdi-dog", 20 | title: "Animals & Nature", 21 | emojis: [ 22 | "🐶", 23 | "🐱", 24 | "🦁", 25 | "🐯", 26 | "🐴", 27 | "🐭", 28 | "🐹", 29 | "🦊", 30 | "🐻", 31 | "🐼", 32 | "🐨", 33 | "🐮", 34 | "🐷", 35 | "🐗", 36 | "🐔", 37 | "🐣", 38 | "🐸", 39 | "🐟", 40 | "🐠", 41 | "🐳", 42 | "🐬", 43 | "🐊", 44 | "🐢", 45 | "🐍", 46 | "🦕", 47 | "🦖", 48 | "🦜", 49 | ], 50 | }, 51 | { 52 | icon: "i-mdi-hamburger", 53 | title: "Food & Drink", 54 | emojis: [ 55 | "🍔", 56 | "🍟", 57 | "🍕", 58 | "🌭", 59 | "🥪", 60 | "🍣", 61 | "🍱", 62 | "🍛", 63 | "🍝", 64 | "🍜", 65 | "🍲", 66 | "🍔", 67 | "🍺", 68 | "🍻", 69 | "🍷", 70 | "🥤", 71 | "🧊", 72 | "🍩", 73 | "🍰", 74 | "🎂", 75 | "🍪", 76 | "🍫", 77 | "🍬", 78 | "🍭", 79 | ], 80 | }, 81 | { 82 | icon: "i-mdi-football", 83 | title: "Activities", 84 | emojis: ["⚽️", "🏀", "🏈", "⚾️", "🎾", "🏐", "🏉", "🥊", "🏋️‍♀️", "🤸", "🚴‍♀️", "🤹", "🎮", "🎲"], 85 | }, 86 | { 87 | icon: "i-mdi-email-outline", 88 | title: "Objects", 89 | emojis: [ 90 | "☎️", 91 | "💻", 92 | "🖥", 93 | "🖨", 94 | "📱", 95 | "🎧", 96 | "🎤", 97 | "📷", 98 | "📹", 99 | "💡", 100 | "🔍", 101 | "🔐", 102 | "🚪", 103 | "💳", 104 | "💵", 105 | "🏺", 106 | "🔑", 107 | "🧸", 108 | "🎁", 109 | ], 110 | }, 111 | { 112 | icon: "i-mdi-earth", 113 | title: "Travel & Places", 114 | emojis: [ 115 | "🌍", 116 | "🌎", 117 | "🌏", 118 | "🌋", 119 | "🏜️", 120 | "🏕️", 121 | "🏞️", 122 | "🌅", 123 | "🌄", 124 | "🏰", 125 | "🌉", 126 | "🎡", 127 | "🎢", 128 | "🏟️", 129 | "🚂", 130 | "🛵", 131 | "🛴", 132 | "🏍️", 133 | "🚲", 134 | "🛬", 135 | "🚀", 136 | ], 137 | }, 138 | { 139 | icon: "i-mdi-lightning-bolt-outline", 140 | title: "Symbols", 141 | emojis: [ 142 | "❤️", 143 | "💔", 144 | "💭", 145 | "💬", 146 | "🔥", 147 | "🌟", 148 | "⭐️", 149 | "🌞", 150 | "🌚", 151 | "🌀", 152 | "🌈", 153 | "💡", 154 | "✨", 155 | "🎉", 156 | "🎊", 157 | "🎁", 158 | "🔨", 159 | "💣", 160 | "🚽", 161 | "🚪", 162 | ], 163 | }, 164 | { 165 | icon: "i-mdi-flag-variant-outline", 166 | title: "Flags", 167 | emojis: [ 168 | "🇨🇳", 169 | "🇺🇸", 170 | "🇬🇧", 171 | "🇯🇵", 172 | "🇰🇷", 173 | "🇿🇦", 174 | "🇪🇸", 175 | "🇫🇷", 176 | "🇩🇪", 177 | "🇮🇳", 178 | "🇲🇾", 179 | "🇳🇪", 180 | "🇵🇹", 181 | "🇷🇺", 182 | "🇸🇦", 183 | "🇸🇬", 184 | "🇹🇷", 185 | "🇻🇳", 186 | "🏴󠁧󠁢󠁥󠁮󠁧󠁿", 187 | ], 188 | }, 189 | ] 190 | 191 | export default defineComponent({ 192 | name: "VEmojiPicker", 193 | props: { 194 | modelValue: { 195 | type: String, 196 | required: true, 197 | }, 198 | }, 199 | emits: ["selected"], 200 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 201 | setup(props, { emit }) { 202 | const searchQuery = ref("") 203 | const recentEmojis = ref([]) 204 | const selectedCategoryIndex = ref(0) 205 | 206 | const currentCategoryEmojis = computed(() => { 207 | return emojiCategories[selectedCategoryIndex.value].emojis.filter((emoji) => emoji.includes(searchQuery.value)) 208 | }) 209 | 210 | const selectCategory = (index: number) => { 211 | selectedCategoryIndex.value = index 212 | } 213 | 214 | const selectEmoji = (emoji: string) => { 215 | if (!recentEmojis.value.includes(emoji)) { 216 | recentEmojis.value.unshift(emoji) 217 | if (recentEmojis.value.length > 10) { 218 | recentEmojis.value.pop() 219 | } 220 | } 221 | emit("selected", emoji) 222 | } 223 | 224 | return () => ( 225 |
226 |
227 | {emojiCategories.map((category, index) => ( 228 |
selectCategory(index)} 234 | > 235 | 236 |
237 | ))} 238 |
239 | 240 |
241 | {currentCategoryEmojis.value.map((emoji) => ( 242 |
246 | selectEmoji(emoji)} 249 | > 250 | {emoji} 251 | 252 |
253 | ))} 254 |
255 |
256 | ) 257 | }, 258 | }) 259 | -------------------------------------------------------------------------------- /src/components/VSharePreview.tsx: -------------------------------------------------------------------------------- 1 | import { toPng } from "html-to-image" 2 | import { HeadIconButton, UCard, UModal, VChatMessage } from "#components" 3 | import { useTrans } from "~/composables/locales" 4 | import { useRoutedChatSession } from "~/composables/chat" 5 | 6 | export default defineComponent({ 7 | emits: ["close"], 8 | setup(props, { emit }) { 9 | const elShare = ref(null) 10 | const visible = ref(true) 11 | // const shareLoading = ref(false) 12 | const chatSession = useRoutedChatSession() 13 | const { t } = useTrans() 14 | const shareChat = () => { 15 | if (!elShare.value) { 16 | return 17 | } 18 | toPng(elShare.value, { cacheBust: true }) 19 | .then((dataUrl) => { 20 | const link = document.createElement("a") 21 | link.download = `${chatSession.session.topic}.png` 22 | link.href = dataUrl 23 | link.click() 24 | }) 25 | .catch((err) => { 26 | console.log(err) 27 | }) 28 | } 29 | 30 | return () => ( 31 | { 33 | emit("close") 34 | }} 35 | ui={{ 36 | padding: "p-4 sm:p-0", 37 | width: "sm:max-w-lg min-w-[600px]", 38 | }} 39 | v-model={visible.value} 40 | > 41 |
42 |
43 | 54 | {{ 55 | header: () => ( 56 |
57 |
58 |
59 |
60 | {chatSession.session.topic} 61 |
62 |
63 | {t("Chat.SubTitle", { count: chatSession.session.messagesCount })} 64 |
65 |
66 |
67 |
68 | ), 69 | default: () => ( 70 |
71 | {chatSession.session.messages.map((message) => ( 72 | 73 | ))} 74 |
75 | ), 76 | }} 77 |
78 |
79 |
80 | { 85 | shareChat() 86 | }} 87 | /> 88 |
89 |
90 |
91 | ) 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /src/components/VSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, NuxtLink, VChatList } from "#components" 2 | import { useTrans } from "~/composables/locales" 3 | import { useSidebar } from "~/composables/useSidebar" 4 | 5 | export default defineComponent({ 6 | setup() { 7 | const { t } = useTrans() 8 | const { visible, hideIfMobile } = useSidebar() 9 | 10 | return () => ( 11 |
16 |
17 |
18 |
ChatGPT Nuxt
19 |
Build your own AI assistant.
20 | 21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 | 35 |
36 |
37 |
38 |
39 | 40 | 41 | 42 |
43 |
44 |
45 | 46 | 47 | 48 |
49 |
50 |
51 |
52 | ) 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /src/components/VSvgIcon.tsx: -------------------------------------------------------------------------------- 1 | // import iconUpload from '@/icons/upload.svg?component' 2 | 3 | const icons = { 4 | // upload: iconUpload, 5 | } 6 | 7 | export default defineComponent({ 8 | name: "SvgIcon", 9 | props: { 10 | icon: { 11 | type: String, 12 | default: "", 13 | required: true, 14 | }, 15 | }, 16 | setup(props) { 17 | const currentComponent = icons[props.icon] 18 | 19 | return () => 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/ui/UIAvatar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | -------------------------------------------------------------------------------- /src/composables/access.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/composables/access.ts -------------------------------------------------------------------------------- /src/composables/chat.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { ComputedRef, ref } from "vue" 3 | import { loadFromLocalStorage, saveSessionToLocalStorage, saveToLocalStorage } from "./storage" 4 | import { useTrans } from "~/composables/locales" 5 | import { getRandomEmoji } from "~/utils/emoji" 6 | import { DEFAULT_INPUT_TEMPLATE, StoreKey } from "~/constants" 7 | import useChatBot from "~/composables/useChatBot" 8 | import { TChatDirection, TChatSession, TMask } from "~/constants/typing" 9 | import { getUtcNow } from "~/utils/date" 10 | 11 | type TSimple = { 12 | avatar: string 13 | topic: string 14 | description: string 15 | } 16 | 17 | interface TUseChatSession { 18 | session: TChatSession 19 | isEmptyInput: ComputedRef 20 | onUserInput: (content: string) => Promise 21 | rename: () => void 22 | onNewMessage: (message: string) => void 23 | deleteMessage: (id: number) => void 24 | } 25 | 26 | const cachedChatSession = new Map() 27 | 28 | function makeEmptySession(s: number, simple: TSimple, mask?: TMask): TChatSession { 29 | const session: TChatSession = { 30 | id: s.toString(), 31 | topic: simple.topic, 32 | avatar: simple.avatar, 33 | memoryPrompt: "Welcome to the chat room!", 34 | composeInput: "", 35 | latestMessageId: 0, 36 | messagesCount: 2, 37 | messages: [], 38 | stat: { 39 | tokenCount: 0, 40 | wordCount: 0, 41 | charCount: 0, 42 | }, 43 | lastUpdate: getUtcNow(), 44 | lastSummarizeIndex: 0, 45 | mask, 46 | modelConfig: { 47 | model: "gpt-3.5-turbo", 48 | temperature: 0.5, 49 | maxTokens: 2000, 50 | presencePenalty: 0, 51 | frequencyPenalty: 0, 52 | sendMemory: true, 53 | historyMessageCount: 4, 54 | compressMessageLengthThreshold: 1000, 55 | template: DEFAULT_INPUT_TEMPLATE, 56 | }, 57 | } 58 | if (simple.description) { 59 | session.messages.push( 60 | ...[ 61 | { 62 | role: "system", 63 | content: simple.description, 64 | date: getUtcNow(), 65 | direction: TChatDirection.SEND, 66 | streaming: false, 67 | isError: false, 68 | id: s, 69 | }, 70 | { 71 | role: "user", 72 | content: "OK", 73 | date: getUtcNow(), 74 | direction: TChatDirection.RECEIVE, 75 | streaming: false, 76 | isError: false, 77 | id: s, 78 | }, 79 | ], 80 | ) 81 | } 82 | return session 83 | } 84 | 85 | export const useSidebarChatSessions = defineStore(StoreKey.Chat, () => { 86 | const sessionGid = ref(0) 87 | const sessions = ref([]) 88 | const { t } = useTrans() 89 | 90 | // eslint-disable-next-line require-await 91 | const loadAll = async () => { 92 | const loaded = loadFromLocalStorage(StoreKey.ChatSession, { 93 | sessions: [], 94 | sessionGid: 10000, 95 | }) 96 | sessions.value = loaded.sessions 97 | sessionGid.value = loaded.sessionGid 98 | } 99 | 100 | const saveAll = useThrottleFn( 101 | () => { 102 | saveToLocalStorage(StoreKey.ChatSession, { 103 | sessions: sessions.value, 104 | sessionGid: sessionGid.value, 105 | }) 106 | }, 107 | 1000, 108 | true, 109 | true, 110 | ) 111 | 112 | const clearSessions = () => { 113 | sessions.value = [] 114 | } 115 | 116 | const createEmptySession = () => { 117 | return newSession(undefined, { 118 | topic: t("Home.NewChat"), 119 | description: `hello`, 120 | avatar: getRandomEmoji("a"), 121 | }) 122 | } 123 | 124 | const moveSession = (from: any, to: any) => { 125 | const session = sessions.value.splice(from, 1)[0] 126 | sessions.value.splice(to, 0, session) 127 | } 128 | 129 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 130 | const newSession = (mask?: TMask, simple?: TSimple): TChatSession => { 131 | const session = makeEmptySession(++sessionGid.value, simple) 132 | sessions.value.unshift(session) 133 | saveAll() 134 | return session 135 | } 136 | 137 | const deleteSession = (id: string) => { 138 | // remove session by id 139 | const index = sessions.value.findIndex((s) => s.id === id) 140 | sessions.value.splice(index, 1) 141 | saveAll() 142 | } 143 | 144 | const nextSession = (delta: number) => { 145 | // Add your logic for navigating to the next session 146 | console.log("nextSession", delta) 147 | } 148 | 149 | // eslint-disable-next-line require-await 150 | const refreshSession = async (session: TChatSession) => { 151 | const s = sessions.value.filter((s) => s.id === session.id)[0] 152 | if (s) { 153 | s.lastUpdate = getUtcNow() 154 | s.messagesCount = session.messages.length 155 | s.topic = session.topic 156 | } 157 | } 158 | 159 | const clearAllData = () => { 160 | sessions.value = [] 161 | } 162 | 163 | return { 164 | sessions, 165 | loadAll, 166 | saveAll, 167 | clearSessions, 168 | moveSession, 169 | newSession, 170 | deleteSession, 171 | nextSession, 172 | clearAllData, 173 | refreshSession, 174 | createEmptySession, 175 | } 176 | }) 177 | 178 | // share chat session between routes and components 179 | 180 | export const useRoutedChatSession = (): TUseChatSession => { 181 | const route = useRoute() 182 | // @ts-ignore 183 | const sid = route.params?.sid 184 | return useChatSession(sid) 185 | } 186 | 187 | export const useChatSession = (sid: string): TUseChatSession => { 188 | const chatStore = useSidebarChatSessions() 189 | if (cachedChatSession.has(sid)) { 190 | return cachedChatSession.get(sid) as TUseChatSession 191 | } 192 | const loaded = loadFromLocalStorage(StoreKey.ChatSession, { 193 | sessions: [] as TChatSession[], 194 | sessionGid: 10000, 195 | }) 196 | const res = loaded.sessions.filter((s) => s.id === sid)[0] 197 | const session = reactive({ 198 | id: sid, 199 | topic: res.topic, 200 | avatar: res.avatar, 201 | latestMessageId: res.latestMessageId || 1000, 202 | messages: [], 203 | composeInput: res.composeInput, 204 | memoryPrompt: res.memoryPrompt, 205 | messagesCount: res.messagesCount, 206 | modelConfig: res.modelConfig, 207 | stat: res.stat, 208 | lastUpdate: res.lastUpdate, 209 | lastSummarizeIndex: res.lastSummarizeIndex, 210 | clearContextIndex: res.clearContextIndex, 211 | mask: res.mask, 212 | }) 213 | session.messages.push(...res.messages) 214 | 215 | const throttledSave = useThrottleFn( 216 | () => { 217 | saveSessionToLocalStorage(toRaw(session)) 218 | }, 219 | 1000, 220 | true, 221 | true, 222 | ) 223 | 224 | const onNewMessage = (message: string) => { 225 | const { chat, message: currentMessage } = useChatBot() 226 | console.log(currentMessage) 227 | const lastMessages = session.messages.slice(-4).map((message) => { 228 | return { 229 | role: message.role, 230 | content: message.content, 231 | } 232 | }) 233 | const payload = { 234 | messages: [ 235 | ...lastMessages, 236 | { 237 | role: "user", 238 | content: message, 239 | }, 240 | ], 241 | model: "gpt-3.5-turbo", 242 | presence_penalty: session.modelConfig.presencePenalty, 243 | stream: true, 244 | temperature: session.modelConfig.temperature, 245 | } 246 | const latestMessage = session.messages[session.messages.length - 1] 247 | if (latestMessage) { 248 | session.latestMessageId = latestMessage.id! 249 | } 250 | 251 | session.latestMessageId++ 252 | session.messages.push({ 253 | role: "user", 254 | content: message, 255 | date: new Date().toISOString(), 256 | direction: TChatDirection.SEND, 257 | streaming: false, 258 | isError: false, 259 | id: session.latestMessageId, 260 | }) 261 | session.latestMessageId++ 262 | const newMessage = { 263 | role: "user", 264 | content: "...", 265 | date: new Date().toISOString(), 266 | direction: TChatDirection.RECEIVE, 267 | streaming: false, 268 | isError: false, 269 | id: session.latestMessageId, 270 | } 271 | session.messages.push(newMessage) 272 | session.messagesCount = session.messages.length 273 | const nMessage = session.messages[session.messages.length - 1] 274 | let loadingDots = 0 275 | const loadingInterval = setInterval(() => { 276 | loadingDots = (loadingDots + 1) % 3 277 | nMessage.content = ".".repeat(loadingDots + 1) 278 | }, 500) 279 | chatStore.refreshSession(session) 280 | 281 | chat( 282 | payload as TOpenApiChatCompletionMessage, 283 | () => {}, 284 | (message) => { 285 | clearInterval(loadingInterval) // 清除定时器 286 | nMessage.content = message.content 287 | throttledSave() 288 | }, 289 | (message) => { 290 | clearInterval(loadingInterval) // 清除定时器 291 | // 在聊天中展示 error message 292 | nMessage.isError = true 293 | nMessage.content = `Error occurred: ${message.errorMessage}` 294 | console.error("Error occurred:", message.errorMessage) 295 | }, 296 | () => {}, 297 | ).then() 298 | } 299 | 300 | // eslint-disable-next-line require-await 301 | const onUserInput = async (content: string) => { 302 | // Add your logic for handling user input 303 | console.log("nextSession", content) 304 | } 305 | 306 | const rename = () => { 307 | const name = prompt("请输入会话名称", session.topic) 308 | if (name) { 309 | const oldName = session.topic 310 | session.topic = name || oldName 311 | chatStore.refreshSession(session) 312 | } 313 | } 314 | 315 | const deleteMessage = (id: number) => { 316 | // check and delete message 317 | const index = session.messages.findIndex((message) => message.id === id) 318 | if (index !== -1) { 319 | session.messages.splice(index, 1) 320 | session.messagesCount = session.messages.length 321 | } 322 | // chatStore.saveAll() 323 | } 324 | 325 | const result = { 326 | session, 327 | onUserInput, 328 | rename, 329 | onNewMessage, 330 | deleteMessage, 331 | // ... 其他与单个会话相关的方法 332 | } 333 | cachedChatSession.set(sid, result) 334 | return result 335 | } 336 | -------------------------------------------------------------------------------- /src/composables/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/composables/index.ts -------------------------------------------------------------------------------- /src/composables/locales.ts: -------------------------------------------------------------------------------- 1 | import { TLocale } from "~/locales/en" 2 | import { TLocaleKeys } from "~/locales/schema" 3 | 4 | export const useTrans = () => { 5 | const { t, n, setLocale } = useI18n<{ message: TLocale }>({ 6 | useScope: "global", 7 | }) 8 | const trans = (key: TLocaleKeys, context?: any) => t(String(key), context) 9 | 10 | return { 11 | t: trans, 12 | setLocale, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/composables/mask.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { useSettingStore } from "~/composables/settings" 3 | import { StoreKey } from "~/constants" 4 | 5 | interface TPromptsJson { 6 | cn: string[] 7 | en: string[] 8 | } 9 | 10 | export enum TLang { 11 | cn, 12 | en, 13 | } 14 | 15 | export interface TPrompts { 16 | name: string 17 | description: string 18 | lang: string 19 | } 20 | 21 | function hasMatch(s: string, q?: string) { 22 | return !q || s.toLowerCase().includes(q.toLowerCase()) 23 | } 24 | 25 | export const useMasks = defineStore(StoreKey.Mask, () => { 26 | const { settings } = useSettingStore() 27 | const masks = ref([]) 28 | const maskRows = ref([]) 29 | const searchedMasks = ref([]) 30 | const load = async () => { 31 | const newMasks = [] 32 | const data = await $fetch("/prompts.json") 33 | for (const [title, description] of data.cn) { 34 | newMasks.push({ 35 | name: title, 36 | description, 37 | lang: "zh_CN", 38 | }) 39 | } 40 | for (const [title, description] of data.en) { 41 | newMasks.push({ 42 | name: title, 43 | description, 44 | lang: "en", 45 | }) 46 | } 47 | masks.value.push(...newMasks.filter((m) => m.name)) 48 | masks.value.sort((a) => { 49 | if (a.lang === settings.language) { 50 | return -1 51 | } else { 52 | return 1 53 | } 54 | }) 55 | searchedMasks.value = masks.value 56 | } 57 | const search = ({ q, language }: { q?: string; language?: string }) => { 58 | // if (!q) { 59 | // searchedMasks.value = masks.value 60 | // return 61 | // } 62 | searchedMasks.value = masks.value.filter((m) => { 63 | const hasMatchingName = hasMatch(m.name, q) 64 | const hasMatchingDescription = hasMatch(m.description, q) 65 | const hasMatchingLanguage = hasMatch(m.lang, language) 66 | return (hasMatchingName || hasMatchingDescription) && hasMatchingLanguage 67 | }) 68 | } 69 | const computeMaskRows = ({ width, height }: { width: number; height: number }) => { 70 | if (!masks.value || masks.value.length === 0) { 71 | return 72 | } 73 | const maxWidth = width 74 | const maxHeight = height * 0.6 75 | // why 120? 76 | const maskItemWidth = 120 77 | const maskItemHeight = 50 78 | 79 | const randomMask = () => masks.value[Math.floor(Math.random() * masks.value.length)] 80 | let maskIndex = 0 81 | const nextMask = () => masks.value[maskIndex++ % masks.value.length] 82 | 83 | const rows = Math.ceil(maxHeight / maskItemHeight) 84 | const cols = Math.ceil(maxWidth / maskItemWidth) 85 | 86 | maskRows.value = new Array(rows) 87 | .fill(0) 88 | .map((_, _i) => new Array(cols).fill(0).map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask()))) 89 | } 90 | 91 | onMounted(() => { 92 | load().then(() => {}) 93 | }) 94 | 95 | return { 96 | masks, 97 | maskRows, 98 | load, 99 | computeMaskRows, 100 | searchedMasks, 101 | search, 102 | } 103 | }) 104 | -------------------------------------------------------------------------------- /src/composables/prompt.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/composables/prompt.ts -------------------------------------------------------------------------------- /src/composables/settings.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | import { reactive } from "vue" 3 | import { useTrans } from "~/composables/locales" 4 | import { loadFromLocalStorage, saveToLocalStorage } from "~/composables/storage" 5 | import { ALL_MODELS, SubmitKey } from "~/constants/typing" 6 | import { FETCH_COMMIT_URL, StoreKey } from "~/constants" 7 | 8 | export interface TSelectOption { 9 | label: string 10 | value: string | number 11 | } 12 | 13 | export const keyMaps = [ 14 | { 15 | label: SubmitKey.Enter, 16 | keys: ["Enter"], 17 | }, 18 | { 19 | label: SubmitKey.CtrlEnter, 20 | keys: ["Control", "Enter"], 21 | }, 22 | { 23 | label: SubmitKey.ShiftEnter, 24 | keys: ["Shift", "Enter"], 25 | }, 26 | { 27 | label: SubmitKey.AltEnter, 28 | keys: ["Alt", "Enter"], 29 | }, 30 | ] 31 | 32 | // use label as key and value 33 | 34 | export const sendKeyOptions: TSelectOption[] = keyMaps.map((keyMap) => { 35 | return { 36 | label: keyMap.label, 37 | value: keyMap.label, 38 | } 39 | }) 40 | export const themeOptions: TSelectOption[] = [ 41 | { 42 | label: "Auto", 43 | value: "system", 44 | }, 45 | { 46 | label: "Light", 47 | value: "light", 48 | }, 49 | { 50 | label: "Dark", 51 | value: "dark", 52 | }, 53 | ] 54 | 55 | export const languageOptions = [ 56 | { 57 | label: "English", 58 | value: "en", 59 | }, 60 | { 61 | label: "简体中文", 62 | value: "zh_CN", 63 | }, 64 | // { 65 | // label: "繁體中文", 66 | // value: "tw", 67 | // }, 68 | // { 69 | // label: "Français", 70 | // value: "fr", 71 | // }, 72 | // { 73 | // label: "Español", 74 | // value: "es", 75 | // }, 76 | // { 77 | // label: "Italiano", 78 | // value: "it", 79 | // }, 80 | // { 81 | // label: "Türkçe", 82 | // value: "tr", 83 | // }, 84 | // { 85 | // label: "日本語", 86 | // value: "jp", 87 | // }, 88 | // { 89 | // label: "Deutsch", 90 | // value: "de", 91 | // }, 92 | // { 93 | // label: "Tiếng Việt", 94 | // value: "vi", 95 | // }, 96 | // { 97 | // label: "Русский", 98 | // value: "ru", 99 | // }, 100 | // { 101 | // label: "Čeština", 102 | // value: "cs", 103 | // }, 104 | // { 105 | // label: "한국어", 106 | // value: "ko", 107 | // }, 108 | ] 109 | 110 | export const modelOptions: TSelectOption[] = ALL_MODELS.map((option) => { 111 | return { 112 | label: option.label, 113 | value: option.value, 114 | } 115 | }) 116 | 117 | const defaultSettings = { 118 | avatar: "🙂", 119 | sendKey: "Enter", 120 | theme: "Auto", 121 | language: "en", 122 | fontSize: 14, 123 | previewBubble: false, 124 | maskLaunchPage: false, 125 | model: "gpt-3.5-turbo", 126 | apiKey: "", 127 | maxTokens: 2000, 128 | temperature: 0.5, 129 | presencePenalty: 0.0, 130 | frequencyPenalty: 0.0, 131 | historyMessagesCount: 4, 132 | compressMessageLengthThreshold: 1000, 133 | serverUrl: "https://api.openai.com", 134 | historySummary: false, 135 | disableAutoCompletePrompt: false, 136 | latestCommitDate: "", 137 | remoteLatestCommitDate: "", 138 | hasNewVersion: false, 139 | } 140 | 141 | export const useSettingStore = defineStore(StoreKey.Setting, () => { 142 | const settings = reactive(loadFromLocalStorage(StoreKey.Setting, defaultSettings)) 143 | const runtimeConfig = useRuntimeConfig() 144 | const { setLocale } = useTrans() 145 | setLocale(settings.language) 146 | 147 | const fetchRemoteLatestCommitDate = () => { 148 | if (runtimeConfig.public.LATEST_COMMIT_DATE) { 149 | settings.latestCommitDate = runtimeConfig.public.LATEST_COMMIT_DATE as string 150 | settings.hasNewVersion = settings.latestCommitDate < settings.remoteLatestCommitDate 151 | } 152 | fetch(FETCH_COMMIT_URL) 153 | .then((response) => response.json()) 154 | .then((data) => { 155 | const date = data[0].commit.author.date 156 | settings.remoteLatestCommitDate = date.slice(0, 10).replace(/-/g, "") 157 | }) 158 | .catch((error) => console.error(error)) 159 | } 160 | watch( 161 | () => settings, 162 | (value) => { 163 | document.documentElement.style.setProperty("--markdown-font-size", `${value.fontSize}px`) 164 | saveToLocalStorage(StoreKey.Setting, settings) 165 | }, 166 | { deep: true }, 167 | ) 168 | return { 169 | settings, 170 | fetchRemoteLatestCommitDate, 171 | } 172 | }) 173 | -------------------------------------------------------------------------------- /src/composables/storage.ts: -------------------------------------------------------------------------------- 1 | import { StoreKey } from "~/constants" 2 | import { TChatSession } from "~/constants/typing" 3 | 4 | interface ChatSessionStorage { 5 | sessions: TChatSession[] 6 | sessionGid: number 7 | } 8 | 9 | const defaultChatSessionStorage: ChatSessionStorage = { 10 | sessions: [], 11 | sessionGid: 10000, 12 | } 13 | 14 | export const loadSessionFromLocalStorage = (sid: string): TChatSession | null => { 15 | const chatSessionStorage = loadFromLocalStorage(StoreKey.ChatSession, defaultChatSessionStorage) 16 | const session = chatSessionStorage.sessions.find((s) => s.id === sid) 17 | if (session) { 18 | return session 19 | } 20 | return null 21 | } 22 | export const saveSessionToLocalStorage = (newSession: TChatSession): void => { 23 | const chatSessionStorage = loadFromLocalStorage(StoreKey.ChatSession, defaultChatSessionStorage) 24 | 25 | const existingSessionIndex = chatSessionStorage.sessions.findIndex((s) => s.id === newSession.id) 26 | 27 | if (existingSessionIndex !== -1) { 28 | chatSessionStorage.sessions[existingSessionIndex] = newSession 29 | } else { 30 | chatSessionStorage.sessions.push(newSession) 31 | } 32 | 33 | saveToLocalStorage(StoreKey.ChatSession, chatSessionStorage) 34 | } 35 | export const loadFromLocalStorage = (key: string, defaultValue: T): T => { 36 | const storedValue = localStorage.getItem(key) 37 | if (storedValue) { 38 | try { 39 | return JSON.parse(storedValue) 40 | } catch (e) { 41 | return defaultValue 42 | } 43 | } 44 | return defaultValue 45 | } 46 | 47 | export const saveToLocalStorage = (key: string, value: T) => { 48 | localStorage.setItem(key, JSON.stringify(value)) 49 | } 50 | -------------------------------------------------------------------------------- /src/composables/update.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/composables/update.ts -------------------------------------------------------------------------------- /src/composables/useChatBot.ts: -------------------------------------------------------------------------------- 1 | import { EventStreamContentType, fetchEventSource } from "@fortaine/fetch-event-source" 2 | import { useSettingStore } from "~/composables/settings" 3 | 4 | type TChatCompletionMessage = { 5 | content: string 6 | status: "pending" | "connected" | "client error" | "messaging" | "error" | "done" 7 | errorMessage: string 8 | } 9 | 10 | function isJsonString(str: string) { 11 | try { 12 | JSON.parse(str) 13 | } catch (e) { 14 | return false 15 | } 16 | return true 17 | } 18 | 19 | export default function useChatBot() { 20 | const { settings } = useSettingStore() 21 | const message = reactive({ 22 | content: "", 23 | status: "pending", 24 | errorMessage: "", 25 | }) 26 | 27 | async function chat( 28 | payload: TOpenApiChatCompletion, 29 | onStart: (message: TChatCompletionMessage) => void, 30 | onMessage: (message: TChatCompletionMessage) => void, 31 | onError: (message: TChatCompletionMessage) => void, 32 | onDone: (message: TChatCompletionMessage) => void, 33 | ) { 34 | const BASE_URL = settings.serverUrl 35 | const TOKEN = settings.apiKey 36 | const API_URL = `${BASE_URL}/v1/chat/completions` 37 | 38 | const controller = new AbortController() 39 | 40 | try { 41 | await fetchEventSource(API_URL, { 42 | method: "POST", 43 | headers: { 44 | Authorization: `Bearer ${TOKEN}`, 45 | "Content-Type": "application/json", 46 | }, 47 | body: JSON.stringify({ 48 | ...payload, 49 | }), 50 | signal: controller.signal, 51 | 52 | async onopen(resp) { 53 | if (resp.ok && resp.headers.get("content-type") === EventStreamContentType) { 54 | message.status = "connected" 55 | onStart(message) 56 | return // everything's good 57 | } 58 | if (resp.status >= 400 && resp.status < 500 && resp.status !== 429) { 59 | message.status = "error" 60 | message.errorMessage = `server side error ${resp.status || resp.statusText} ${resp.type}` 61 | controller.abort("Error parsing response") 62 | onError(message) 63 | console.log("Client-side error ", resp) 64 | return 65 | } 66 | const content = await resp.text() 67 | console.log("Response content ", content) 68 | try { 69 | console.log("Error message ", content) 70 | if (isJsonString(content)) { 71 | message.errorMessage = content 72 | controller.abort("Server Error") 73 | onError(message) 74 | } 75 | } catch (e) { 76 | controller.abort("Error parsing response") 77 | } 78 | }, 79 | onmessage(ev) { 80 | console.log("messaging-side error ", ev.data) 81 | if (ev.data === "[DONE]") { 82 | message.status = "done" 83 | onDone(message) 84 | return 85 | } 86 | if (!ev.data) { 87 | return 88 | } 89 | try { 90 | const parsedData = JSON.parse(ev.data) 91 | const content = parsedData.choices[0]?.delta?.content 92 | if (content) { 93 | message.content += content 94 | onMessage(message) 95 | } 96 | } catch (e) { 97 | message.content = String(e) 98 | controller.abort("Error message response") 99 | onError(message) 100 | } 101 | }, 102 | onerror(err) { 103 | message.status = "error" 104 | message.errorMessage = `error ${err.status || err.statusText || ""} ${err.type || ""}` 105 | onError(message) 106 | throw err 107 | }, 108 | }) 109 | } catch (e) { 110 | message.content = String(e) 111 | } 112 | } 113 | 114 | return { 115 | message, 116 | chat, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/composables/useGlobalCss.ts: -------------------------------------------------------------------------------- 1 | export const useGlobalCssVar = () => { 2 | const windowWidth = useCssVar("--window-width") 3 | const windowHeight = useCssVar("--window-height") 4 | const sidebarWidth = useCssVar("--sidebar-width") 5 | 6 | const setupMobile = () => { 7 | windowWidth.value = "100vw" 8 | windowHeight.value = "100vh" 9 | sidebarWidth.value = "100vw" 10 | } 11 | 12 | return { 13 | setupMobile, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/composables/useSidebar.ts: -------------------------------------------------------------------------------- 1 | const routePrefix = ["/chat/session", "/chat/settings", "/chat/new", "/chat/plugins", "/chat/masks"] 2 | const visible = ref(true) 3 | 4 | export const useSidebar = () => { 5 | const { isMobile } = useDevice() 6 | const route = useRoute() 7 | 8 | const computeMobileFullScreen = () => { 9 | if (!isMobile) { 10 | visible.value = true 11 | return 12 | } 13 | visible.value = !routePrefix.some((prefix) => route.path.startsWith(prefix)) 14 | console.log("hide sidebar", visible.value) 15 | } 16 | 17 | onMounted(() => { 18 | computeMobileFullScreen() 19 | }) 20 | const show = () => { 21 | visible.value = true 22 | } 23 | const hide = () => { 24 | visible.value = false 25 | } 26 | 27 | const hideIfMobile = () => { 28 | if (!isMobile) { 29 | visible.value = true 30 | return 31 | } 32 | visible.value = false 33 | } 34 | 35 | return { 36 | visible, 37 | hideIfMobile, 38 | show, 39 | hide, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/composables/user.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from "pinia" 2 | 3 | export const useUserStore = defineStore("user", () => { 4 | /** 5 | * Current named of the user. 6 | */ 7 | const savedName = ref("") 8 | const previousNames = ref(new Set()) 9 | 10 | const usedNames = computed(() => Array.from(previousNames.value)) 11 | const otherNames = computed(() => usedNames.value.filter(name => name !== savedName.value)) 12 | 13 | /** 14 | * Changes the current name of the user and saves the one that was used 15 | * before. 16 | * 17 | * @param name - new name to set 18 | */ 19 | function setNewName (name: string) { 20 | if (savedName.value) { previousNames.value.add(savedName.value) } 21 | 22 | savedName.value = name 23 | } 24 | 25 | return { 26 | setNewName, 27 | otherNames, 28 | savedName, 29 | } 30 | }) 31 | 32 | if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) } 33 | -------------------------------------------------------------------------------- /src/config/pwa.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from "@vite-pwa/nuxt" 2 | import { appDescription, appName } from "../constants" 3 | 4 | const scope = "/" 5 | 6 | export const pwa: ModuleOptions = { 7 | registerType: "autoUpdate", 8 | scope, 9 | base: scope, 10 | manifest: { 11 | id: scope, 12 | scope, 13 | name: appName, 14 | short_name: appName, 15 | description: appDescription, 16 | theme_color: "#ffffff", 17 | icons: [ 18 | { 19 | src: "pwa-192x192.png", 20 | sizes: "192x192", 21 | type: "image/png", 22 | }, 23 | { 24 | src: "pwa-512x512.png", 25 | sizes: "512x512", 26 | type: "image/png", 27 | }, 28 | { 29 | src: "maskable-icon.png", 30 | sizes: "512x512", 31 | type: "image/png", 32 | purpose: "any maskable", 33 | }, 34 | ], 35 | }, 36 | workbox: { 37 | globPatterns: ["**/*.{js,css,html,txt,png,ico,svg}"], 38 | navigateFallbackDenylist: [/^\/api\//], 39 | navigateFallback: "/", 40 | cleanupOutdatedCaches: true, 41 | runtimeCaching: [ 42 | { 43 | urlPattern: /^https:\/\/fonts.googleapis.com\/.*/i, 44 | handler: "CacheFirst", 45 | options: { 46 | cacheName: "google-fonts-cache", 47 | expiration: { 48 | maxEntries: 10, 49 | maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days 50 | }, 51 | cacheableResponse: { 52 | statuses: [0, 200], 53 | }, 54 | }, 55 | }, 56 | { 57 | urlPattern: /^https:\/\/fonts.gstatic.com\/.*/i, 58 | handler: "CacheFirst", 59 | options: { 60 | cacheName: "gstatic-fonts-cache", 61 | expiration: { 62 | maxEntries: 10, 63 | maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days 64 | }, 65 | cacheableResponse: { 66 | statuses: [0, 200], 67 | }, 68 | }, 69 | }, 70 | ], 71 | }, 72 | registerWebManifestInRouteRules: true, 73 | writePlugin: true, 74 | devOptions: { 75 | enabled: process.env.VITE_PLUGIN_PWA === "true", 76 | navigateFallback: scope, 77 | }, 78 | } 79 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const appName = "ChatGPT Nuxt" 2 | export const appDescription = "ChatGPT Nuxt" 3 | 4 | export const OWNER = "hylarucoder" 5 | export const REPO = "ChatGPT-Nuxt" 6 | export const REPO_URL = `https://github.com/${OWNER}/${REPO}` 7 | export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues` 8 | export const UPDATE_URL = `${REPO_URL}#keep-updated` 9 | export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?sha=main&per_page=1` 10 | export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1` 11 | export const RUNTIME_CONFIG_DOM = "danger-runtime-config" 12 | 13 | export enum Path { 14 | Chat = "/chat", 15 | Settings = "/chat/settings", 16 | NewChat = "/chat/new", 17 | Masks = "/chat/masks", 18 | Auth = "/chat/auth", 19 | } 20 | 21 | export enum FileName { 22 | Masks = "masks.json", 23 | Prompts = "prompts.json", 24 | } 25 | 26 | export enum StoreKey { 27 | Chat = "store-chat", 28 | ChatSession = "store-chat-sessions", 29 | Access = "access-control", 30 | Setting = "store-setting-v1", 31 | Mask = "store-mask", 32 | Prompt = "store-prompt", 33 | Update = "store-update", 34 | } 35 | 36 | export const MAX_SIDEBAR_WIDTH = 500 37 | export const MIN_SIDEBAR_WIDTH = 230 38 | export const NARROW_SIDEBAR_WIDTH = 100 39 | 40 | export const ACCESS_CODE_PREFIX = "ak-" 41 | 42 | export const LAST_INPUT_KEY = "last-input" 43 | 44 | export const REQUEST_TIMEOUT_MS = 60000 45 | 46 | export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown" 47 | 48 | export const OpenaiPath = { 49 | ChatPath: "v1/chat/completions", 50 | UsagePath: "dashboard/billing/usage", 51 | SubsPath: "dashboard/billing/subscription", 52 | } 53 | 54 | export const DEFAULT_INPUT_TEMPLATE = `{{input}}` // input / time / model / lang 55 | -------------------------------------------------------------------------------- /src/constants/typing.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_INPUT_TEMPLATE } from "~/constants" 2 | 3 | export const ROLES = ["system", "user", "assistant"] 4 | export type MessageRole = (typeof ROLES)[number] 5 | 6 | export const ALL_MODELS = [ 7 | { 8 | label: "gpt-4", 9 | value: "gpt-4", 10 | }, 11 | { 12 | label: "gpt-4-0314", 13 | value: "gpt-4-0314", 14 | }, 15 | { 16 | label: "gpt-4-32k", 17 | value: "gpt-4-32k", 18 | }, 19 | { 20 | label: "gpt-4-32k-0314", 21 | value: "gpt-4-32k-0314", 22 | }, 23 | { 24 | label: "gpt-4-mobile", 25 | value: "gpt-4-mobile", 26 | }, 27 | { 28 | label: "text-davinci-002-render-sha-mobile", 29 | value: "text-davinci-002-render-sha-mobile", 30 | }, 31 | { 32 | label: "gpt-3.5-turbo", 33 | value: "gpt-3.5-turbo", 34 | }, 35 | { 36 | label: "gpt-3.5-turbo-0301", 37 | value: "gpt-3.5-turbo-0301", 38 | }, 39 | { 40 | label: "qwen-v1", 41 | value: "qwen-v1", 42 | }, 43 | { 44 | label: "ernie", 45 | value: "ernie", 46 | }, 47 | { 48 | label: "spark", 49 | value: "spark", 50 | }, 51 | { 52 | label: "llama", 53 | value: "llama", 54 | }, 55 | { 56 | label: "chatglm", 57 | value: "chatglm", 58 | }, 59 | ] as const 60 | 61 | export type TModelType = (typeof ALL_MODELS)[number]["label"] 62 | 63 | export enum SubmitKey { 64 | Enter = "Enter", 65 | CtrlEnter = "Ctrl + Enter", 66 | ShiftEnter = "Shift + Enter", 67 | AltEnter = "Alt + Enter", 68 | } 69 | 70 | export enum Theme { 71 | Auto = "auto", 72 | Dark = "dark", 73 | Light = "light", 74 | } 75 | 76 | export interface TModelConfig { 77 | model: TModelType 78 | temperature: number 79 | maxTokens: number 80 | presencePenalty: number 81 | frequencyPenalty: number 82 | sendMemory: boolean 83 | historyMessageCount: number 84 | compressMessageLengthThreshold: number 85 | template: string 86 | } 87 | 88 | export interface TConfig { 89 | submitKey: SubmitKey 90 | avatar: string 91 | fontSize: number 92 | theme: Theme 93 | tightBorder: boolean 94 | sendPreviewBubble: boolean 95 | sidebarWidth: number 96 | disablePromptHint: boolean 97 | dontShowMaskSplashScreen: boolean 98 | modelConfig: TModelConfig 99 | } 100 | 101 | export const DEFAULT_CONFIG: TConfig = { 102 | submitKey: SubmitKey.CtrlEnter, 103 | avatar: "1f603", 104 | fontSize: 14, 105 | theme: Theme.Auto as Theme, 106 | tightBorder: true, 107 | sendPreviewBubble: false, 108 | sidebarWidth: 300, 109 | disablePromptHint: false, 110 | dontShowMaskSplashScreen: false, // dont show splash screen when create chat 111 | 112 | modelConfig: { 113 | model: "gpt-3.5-turbo" as TModelType, 114 | temperature: 0.5, 115 | maxTokens: 2000, 116 | presencePenalty: 0, 117 | frequencyPenalty: 0, 118 | sendMemory: true, 119 | historyMessageCount: 4, 120 | compressMessageLengthThreshold: 1000, 121 | template: DEFAULT_INPUT_TEMPLATE, 122 | }, 123 | } 124 | 125 | export interface RequestMessage { 126 | role: MessageRole 127 | content: string 128 | } 129 | 130 | export type MessageContext = { 131 | role: MessageRole 132 | content: string 133 | } 134 | 135 | const ALL_LANG = { 136 | en: "English", 137 | cn: "Chinese", 138 | } 139 | 140 | export type Lang = keyof typeof ALL_LANG 141 | 142 | export type TMask = { 143 | id: number 144 | avatar: string 145 | name: string 146 | hideContext?: boolean 147 | context: MessageContext[] 148 | syncGlobalConfig?: boolean 149 | lang: Lang 150 | builtin: boolean 151 | } 152 | 153 | export enum TChatDirection { 154 | 155 | SEND = "SEND", 156 | 157 | RECEIVE = "RECEIVE", 158 | } 159 | 160 | export type TChatMessage = RequestMessage & { 161 | date: string 162 | streaming?: boolean 163 | isError?: boolean 164 | id?: number 165 | model?: TModelType 166 | direction: TChatDirection 167 | } 168 | 169 | export interface TChatStat { 170 | tokenCount: number 171 | wordCount: number 172 | charCount: number 173 | } 174 | 175 | export interface TChatSession { 176 | id: string 177 | topic: string 178 | avatar: string 179 | latestMessageId: number 180 | composeInput: string 181 | memoryPrompt: string 182 | messagesCount: number 183 | messages: TChatMessage[] 184 | stat: TChatStat 185 | modelConfig: TModelConfig 186 | lastUpdate: string 187 | lastSummarizeIndex: number 188 | clearContextIndex?: number 189 | mask?: TMask 190 | } 191 | -------------------------------------------------------------------------------- /src/layouts/README.md: -------------------------------------------------------------------------------- 1 | ## Layouts 2 | 3 | Vue components in this dir are used as layouts. 4 | 5 | By default, `default.vue` will be used unless an alternative is specified in the route meta. 6 | 7 | ```html 8 | 13 | ``` 14 | 15 | Learn more on https://nuxt.com/docs/guide/directory-structure/layouts 16 | -------------------------------------------------------------------------------- /src/layouts/custom.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/locales/en.ts: -------------------------------------------------------------------------------- 1 | const en = { 2 | WIP: "Coming Soon...", 3 | Error: { 4 | Unauthorized: "Unauthorized access, please enter access code in [auth](/#/auth) page.", 5 | }, 6 | Auth: { 7 | Title: "Need Access Code", 8 | Tips: "Please enter access code below", 9 | Input: "access code", 10 | Confirm: "Confirm", 11 | Later: "Later", 12 | }, 13 | ChatItem: { 14 | ChatItemCount: `{count} messages`, 15 | }, 16 | Chat: { 17 | SubTitle: `{count} messages`, 18 | Actions: { 19 | ChatList: "Go To Chat List", 20 | CompressedHistory: "Compressed History Memory Prompt", 21 | Export: "Export All Messages as Markdown", 22 | Copy: "Copy", 23 | Stop: "Stop", 24 | Retry: "Retry", 25 | Pin: "Pin", 26 | PinToastContent: "Pinned 2 messages to contextual prompts", 27 | PinToastAction: "View", 28 | Delete: "Delete", 29 | Edit: "Edit", 30 | }, 31 | Commands: { 32 | new: "Start a new chat", 33 | newm: "Start a new chat with mask", 34 | next: "Next Chat", 35 | prev: "Previous Chat", 36 | clear: "Clear Context", 37 | del: "Delete Chat", 38 | }, 39 | InputActions: { 40 | Stop: "Stop", 41 | ToBottom: "To Latest", 42 | Theme: { 43 | auto: "Auto", 44 | light: "Light Theme", 45 | dark: "Dark Theme", 46 | }, 47 | Prompt: "Prompts", 48 | Masks: "Masks", 49 | Clear: "Clear Context", 50 | Settings: "Settings", 51 | }, 52 | Rename: "Rename Chat", 53 | Typing: "Typing…", 54 | // Input: (submitKey: string) => { 55 | // var inputHints = `${submitKey} to send` 56 | // if (submitKey === String(SubmitKey.Enter)) { 57 | // inputHints += ", Shift + Enter to wrap" 58 | // } 59 | // return inputHints + ", / to search prompts, : to use commands" 60 | // }, 61 | Send: "Send", 62 | Config: { 63 | Reset: "Reset to Default", 64 | SaveAs: "Save as Mask", 65 | }, 66 | }, 67 | Export: { 68 | Title: "Export Messages", 69 | Copy: "Copy All", 70 | Download: "Download", 71 | MessageFromYou: "Message From You", 72 | MessageFromChatGPT: "Message From ChatGPT", 73 | Share: "Share to ShareGPT", 74 | Format: { 75 | Title: "Export Format", 76 | SubTitle: "Markdown or PNG Image", 77 | }, 78 | IncludeContext: { 79 | Title: "Including Context", 80 | SubTitle: "Export context prompts in mask or not", 81 | }, 82 | Steps: { 83 | Select: "Select", 84 | Preview: "Preview", 85 | }, 86 | }, 87 | Select: { 88 | Search: "Search", 89 | All: "Select All", 90 | Latest: "Select Latest", 91 | Clear: "Clear", 92 | }, 93 | Memory: { 94 | Title: "Memory Prompt", 95 | EmptyContent: "Nothing yet.", 96 | Send: "Send Memory", 97 | Copy: "Copy Memory", 98 | Reset: "Reset Session", 99 | ResetConfirm: 100 | "Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?", 101 | }, 102 | Home: { 103 | NewChat: "New Chat", 104 | DeleteChat: "Confirm to delete the selected conversation?", 105 | DeleteToast: "Chat Deleted", 106 | Revert: "Revert", 107 | }, 108 | Settings: { 109 | Title: "Settings", 110 | SubTitle: "All Settings", 111 | Danger: { 112 | Reset: { 113 | Title: "Reset All Settings", 114 | SubTitle: "Reset all setting items to default", 115 | Action: "Reset", 116 | Confirm: "Confirm to reset all settings to default?", 117 | }, 118 | Clear: { 119 | Title: "Clear All Data", 120 | SubTitle: "Clear all messages and settings", 121 | Action: "Clear", 122 | Confirm: "Confirm to clear all messages and settings?", 123 | }, 124 | }, 125 | Lang: { 126 | Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language` 127 | All: "All", 128 | }, 129 | Avatar: "Avatar", 130 | FontSize: { 131 | Title: "Font Size", 132 | SubTitle: "Adjust font size of chat content", 133 | }, 134 | 135 | InputTemplate: { 136 | Title: "Input Template", 137 | SubTitle: "Newest message will be filled to this template", 138 | }, 139 | 140 | Update: { 141 | Version: `Version: {x}`, 142 | IsLatest: "Latest version", 143 | CheckUpdate: "Check Update", 144 | IsChecking: "Checking update...", 145 | FoundUpdate: `Found new version: {x}`, 146 | GoToUpdate: "Update", 147 | }, 148 | SendKey: "Send Key", 149 | Theme: "Theme", 150 | TightBorder: "Tight Border", 151 | SendPreviewBubble: { 152 | Title: "Send Preview Bubble", 153 | SubTitle: "Preview markdown in bubble", 154 | }, 155 | Mask: { 156 | Title: "Mask Splash Screen", 157 | SubTitle: "Show a mask splash screen before starting new chat", 158 | }, 159 | Prompt: { 160 | Disable: { 161 | Title: "Disable auto-completion", 162 | SubTitle: "Input / to trigger auto-completion", 163 | }, 164 | List: "Prompt List", 165 | ListCount: `{builtin} built-in, {custom} user-defined`, 166 | Edit: "Edit", 167 | Modal: { 168 | Title: "Prompt List", 169 | Add: "Add One", 170 | Search: "Search Prompts", 171 | }, 172 | EditModal: { 173 | Title: "Edit Prompt", 174 | }, 175 | }, 176 | HistoryCount: { 177 | Title: "Attached Messages Count", 178 | SubTitle: "Number of sent messages attached per request", 179 | }, 180 | CompressThreshold: { 181 | Title: "History Compression Threshold", 182 | SubTitle: "Will compress if uncompressed messages length exceeds the value", 183 | }, 184 | Token: { 185 | Title: "API Key", 186 | SubTitle: "Use your key to ignore access code limit", 187 | Placeholder: "OpenAI API Key", 188 | }, 189 | Usage: { 190 | Title: "Account Balance", 191 | SubTitle: `Used this month \${used}, subscription \${total}`, 192 | IsChecking: "Checking...", 193 | Check: "Check", 194 | NoAccess: "Enter API Key to check balance", 195 | }, 196 | AccessCode: { 197 | Title: "Access Code", 198 | SubTitle: "Access control enabled", 199 | Placeholder: "Need Access Code", 200 | }, 201 | Endpoint: { 202 | Title: "Endpoint", 203 | SubTitle: "Custom endpoint must start with http(s)://", 204 | }, 205 | Model: "Model", 206 | Temperature: { 207 | Title: "Temperature", 208 | SubTitle: "A larger value makes the more random output", 209 | }, 210 | MaxTokens: { 211 | Title: "Max Tokens", 212 | SubTitle: "Maximum length of input tokens and generated tokens", 213 | }, 214 | PresencePenalty: { 215 | Title: "Presence Penalty", 216 | SubTitle: "A larger value increases the likelihood to talk about new topics", 217 | }, 218 | FrequencyPenalty: { 219 | Title: "Frequency Penalty", 220 | SubTitle: "A larger value decreasing the likelihood to repeat the same line", 221 | }, 222 | }, 223 | Store: { 224 | DefaultTopic: "New Conversation", 225 | BotHello: "Hello! How can I assist you today?", 226 | Error: "Something went wrong, please try again later.", 227 | Prompt: { 228 | History: "This is a summary of the chat history as a recap: {content}", 229 | Topic: 230 | "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.", 231 | Summarize: "Summarize the discussion briefly in 200 words or less to use as a prompt for future context.", 232 | }, 233 | }, 234 | Copy: { 235 | Success: "Copied to clipboard", 236 | Failed: "Copy failed, please grant permission to access clipboard", 237 | }, 238 | Context: { 239 | Toast: `With {x} contextual prompts`, 240 | Edit: "Contextual and Memory Prompts", 241 | Add: "Add a Prompt", 242 | Clear: "Context Cleared", 243 | Revert: "Revert", 244 | }, 245 | Plugin: { 246 | Name: "Plugin", 247 | Page: { 248 | Title: "Plugins", 249 | SubTitle: "Some Plugins You May Like", 250 | }, 251 | }, 252 | Mask: { 253 | Name: "Mask", 254 | Page: { 255 | Title: "Prompt Template", 256 | SubTitle: `{count} prompt templates`, 257 | Search: "Search Templates", 258 | Create: "Create", 259 | }, 260 | Item: { 261 | Info: `{count} prompts`, 262 | Chat: "Chat", 263 | View: "View", 264 | Edit: "Edit", 265 | Delete: "Delete", 266 | DeleteConfirm: "Confirm to delete?", 267 | }, 268 | EditModal: { 269 | // Title: (readonly: boolean) => `Edit Prompt Template ${readonly ? "(readonly)" : ""}`, 270 | Download: "Download", 271 | Clone: "Clone", 272 | }, 273 | Config: { 274 | Avatar: "Bot Avatar", 275 | Name: "Bot Name", 276 | Sync: { 277 | Title: "Use Global Config", 278 | SubTitle: "Use global config in this chat", 279 | Confirm: "Confirm to override custom config with global config?", 280 | }, 281 | HideContext: { 282 | Title: "Hide Context Prompts", 283 | SubTitle: "Do not show in-context prompts in chat", 284 | }, 285 | }, 286 | }, 287 | NewChat: { 288 | Return: "Return", 289 | Skip: "Just Start", 290 | Title: "Pick a Mask", 291 | SubTitle: "Chat with the Soul behind the Mask", 292 | More: "Find More", 293 | NotShow: "Never Show Again", 294 | ConfirmNoShow: "Confirm to disable?You can enable it in settings later.", 295 | }, 296 | 297 | UI: { 298 | Confirm: "Confirm", 299 | Cancel: "Cancel", 300 | Close: "Close", 301 | Create: "Create", 302 | Edit: "Edit", 303 | }, 304 | Exporter: { 305 | Model: "Model", 306 | Messages: "Messages", 307 | Topic: "Topic", 308 | Time: "Time", 309 | }, 310 | } 311 | 312 | export type TLocale = typeof en 313 | export default en 314 | -------------------------------------------------------------------------------- /src/locales/schema.ts: -------------------------------------------------------------------------------- 1 | const localeKeys = [ 2 | "WIP", 3 | "Error.Unauthorized", 4 | "Auth.Title", 5 | "Auth.Tips", 6 | "Auth.Input", 7 | "Auth.Confirm", 8 | "Auth.Later", 9 | "ChatItem.ChatItemCount", 10 | "Chat.SubTitle", 11 | "Chat.Actions.ChatList", 12 | "Chat.Actions.CompressedHistory", 13 | "Chat.Actions.Export", 14 | "Chat.Actions.Copy", 15 | "Chat.Actions.Stop", 16 | "Chat.Actions.Retry", 17 | "Chat.Actions.Pin", 18 | "Chat.Actions.PinToastContent", 19 | "Chat.Actions.PinToastAction", 20 | "Chat.Actions.Delete", 21 | "Chat.Actions.Edit", 22 | "Chat.Commands.new", 23 | "Chat.Commands.newm", 24 | "Chat.Commands.next", 25 | "Chat.Commands.prev", 26 | "Chat.Commands.clear", 27 | "Chat.Commands.del", 28 | "Chat.InputActions.Stop", 29 | "Chat.InputActions.ToBottom", 30 | "Chat.InputActions.Theme.auto", 31 | "Chat.InputActions.Theme.light", 32 | "Chat.InputActions.Theme.dark", 33 | "Chat.InputActions.Prompt", 34 | "Chat.InputActions.Masks", 35 | "Chat.InputActions.Clear", 36 | "Chat.InputActions.Settings", 37 | "Chat.Rename", 38 | "Chat.Typing", 39 | "Chat.Send", 40 | "Chat.Config.Reset", 41 | "Chat.Config.SaveAs", 42 | "Export.Title", 43 | "Export.Copy", 44 | "Export.Download", 45 | "Export.MessageFromYou", 46 | "Export.MessageFromChatGPT", 47 | "Export.Share", 48 | "Export.Format.Title", 49 | "Export.Format.SubTitle", 50 | "Export.IncludeContext.Title", 51 | "Export.IncludeContext.SubTitle", 52 | "Export.Steps.Select", 53 | "Export.Steps.Preview", 54 | "Select.Search", 55 | "Select.All", 56 | "Select.Latest", 57 | "Select.Clear", 58 | "Memory.Title", 59 | "Memory.EmptyContent", 60 | "Memory.Send", 61 | "Memory.Copy", 62 | "Memory.Reset", 63 | "Memory.ResetConfirm", 64 | "Home.NewChat", 65 | "Home.DeleteChat", 66 | "Home.DeleteToast", 67 | "Home.Revert", 68 | "Settings.Title", 69 | "Settings.SubTitle", 70 | "Settings.Danger.Reset.Title", 71 | "Settings.Danger.Reset.SubTitle", 72 | "Settings.Danger.Reset.Action", 73 | "Settings.Danger.Reset.Confirm", 74 | "Settings.Danger.Clear.Title", 75 | "Settings.Danger.Clear.SubTitle", 76 | "Settings.Danger.Clear.Action", 77 | "Settings.Danger.Clear.Confirm", 78 | "Settings.Lang.Name", 79 | "Settings.Lang.All", 80 | "Settings.Avatar", 81 | "Settings.FontSize.Title", 82 | "Settings.FontSize.SubTitle", 83 | "Settings.InputTemplate.Title", 84 | "Settings.InputTemplate.SubTitle", 85 | "Settings.Update.Version", 86 | "Settings.Update.IsLatest", 87 | "Settings.Update.CheckUpdate", 88 | "Settings.Update.IsChecking", 89 | "Settings.Update.FoundUpdate", 90 | "Settings.Update.GoToUpdate", 91 | "Settings.SendKey", 92 | "Settings.Theme", 93 | "Settings.TightBorder", 94 | "Settings.SendPreviewBubble.Title", 95 | "Settings.SendPreviewBubble.SubTitle", 96 | "Settings.Mask.Title", 97 | "Settings.Mask.SubTitle", 98 | "Settings.Prompt.Disable.Title", 99 | "Settings.Prompt.Disable.SubTitle", 100 | "Settings.Prompt.List", 101 | "Settings.Prompt.ListCount", 102 | "Settings.Prompt.Edit", 103 | "Settings.Prompt.Modal.Title", 104 | "Settings.Prompt.Modal.Add", 105 | "Settings.Prompt.Modal.Search", 106 | "Settings.Prompt.EditModal.Title", 107 | "Settings.HistoryCount.Title", 108 | "Settings.HistoryCount.SubTitle", 109 | "Settings.CompressThreshold.Title", 110 | "Settings.CompressThreshold.SubTitle", 111 | "Settings.Token.Title", 112 | "Settings.Token.SubTitle", 113 | "Settings.Token.Placeholder", 114 | "Settings.Usage.Title", 115 | "Settings.Usage.SubTitle", 116 | "Settings.Usage.IsChecking", 117 | "Settings.Usage.Check", 118 | "Settings.Usage.NoAccess", 119 | "Settings.AccessCode.Title", 120 | "Settings.AccessCode.SubTitle", 121 | "Settings.AccessCode.Placeholder", 122 | "Settings.Endpoint.Title", 123 | "Settings.Endpoint.SubTitle", 124 | "Settings.Model", 125 | "Settings.Temperature.Title", 126 | "Settings.Temperature.SubTitle", 127 | "Settings.MaxTokens.Title", 128 | "Settings.MaxTokens.SubTitle", 129 | "Settings.PresencePenalty.Title", 130 | "Settings.PresencePenalty.SubTitle", 131 | "Settings.FrequencyPenalty.Title", 132 | "Settings.FrequencyPenalty.SubTitle", 133 | "Store.DefaultTopic", 134 | "Store.BotHello", 135 | "Store.Error", 136 | "Store.Prompt.History", 137 | "Store.Prompt.Topic", 138 | "Store.Prompt.Summarize", 139 | "Copy.Success", 140 | "Copy.Failed", 141 | "Context.Toast", 142 | "Context.Edit", 143 | "Context.Add", 144 | "Context.Clear", 145 | "Context.Revert", 146 | "Plugin.Name", 147 | "Mask.Name", 148 | "Mask.Page.Title", 149 | "Mask.Page.SubTitle", 150 | "Mask.Page.Search", 151 | "Mask.Page.Create", 152 | "Mask.Item.Info", 153 | "Mask.Item.Chat", 154 | "Mask.Item.View", 155 | "Mask.Item.Edit", 156 | "Mask.Item.Delete", 157 | "Mask.Item.DeleteConfirm", 158 | "Mask.EditModal.Download", 159 | "Mask.EditModal.Clone", 160 | "Mask.Config.Avatar", 161 | "Mask.Config.Name", 162 | "Mask.Config.Sync.Title", 163 | "Mask.Config.Sync.SubTitle", 164 | "Mask.Config.Sync.Confirm", 165 | "Mask.Config.HideContext.Title", 166 | "Mask.Config.HideContext.SubTitle", 167 | "NewChat.Return", 168 | "NewChat.Skip", 169 | "NewChat.Title", 170 | "NewChat.SubTitle", 171 | "NewChat.More", 172 | "NewChat.NotShow", 173 | "NewChat.ConfirmNoShow", 174 | "UI.Confirm", 175 | "UI.Cancel", 176 | "UI.Close", 177 | "UI.Create", 178 | "UI.Edit", 179 | "Exporter.Model", 180 | "Exporter.Messages", 181 | "Exporter.Topic", 182 | "Exporter.Time", 183 | ] as const 184 | 185 | export type TLocaleKeys = (typeof localeKeys)[number] 186 | -------------------------------------------------------------------------------- /src/locales/zh_CN.ts: -------------------------------------------------------------------------------- 1 | import { TLocale } from "./en" 2 | 3 | const cn: TLocale = { 4 | WIP: "该功能仍在开发中……", 5 | Error: { 6 | Unauthorized: 7 | "访问密码不正确或为空,请前往 [登录](/#/auth) 页输入正确的访问密码,或者在 [设置](/#/settings) 页填入你自己的 OpenAI API Key。", 8 | }, 9 | Auth: { 10 | Title: "需要密码", 11 | Tips: "管理员开启了密码验证,请在下方填入访问码", 12 | Input: "在此处填写访问码", 13 | Confirm: "确认", 14 | Later: "稍后再说", 15 | }, 16 | ChatItem: { 17 | ChatItemCount: `{count} 条对话`, 18 | }, 19 | Chat: { 20 | SubTitle: `共 {count} 条消息`, 21 | Actions: { 22 | ChatList: "查看消息列表", 23 | CompressedHistory: "查看压缩后的历史 Prompt", 24 | Export: "导出聊天记录", 25 | Copy: "复制", 26 | Stop: "停止", 27 | Retry: "重试", 28 | Pin: "固定", 29 | PinToastContent: "已将 2 条对话固定至预设提示词", 30 | PinToastAction: "查看", 31 | Delete: "删除", 32 | Edit: "编辑", 33 | }, 34 | Commands: { 35 | new: "新建聊天", 36 | newm: "从面具新建聊天", 37 | next: "下一个聊天", 38 | prev: "上一个聊天", 39 | clear: "清除上下文", 40 | del: "删除聊天", 41 | }, 42 | InputActions: { 43 | Stop: "停止响应", 44 | ToBottom: "滚到最新", 45 | Theme: { 46 | auto: "自动主题", 47 | light: "亮色模式", 48 | dark: "深色模式", 49 | }, 50 | Prompt: "快捷指令", 51 | Masks: "所有面具", 52 | Clear: "清除聊天", 53 | Settings: "对话设置", 54 | }, 55 | Rename: "重命名对话", 56 | Typing: "正在输入…", 57 | // Input: (submitKey: string) => { 58 | // var inputHints = `${submitKey} 发送` 59 | // if (submitKey === String(SubmitKey.Enter)) { 60 | // inputHints += ",Shift + Enter 换行" 61 | // } 62 | // return inputHints + ",/ 触发补全,: 触发命令" 63 | // }, 64 | Send: "发送", 65 | Config: { 66 | Reset: "清除记忆", 67 | SaveAs: "存为面具", 68 | }, 69 | }, 70 | Export: { 71 | Title: "分享聊天记录", 72 | Copy: "全部复制", 73 | Download: "下载文件", 74 | Share: "分享到 ShareGPT", 75 | MessageFromYou: "来自你的消息", 76 | MessageFromChatGPT: "来自 ChatGPT 的消息", 77 | Format: { 78 | Title: "导出格式", 79 | SubTitle: "可以导出 Markdown 文本或者 PNG 图片", 80 | }, 81 | IncludeContext: { 82 | Title: "包含面具上下文", 83 | SubTitle: "是否在消息中展示面具上下文", 84 | }, 85 | Steps: { 86 | Select: "选取", 87 | Preview: "预览", 88 | }, 89 | }, 90 | Select: { 91 | Search: "搜索消息", 92 | All: "选取全部", 93 | Latest: "最近几条", 94 | Clear: "清除选中", 95 | }, 96 | Memory: { 97 | Title: "历史摘要", 98 | EmptyContent: "对话内容过短,无需总结", 99 | Send: "自动压缩聊天记录并作为上下文发送", 100 | Copy: "复制摘要", 101 | Reset: "[unused]", 102 | ResetConfirm: "确认清空历史摘要?", 103 | }, 104 | Home: { 105 | NewChat: "新的聊天", 106 | DeleteChat: "确认删除选中的对话?", 107 | DeleteToast: "已删除会话", 108 | Revert: "撤销", 109 | }, 110 | Settings: { 111 | Title: "设置", 112 | SubTitle: "所有设置选项", 113 | 114 | Danger: { 115 | Reset: { 116 | Title: "重置所有设置", 117 | SubTitle: "重置所有设置项回默认值", 118 | Action: "立即重置", 119 | Confirm: "确认重置所有设置?", 120 | }, 121 | Clear: { 122 | Title: "清除所有数据", 123 | SubTitle: "清除所有聊天、设置数据", 124 | Action: "立即清除", 125 | Confirm: "确认清除所有聊天、设置数据?", 126 | }, 127 | }, 128 | Lang: { 129 | Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language` 130 | All: "所有语言", 131 | }, 132 | Avatar: "头像", 133 | FontSize: { 134 | Title: "字体大小", 135 | SubTitle: "聊天内容的字体大小", 136 | }, 137 | 138 | InputTemplate: { 139 | Title: "用户输入预处理", 140 | SubTitle: "用户最新的一条消息会填充到此模板", 141 | }, 142 | 143 | Update: { 144 | Version: `当前版本:{x}`, 145 | IsLatest: "已是最新版本", 146 | CheckUpdate: "检查更新", 147 | IsChecking: "正在检查更新...", 148 | FoundUpdate: `发现新版本:{x}`, 149 | GoToUpdate: "前往更新", 150 | }, 151 | SendKey: "发送键", 152 | Theme: "主题", 153 | TightBorder: "无边框模式", 154 | SendPreviewBubble: { 155 | Title: "预览气泡", 156 | SubTitle: "在预览气泡中预览 Markdown 内容", 157 | }, 158 | Mask: { 159 | Title: "面具启动页", 160 | SubTitle: "新建聊天时,展示面具启动页", 161 | }, 162 | Prompt: { 163 | Disable: { 164 | Title: "禁用提示词自动补全", 165 | SubTitle: "在输入框开头输入 / 即可触发自动补全", 166 | }, 167 | List: "自定义提示词列表", 168 | ListCount: `内置 {builtin} 条,用户定义 {custom} 条`, 169 | Edit: "编辑", 170 | Modal: { 171 | Title: "提示词列表", 172 | Add: "新建", 173 | Search: "搜索提示词", 174 | }, 175 | EditModal: { 176 | Title: "编辑提示词", 177 | }, 178 | }, 179 | HistoryCount: { 180 | Title: "附带历史消息数", 181 | SubTitle: "每次请求携带的历史消息数", 182 | }, 183 | CompressThreshold: { 184 | Title: "历史消息长度压缩阈值", 185 | SubTitle: "当未压缩的历史消息超过该值时,将进行压缩", 186 | }, 187 | Token: { 188 | Title: "API Key", 189 | SubTitle: "使用自己的 Key 可绕过密码访问限制", 190 | Placeholder: "OpenAI API Key", 191 | }, 192 | 193 | Usage: { 194 | Title: "余额查询", 195 | SubTitle: `本月已使用 \${used},订阅总额 \${total}`, 196 | IsChecking: "正在检查…", 197 | Check: "重新检查", 198 | NoAccess: "输入 API Key 或访问密码查看余额", 199 | }, 200 | AccessCode: { 201 | Title: "访问密码", 202 | SubTitle: "管理员已开启加密访问", 203 | Placeholder: "请输入访问密码", 204 | }, 205 | Endpoint: { 206 | Title: "接口地址", 207 | SubTitle: "除默认地址外,必须包含 http(s)://", 208 | }, 209 | Model: "模型 (model)", 210 | Temperature: { 211 | Title: "随机性 (temperature)", 212 | SubTitle: "值越大,回复越随机", 213 | }, 214 | MaxTokens: { 215 | Title: "单次回复限制 (max_tokens)", 216 | SubTitle: "单次交互所用的最大 Token 数", 217 | }, 218 | PresencePenalty: { 219 | Title: "话题新鲜度 (presence_penalty)", 220 | SubTitle: "值越大,越有可能扩展到新话题", 221 | }, 222 | FrequencyPenalty: { 223 | Title: "频率惩罚度 (frequency_penalty)", 224 | SubTitle: "值越大,越有可能降低重复字词", 225 | }, 226 | }, 227 | Store: { 228 | DefaultTopic: "新的聊天", 229 | BotHello: "有什么可以帮你的吗", 230 | Error: "出错了,稍后重试吧", 231 | Prompt: { 232 | History: "这是历史聊天总结作为前情提要:{content}", 233 | Topic: 234 | "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”", 235 | Summarize: "简要总结一下对话内容,用作后续的上下文提示 prompt,控制在 200 字以内", 236 | }, 237 | }, 238 | Copy: { 239 | Success: "已写入剪切板", 240 | Failed: "复制失败,请赋予剪切板权限", 241 | }, 242 | Context: { 243 | Toast: `包含 {x} 条预设提示词`, 244 | Edit: "当前对话设置", 245 | Add: "新增预设对话", 246 | Clear: "上下文已清除", 247 | Revert: "恢复上下文", 248 | }, 249 | Plugin: { 250 | Name: "插件", 251 | Page: { 252 | Title: "插件", 253 | SubTitle: "一些有趣的插件", 254 | }, 255 | }, 256 | Mask: { 257 | Name: "面具", 258 | Page: { 259 | Title: "预设角色面具", 260 | SubTitle: `{count} 个预设角色定义`, 261 | Search: "搜索角色面具", 262 | Create: "新建", 263 | }, 264 | Item: { 265 | Info: `包含 {count} 条预设对话`, 266 | Chat: "对话", 267 | View: "查看", 268 | Edit: "编辑", 269 | Delete: "删除", 270 | DeleteConfirm: "确认删除?", 271 | }, 272 | EditModal: { 273 | // Title: (readonly: boolean) => `编辑预设面具 ${readonly ? "(只读)" : ""}`, 274 | Download: "下载预设", 275 | Clone: "克隆预设", 276 | }, 277 | Config: { 278 | Avatar: "角色头像", 279 | Name: "角色名称", 280 | Sync: { 281 | Title: "使用全局设置", 282 | SubTitle: "当前对话是否使用全局模型设置", 283 | Confirm: "当前对话的自定义设置将会被自动覆盖,确认启用全局设置?", 284 | }, 285 | HideContext: { 286 | Title: "隐藏预设对话", 287 | SubTitle: "隐藏后预设对话不会出现在聊天界面", 288 | }, 289 | }, 290 | }, 291 | NewChat: { 292 | Return: "返回", 293 | Skip: "直接开始", 294 | NotShow: "不再展示", 295 | ConfirmNoShow: "确认禁用?禁用后可以随时在设置中重新启用。", 296 | Title: "挑选一个面具", 297 | SubTitle: "现在开始,与面具背后的灵魂思维碰撞", 298 | More: "查看全部", 299 | }, 300 | 301 | UI: { 302 | Confirm: "确认", 303 | Cancel: "取消", 304 | Close: "关闭", 305 | Create: "新建", 306 | Edit: "编辑", 307 | }, 308 | Exporter: { 309 | Model: "模型", 310 | Messages: "消息", 311 | Topic: "主题", 312 | Time: "时间", 313 | }, 314 | } 315 | 316 | export default cn 317 | -------------------------------------------------------------------------------- /src/pages/[...all].tsx: -------------------------------------------------------------------------------- 1 | export default defineComponent({ 2 | setup() { 3 | const router = useRouter() 4 | 5 | const handleBack = () => { 6 | router.back() 7 | } 8 | 9 | return () => ( 10 |
11 |
Not found
12 | 13 |
14 | ) 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /src/pages/chat.tsx: -------------------------------------------------------------------------------- 1 | import { ClientOnly, NuxtPage, VSidebar } from "#components" 2 | import { useSidebarChatSessions } from "~/composables/chat" 3 | 4 | export default defineComponent({ 5 | setup() { 6 | const chatStore = useSidebarChatSessions() 7 | onBeforeMount(async () => { 8 | await chatStore.loadAll() 9 | }) 10 | 11 | return {} 12 | }, 13 | 14 | render() { 15 | return ( 16 |
17 | 18 | 19 |
20 | 21 |
22 |
23 |
24 | ) 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /src/pages/chat/index.vue: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/pages/chat/masks/MasksHeader.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | import { HeadIconButton, VDetailHeader } from "#components" 3 | import { useTrans } from "~/composables/locales" 4 | 5 | export const MasksHeader = defineComponent({ 6 | props: { 7 | count: Number, 8 | }, 9 | setup(props, { slots }) { 10 | const { t } = useTrans() 11 | const router = useRouter() 12 | 13 | return () => ( 14 | 15 | {{ 16 | rightIcons: () => ( 17 |
18 | 19 | 20 | router.back()} /> 21 |
22 | ), 23 | }} 24 |
25 | ) 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/pages/chat/masks/index.tsx: -------------------------------------------------------------------------------- 1 | import { UButton, UInput, UModal, USelect } from "#components" 2 | import { useTrans } from "~/composables/locales" 3 | import { TPrompts, useMasks } from "~/composables/mask" 4 | import { useSidebar } from "~/composables/useSidebar" 5 | import { MasksHeader } from "~/pages/chat/masks/MasksHeader" 6 | import { getRandomEmoji } from "~/utils/emoji" 7 | import { useSidebarChatSessions } from "~/composables/chat" 8 | 9 | export default defineComponent({ 10 | setup() { 11 | const router = useRouter() 12 | const chatStore = useSidebarChatSessions() 13 | const maskUse = useMasks() 14 | const sidebarUsed = useSidebar() 15 | const visible = ref(false) 16 | 17 | const newSessionAndNav = (mask: TPrompts) => { 18 | const session = chatStore.newSession(undefined, { 19 | topic: mask.name, 20 | description: mask.description, 21 | avatar: getRandomEmoji("a"), 22 | }) 23 | sidebarUsed.hideIfMobile() 24 | router.push({ 25 | path: "/chat/session/" + session.id, 26 | }) 27 | } 28 | 29 | const { t } = useTrans() 30 | 31 | const inputSearch = reactive({ 32 | q: "", 33 | language: "", 34 | }) 35 | 36 | watch( 37 | () => inputSearch, 38 | (value) => { 39 | maskUse.search({ 40 | q: value.q, 41 | language: value.language, 42 | }) 43 | console.log(value.q, value.language) 44 | }, 45 | { 46 | deep: true, 47 | }, 48 | ) 49 | 50 | return () => ( 51 |
52 | 53 |
54 |
55 |
56 | 57 |
58 |
59 | 74 |
75 |
76 | { 81 | visible.value = true 82 | }} 83 | > 84 | {t("Mask.Page.Create")} 85 | 86 |
87 | { 90 | visible.value = false 91 | }} 92 | > 93 |

This is an example Modal.

94 |

This is an example Modal.

95 |

This is an example Modal.

96 |
97 |
98 | 99 |
100 | {maskUse.searchedMasks.map((mask: TPrompts, index: number) => ( 101 |
111 |
112 |
113 |
117 | {getRandomEmoji(mask.name)} 118 |
119 |
120 |
121 |
{mask.name}
122 |

{mask.description}

123 |
124 |
125 |
126 | 135 | {/* */} 141 |
142 |
143 | ))} 144 |
145 |
146 |
147 | ) 148 | }, 149 | }) 150 | -------------------------------------------------------------------------------- /src/pages/chat/new.tsx: -------------------------------------------------------------------------------- 1 | import MaskCard from "~/components/MaskCard" 2 | import { useMasks } from "~/composables/mask" 3 | import { useSettingStore } from "~/composables/settings" 4 | import { getRandomEmoji } from "~/utils/emoji" 5 | import { useSidebarChatSessions } from "~/composables/chat" 6 | 7 | export default defineComponent({ 8 | setup() { 9 | const router = useRouter() 10 | const { settings } = useSettingStore() 11 | const { t } = useTrans() 12 | const chatStore = useSidebarChatSessions() 13 | const masksUse = useMasks() 14 | 15 | const maskRef = ref(null) 16 | const pageRef = ref(null) 17 | 18 | const newDefaultSession = () => { 19 | const session = chatStore.createEmptySession() 20 | router.push({ 21 | path: "/chat/session/" + session.id, 22 | }) 23 | } 24 | 25 | onBeforeMount(() => { 26 | if (!settings.maskLaunchPage) { 27 | newDefaultSession() 28 | } 29 | }) 30 | 31 | const resizeMaskRows = useThrottleFn( 32 | ({ width, height }: { width: number; height: number }) => { 33 | if (!pageRef.value) { 34 | return 35 | } 36 | if (!masksUse.masks || masksUse.masks.length === 0) { 37 | return 38 | } 39 | masksUse.computeMaskRows({ 40 | width, 41 | height, 42 | }) 43 | if (!maskRef.value) { 44 | return 45 | } 46 | maskRef.value.scrollLeft = (maskRef.value.scrollWidth - maskRef.value.clientWidth) / 2 47 | }, 48 | 300, 49 | true, 50 | true, 51 | ) 52 | 53 | useResizeObserver(pageRef, (entries) => { 54 | const { width, height } = entries[0].contentRect 55 | resizeMaskRows({ 56 | width, 57 | height, 58 | }) 59 | }) 60 | 61 | onMounted(() => { 62 | if (!pageRef.value) { 63 | return 64 | } 65 | resizeMaskRows({ 66 | width: pageRef.value.clientWidth, 67 | height: pageRef.value.clientHeight, 68 | }) 69 | }) 70 | 71 | const notShowAndNav = () => { 72 | settings.maskLaunchPage = false 73 | newDefaultSession() 74 | } 75 | 76 | return () => ( 77 |
78 |
79 | 88 | 94 |
95 |
96 |
97 | 😆 98 |
99 |
100 | 🤖 101 |
102 |
103 | 👹 104 |
105 |
106 |
{t("NewChat.Title")}
107 |
{t("NewChat.SubTitle")}
108 |
109 | 118 | 127 |
128 |
129 | {masksUse.maskRows.map((row, i) => ( 130 |
131 | {row.map((mask, j) => ( 132 | { 138 | const session = chatStore.newSession(undefined, { 139 | topic: mask.name, 140 | description: mask.description, 141 | avatar: getRandomEmoji("a"), 142 | }) 143 | router.push({ 144 | path: "/chat/session/" + session.id, 145 | }) 146 | }} 147 | /> 148 | ))} 149 |
150 | ))} 151 |
152 |
153 | ) 154 | }, 155 | }) 156 | -------------------------------------------------------------------------------- /src/pages/chat/plugins.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | import { HeadIconButton, VDetailHeader } from "#components" 3 | import { useTrans } from "~/composables/locales" 4 | 5 | const PagePlugin = defineComponent({ 6 | props: { 7 | count: Number, 8 | }, 9 | setup(props, { slots }) { 10 | const { t } = useTrans() 11 | const router = useRouter() 12 | 13 | return () => ( 14 |
15 | 16 | {{ 17 | rightIcons: () => ( 18 |
19 | router.back()} /> 20 |
21 | ), 22 | }} 23 |
24 |
Coming soon...
25 |
26 | ) 27 | }, 28 | }) 29 | 30 | export default PagePlugin 31 | -------------------------------------------------------------------------------- /src/pages/chat/session.tsx: -------------------------------------------------------------------------------- 1 | import { NuxtPage } from "#components" 2 | 3 | export default defineComponent({ 4 | render() { 5 | return 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /src/pages/chat/session/[sid].tsx: -------------------------------------------------------------------------------- 1 | import { HeadIconButton, LazyVSharePreview, VChatMessage, VComposeView, VDetailHeader } from "#components" 2 | import { useRoutedChatSession } from "~/composables/chat" 3 | import { useTrans } from "~/composables/locales" 4 | 5 | export default defineComponent({ 6 | setup() { 7 | const router = useRouter() 8 | const chatSession = useRoutedChatSession() 9 | if (!chatSession.session) { 10 | router.push("/chat/new") 11 | } 12 | const { t } = useTrans() 13 | 14 | const el = ref(null) 15 | const visibleShareModal = ref(false) 16 | 17 | const scrollToBottom = () => { 18 | if (el.value) { 19 | const scrollHeight = el.value.scrollHeight 20 | el.value.scrollTo({ 21 | top: scrollHeight, 22 | behavior: "auto", 23 | }) 24 | } 25 | } 26 | 27 | watch( 28 | () => chatSession.session.messages, 29 | () => { 30 | nextTick(() => { 31 | scrollToBottom() 32 | }) 33 | }, 34 | { 35 | deep: true, 36 | } 37 | ) 38 | 39 | onMounted(() => { 40 | nextTick(() => { 41 | scrollToBottom() 42 | }) 43 | }) 44 | 45 | return () => ( 46 |
47 | {visibleShareModal.value && ( 48 | { 50 | visibleShareModal.value = false 51 | }} 52 | /> 53 | )} 54 | 58 | {{ 59 | rightIcons: () => ( 60 |
61 | 62 | { 66 | visibleShareModal.value = true 67 | }} 68 | /> 69 | router.back()} /> 70 |
71 | ), 72 | }} 73 |
74 |
75 | {chatSession.session.messages.map((message) => ( 76 | 77 | ))} 78 |
79 | 80 |
81 | ) // Add a closing parenthesis here 82 | }, 83 | }) 84 | -------------------------------------------------------------------------------- /src/pages/chat/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, onMounted, ref, watch } from "vue" 2 | import { 3 | ClientOnly, 4 | HeadIconButton, 5 | NuxtLink, 6 | UCheckbox, 7 | UInput, 8 | URange, 9 | USelect, 10 | VDetailHeader, 11 | VEmojiAvatar, 12 | } from "#components" 13 | import { useTrans } from "~/composables/locales" 14 | import { languageOptions, modelOptions, sendKeyOptions, themeOptions, useSettingStore } from "~/composables/settings" 15 | 16 | const SettingItem = defineComponent({ 17 | props: { 18 | title: String, 19 | subtitle: String, 20 | }, 21 | setup(props: { title: string; subtitle?: string }, { slots }) { 22 | return () => ( 23 |
24 |
25 |
26 |
{props.title}
27 | {props.subtitle && } 28 |
29 |
30 |
{slots?.default?.()}
31 |
32 | ) 33 | }, 34 | }) 35 | 36 | export default defineComponent({ 37 | setup() { 38 | const router = useRouter() 39 | const colorMode = useColorMode() 40 | 41 | const settingStore = useSettingStore() 42 | const settings = settingStore.settings 43 | onMounted(() => { 44 | settingStore.fetchRemoteLatestCommitDate() 45 | }) 46 | 47 | const { t, setLocale } = useTrans() 48 | 49 | const apiKeyShow = ref(false) 50 | const usageReloading = ref(false) 51 | const checkUsage = () => { 52 | if (usageReloading.value) { 53 | return 54 | } 55 | usageReloading.value = true 56 | setTimeout(() => { 57 | usageReloading.value = false 58 | }, 1000) 59 | } 60 | 61 | watch( 62 | () => settings.language, 63 | () => { 64 | setLocale(settings.language) 65 | }, 66 | ) 67 | 68 | return () => ( 69 | 70 |
71 | 72 | {{ 73 | rightIcons: () => ( 74 |
75 | 76 | 77 | router.back()} /> 78 |
79 | ), 80 | }} 81 |
82 |
83 |
84 | 85 | 86 | 87 | 88 | 96 | 101 | {settings.hasNewVersion ? t("Settings.Update.GoToUpdate") : t("Settings.Update.IsLatest")} 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
118 | {settings.fontSize}px 119 | 120 |
121 |
122 | 123 | 124 | 125 | 126 |
127 |
128 | 129 |
130 | 131 |
132 |
133 | 134 |
135 | 149 | 154 |
155 |
156 | 157 | 164 | 178 | 179 |
180 |
181 | 182 | 183 | 184 | 185 | 186 |
187 | {settings.temperature} 188 | 189 |
190 |
191 | 192 | 193 |
194 | 195 |
196 |
197 | 198 | 202 |
203 | {settings.presencePenalty} 204 | 205 |
206 |
207 | 208 | 212 |
213 | {settings.frequencyPenalty} 214 | 215 |
216 |
217 | 218 | 219 |
220 | {settings.historyMessagesCount} 221 | 222 |
223 |
224 | 225 | 229 |
230 | 231 |
232 |
233 | 234 | 235 | 236 | 237 |
238 |
239 |
240 |
241 | ) 242 | }, 243 | }) 244 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /src/pages/suspension.vue: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /src/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/public/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/assets/logo.png -------------------------------------------------------------------------------- /src/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/favicon-16x16.png -------------------------------------------------------------------------------- /src/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/favicon.ico -------------------------------------------------------------------------------- /src/public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/icon-512.png -------------------------------------------------------------------------------- /src/public/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/maskable-icon.png -------------------------------------------------------------------------------- /src/public/nuxt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layer 1 5 | 6 | 9 | 12 | 14 | 17 | 20 | 22 | 25 | 28 | 31 | 34 | 37 | 40 | 43 | 46 | 49 | 52 | 54 | 57 | 59 | 62 | 65 | 66 | 69 | 72 | 75 | 78 | 81 | 84 | 86 | 89 | 92 | 95 | 97 | 100 | 102 | 105 | 108 | 111 | 113 | 115 | 117 | 118 | 121 | 124 | 127 | 130 | 133 | 136 | 139 | 140 | 143 | 146 | 149 | 152 | 155 | 158 | 161 | 164 | 167 | 170 | 173 | 176 | 179 | 182 | 185 | 188 | 191 | 194 | 197 | 200 | 203 | 206 | 209 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /src/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/pwa-192x192.png -------------------------------------------------------------------------------- /src/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hylarucoder/ChatGPT-Nuxt/428e9d3a3a500b8fcbba14c9e3998f15b9ef2aca/src/public/pwa-512x512.png -------------------------------------------------------------------------------- /src/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /src/typing/openai.d.ts: -------------------------------------------------------------------------------- 1 | type TOpenApiChatCompletionMessage = { 2 | role: "user" | "system" | "assistant" 3 | content: string 4 | } 5 | 6 | type TOpenApiChatCompletion = { 7 | messages: TOpenApiChatCompletionMessage 8 | model: string 9 | presence_penalty: number 10 | stream: boolean 11 | temperature: number 12 | } 13 | -------------------------------------------------------------------------------- /src/typing/vue-shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg?component" { 2 | import { ComponentOptions } from "vue" 3 | const component: ComponentOptions 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | export const copyToClipboard = (content: string) => { 2 | const input = document.createElement("textarea") 3 | input.innerHTML = content 4 | input.setAttribute("readonly", "readonly") 5 | input.setAttribute("value", content) 6 | document.body.appendChild(input) 7 | input.select() 8 | input.setSelectionRange(0, 99999) 9 | document.execCommand("copy") 10 | document.body.removeChild(input) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | // Date utilities 2 | export function getUtcNow () { 3 | return new Date().toISOString().toString() 4 | } 5 | 6 | export function formatDateString (dateString: string): string { 7 | const date = new Date(dateString) 8 | const now = new Date() 9 | 10 | const timeDifference = now.getTime() - date.getTime() 11 | const withinOneDay = timeDifference < 24 * 60 * 60 * 1000 12 | const withinOneYear = now.getFullYear() - date.getFullYear() < 1 13 | 14 | const hours = date.getHours().toString().padStart(2, "0") 15 | const minutes = date.getMinutes().toString().padStart(2, "0") 16 | const day = date.getDate().toString().padStart(2, "0") 17 | const month = (date.getMonth() + 1).toString().padStart(2, "0") 18 | const year = date.getFullYear() 19 | 20 | if (withinOneDay) { 21 | return `${hours}:${minutes}` 22 | } else if (withinOneYear) { 23 | return `${month}/${day} ${hours}:${minutes}` 24 | } else { 25 | return `${year}/${month}/${day}` 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/emoji.ts: -------------------------------------------------------------------------------- 1 | const commonEmojis: string[] = [ 2 | "😀", 3 | "😂", 4 | "😍", 5 | "❤️", 6 | "👍", 7 | "👋", 8 | "🤔", 9 | "👀", 10 | "🍕", 11 | "🎉", 12 | "🍔", 13 | "🥑", 14 | "🍦", 15 | "🍩", 16 | "🥪", 17 | "🍺", 18 | "🎸", 19 | "📷", 20 | "🎮", 21 | "💻", 22 | "🌎", 23 | "🌻", 24 | "🏠", 25 | "🚗", 26 | "🛵", 27 | "🎈", 28 | "🎁", 29 | "💰", 30 | "📚", 31 | "🎤", 32 | "😎", 33 | "🤯", 34 | "🤢", 35 | "🥳", 36 | "🤗", 37 | "😴", 38 | "👻", 39 | "💊", 40 | "🍼", 41 | "🐶", 42 | "🐱", 43 | "🐢", 44 | "🐬", 45 | "🦁", 46 | "🍓", 47 | "🍇", 48 | "🍌", 49 | "🥭", 50 | "🍎", 51 | "🍫", 52 | ] 53 | 54 | export function getRandomEmoji (str: string) { 55 | if (!str) { 56 | str = "." 57 | } 58 | // 将字符串的每个字符的 Unicode 编码相加 59 | const total = str.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0) 60 | return commonEmojis[total % commonEmojis.length] 61 | } 62 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme") 2 | 3 | /** @type {import("tailwindcss").Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | content: [ 7 | "./src/**/*.{js,vue,tsx}", 8 | ], 9 | theme: {}, 10 | plugins: [ 11 | require("tailwindcss-animate"), 12 | require("@tailwindcss/forms"), 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------