├── service ├── .npmrc ├── .vscode │ ├── extensions.json │ └── settings.json ├── eslint.config.ts ├── .gitignore ├── tsconfig.json ├── src │ ├── utils │ │ ├── index.ts │ │ ├── image.ts │ │ ├── is.ts │ │ ├── security.ts │ │ ├── textAudit.ts │ │ ├── templates │ │ │ ├── mail.notice.template.html │ │ │ ├── mail.template.html │ │ │ ├── mail.admin.template.html │ │ │ └── mail.resetpassword.template.html │ │ └── mail.ts │ ├── routes │ │ ├── upload.ts │ │ └── prompt.ts │ ├── types.ts │ ├── chatgpt │ │ └── types.ts │ └── middleware │ │ ├── rootAuth.ts │ │ ├── limiter.ts │ │ └── auth.ts ├── .env.example └── package.json ├── src ├── store │ ├── helper.ts │ ├── modules │ │ ├── index.ts │ │ ├── auth │ │ │ ├── helper.ts │ │ │ └── index.ts │ │ ├── prompt │ │ │ └── index.ts │ │ ├── app │ │ │ ├── helper.ts │ │ │ └── index.ts │ │ ├── settings │ │ │ └── helper.ts │ │ └── user │ │ │ ├── index.ts │ │ │ └── helper.ts │ └── index.ts ├── views │ ├── chat │ │ ├── layout │ │ │ ├── index.ts │ │ │ ├── sider │ │ │ │ ├── Footer.vue │ │ │ │ ├── List.vue │ │ │ │ └── index.vue │ │ │ └── Layout.vue │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── Message │ │ │ │ ├── Avatar.vue │ │ │ │ ├── style.less │ │ │ │ ├── Text.vue │ │ │ │ └── Reasoning.vue │ │ │ └── Header │ │ │ │ └── index.vue │ │ └── hooks │ │ │ ├── useChat.ts │ │ │ └── useScroll.ts │ └── exception │ │ ├── 404 │ │ └── index.vue │ │ └── 500 │ │ └── index.vue ├── assets │ ├── avatar.jpg │ └── recommend.json ├── plugins │ ├── index.ts │ ├── assets.ts │ └── scrollbarStyle.ts ├── styles │ ├── global.less │ └── lib │ │ ├── tailwind.css │ │ └── highlight.less ├── vite-env.d.ts ├── utils │ ├── functions │ │ ├── index.ts │ │ └── debounce.ts │ ├── request │ │ ├── axios.ts │ │ ├── index.ts │ │ └── fetchService.ts │ ├── copy.ts │ ├── storage │ │ └── index.ts │ └── is │ │ └── index.ts ├── hooks │ ├── useBasicLayout.ts │ ├── useLanguage.ts │ ├── useIconRender.ts │ └── useTheme.ts ├── typings │ ├── global.d.ts │ └── chat.d.ts ├── icons │ ├── Prompt.vue │ └── Spinner.vue ├── components │ └── common │ │ ├── SvgIcon │ │ └── index.vue │ │ ├── HoverButton │ │ ├── Button.vue │ │ └── index.vue │ │ ├── PromptTypeTag │ │ └── index.vue │ │ ├── index.ts │ │ ├── GitHubSite │ │ └── index.vue │ │ ├── Watermark │ │ └── index.vue │ │ ├── NaiveProvider │ │ └── index.vue │ │ ├── Setting │ │ ├── Advanced.vue │ │ ├── Prompt.vue │ │ ├── Anonuncement.vue │ │ ├── Password.vue │ │ ├── About.vue │ │ ├── Mail.vue │ │ ├── Gift.vue │ │ ├── TwoFA.vue │ │ └── model.ts │ │ └── UserAvatar │ │ └── index.vue ├── main.ts ├── App.vue ├── router │ ├── permission.ts │ └── index.ts └── locales │ └── index.ts ├── .commitlintrc.json ├── docs ├── c1.png ├── c2.png ├── alipay.png ├── docker.png ├── login.jpg ├── prompt.jpg ├── wechat.png ├── c1-2.8.0.png ├── c1-2.9.0.png ├── c2-2.8.0.png ├── c2-2.9.0.png ├── key-manager.jpg ├── prompt_en.jpg ├── basesettings.jpg ├── mailsettings.jpg ├── sitesettings.jpg ├── user-manager.jpg ├── key-manager-en.jpg ├── manual_set_limit.png ├── giftcard_db_design.png └── add_redeem_and_limit.png ├── .husky ├── pre-commit └── commit-msg ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── public ├── favicon.ico ├── pwa-192x192.png ├── pwa-512x512.png └── favicon.svg ├── .dockerignore ├── kubernetes ├── README.md ├── ingress.yaml └── deploy.yaml ├── eslint.config.ts ├── start.cmd ├── .editorconfig ├── docker-compose ├── readme.md ├── docker-compose-mongodb.yml ├── nginx │ └── nginx.conf └── docker-compose.yml ├── start.sh ├── .env ├── .gitattributes ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── ci.yml │ └── build_docker.yml ├── Dockerfile ├── CONTRIBUTING.md ├── index.html ├── vite.config.ts ├── package.json ├── CONTRIBUTING.en.md ├── components.d.ts └── auto-imports.d.ts /service/.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | -------------------------------------------------------------------------------- /src/store/helper.ts: -------------------------------------------------------------------------------- 1 | export const store = createPinia() 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/c1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/c1.png -------------------------------------------------------------------------------- /docs/c2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/c2.png -------------------------------------------------------------------------------- /docs/alipay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/alipay.png -------------------------------------------------------------------------------- /docs/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/docker.png -------------------------------------------------------------------------------- /docs/login.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/login.jpg -------------------------------------------------------------------------------- /docs/prompt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/prompt.jpg -------------------------------------------------------------------------------- /docs/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/wechat.png -------------------------------------------------------------------------------- /docs/c1-2.8.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/c1-2.8.0.png -------------------------------------------------------------------------------- /docs/c1-2.9.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/c1-2.9.0.png -------------------------------------------------------------------------------- /docs/c2-2.8.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/c2-2.8.0.png -------------------------------------------------------------------------------- /docs/c2-2.9.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/c2-2.9.0.png -------------------------------------------------------------------------------- /service/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/key-manager.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/key-manager.jpg -------------------------------------------------------------------------------- /docs/prompt_en.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/prompt_en.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /service/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({}) 4 | -------------------------------------------------------------------------------- /src/views/chat/layout/index.ts: -------------------------------------------------------------------------------- 1 | import ChatLayout from './Layout.vue' 2 | 3 | export { ChatLayout } 4 | -------------------------------------------------------------------------------- /docs/basesettings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/basesettings.jpg -------------------------------------------------------------------------------- /docs/mailsettings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/mailsettings.jpg -------------------------------------------------------------------------------- /docs/sitesettings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/sitesettings.jpg -------------------------------------------------------------------------------- /docs/user-manager.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/user-manager.jpg -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /src/assets/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/src/assets/avatar.jpg -------------------------------------------------------------------------------- /src/views/chat/components/index.ts: -------------------------------------------------------------------------------- 1 | import Message from './Message/index.vue' 2 | 3 | export { Message } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | */node_modules 3 | node_modules 4 | Dockerfile 5 | .* 6 | */.* 7 | !.env 8 | -------------------------------------------------------------------------------- /docs/key-manager-en.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/key-manager-en.jpg -------------------------------------------------------------------------------- /docs/manual_set_limit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/manual_set_limit.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /docs/giftcard_db_design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/giftcard_db_design.png -------------------------------------------------------------------------------- /docs/add_redeem_and_limit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatgpt-web-dev/chatgpt-web/HEAD/docs/add_redeem_and_limit.png -------------------------------------------------------------------------------- /src/store/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app' 2 | export * from './auth' 3 | export * from './chat' 4 | export * from './prompt' 5 | export * from './user' 6 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import setupAssets from './assets' 2 | import setupScrollbarStyle from './scrollbarStyle' 3 | 4 | export { setupAssets, setupScrollbarStyle } 5 | -------------------------------------------------------------------------------- /kubernetes/README.md: -------------------------------------------------------------------------------- 1 | ## 增加一个Kubernetes的部署方式 2 | ``` 3 | kubectl apply -f deploy.yaml 4 | ``` 5 | 6 | ### 如果需要Ingress域名接入 7 | ``` 8 | kubectl apply -f ingress.yaml 9 | ``` 10 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | ignores: [ 5 | 'service/*', 6 | '*.md', 7 | '*.json', 8 | ], 9 | }) 10 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | import { store } from './helper' 3 | 4 | export function setupStore(app: App) { 5 | app.use(store) 6 | } 7 | 8 | export * from './modules' 9 | -------------------------------------------------------------------------------- /src/styles/global.less: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #app { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | padding-bottom: constant(safe-area-inset-bottom); 9 | padding-bottom: env(safe-area-inset-bottom); 10 | } 11 | -------------------------------------------------------------------------------- /start.cmd: -------------------------------------------------------------------------------- 1 | cd ./service 2 | start pnpm start > service.log & 3 | echo "Start service complete!" 4 | 5 | 6 | cd .. 7 | echo "" > front.log 8 | start pnpm dev > front.log & 9 | echo "Start front complete!" 10 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_GLOB_API_URL: string 5 | readonly VITE_APP_API_BASE_URL: string 6 | readonly VITE_GLOB_OPEN_LONG_REPLY: string 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/functions/index.ts: -------------------------------------------------------------------------------- 1 | export function getCurrentDate() { 2 | const date = new Date() 3 | const day = date.getDate() 4 | const month = date.getMonth() + 1 5 | const year = date.getFullYear() 6 | return `${year}-${month}-${day}` 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = tab 8 | indent_size = 2 9 | end_of_line = lf 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /docker-compose/readme.md: -------------------------------------------------------------------------------- 1 | ### docker-compose 部署教程 2 | - 将打包好的前端文件放到 `nginx/html` 目录下 3 | - ```shell 4 | # 启动 5 | docker-compose up -d 6 | ``` 7 | - ```shell 8 | # 查看运行状态 9 | docker ps 10 | ``` 11 | - ```shell 12 | # 结束运行 13 | docker-compose down 14 | ``` 15 | -------------------------------------------------------------------------------- /src/hooks/useBasicLayout.ts: -------------------------------------------------------------------------------- 1 | import { breakpointsTailwind, useBreakpoints } from '@vueuse/core' 2 | 3 | export function useBasicLayout() { 4 | const breakpoints = useBreakpoints(breakpointsTailwind) 5 | const isMobile = breakpoints.smaller('sm') 6 | 7 | return { isMobile } 8 | } 9 | -------------------------------------------------------------------------------- /src/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | $loadingBar?: import('naive-ui').LoadingBarProviderInst 3 | $dialog?: import('naive-ui').DialogProviderInst 4 | $message?: import('naive-ui').MessageProviderInst 5 | $notification?: import('naive-ui').NotificationProviderInst 6 | } 7 | -------------------------------------------------------------------------------- /src/store/modules/auth/helper.ts: -------------------------------------------------------------------------------- 1 | import { ss } from '@/utils/storage' 2 | 3 | const LOCAL_NAME = 'SECRET_TOKEN' 4 | 5 | export function getToken() { 6 | return ss.get(LOCAL_NAME) 7 | } 8 | 9 | export function setToken(token: string) { 10 | return ss.set(LOCAL_NAME, token) 11 | } 12 | 13 | export function removeToken() { 14 | return ss.remove(LOCAL_NAME) 15 | } 16 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | export VITE_GIT_COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null) 2 | export VITE_RELEASE_VERSION=$(git describe --tags --exact-match 2>/dev/null) 3 | 4 | cd ./service 5 | nohup pnpm start > service.log & 6 | echo "Start service complete!" 7 | 8 | 9 | cd .. 10 | echo "" > front.log 11 | nohup pnpm dev > front.log & 12 | echo "Start front complete!" 13 | tail -f front.log 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Glob API URL 2 | VITE_GLOB_API_URL=/api 3 | 4 | VITE_APP_API_BASE_URL=http://127.0.0.1:3002/ 5 | 6 | # Whether long replies are supported, which may result in higher API fees 7 | VITE_GLOB_OPEN_LONG_REPLY=true 8 | 9 | # git commit hash 10 | # GITHUB_SHA 11 | VITE_GIT_COMMIT_HASH=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 12 | 13 | # release version 14 | VITE_RELEASE_VERSION=v0.0.0 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | "*.vue" eol=lf 2 | "*.js" eol=lf 3 | "*.ts" eol=lf 4 | "*.jsx" eol=lf 5 | "*.tsx" eol=lf 6 | "*.cjs" eol=lf 7 | "*.cts" eol=lf 8 | "*.mjs" eol=lf 9 | "*.mts" eol=lf 10 | "*.json" eol=lf 11 | "*.html" eol=lf 12 | "*.css" eol=lf 13 | "*.less" eol=lf 14 | "*.scss" eol=lf 15 | "*.sass" eol=lf 16 | "*.styl" eol=lf 17 | "*.md" eol=lf 18 | -------------------------------------------------------------------------------- /docker-compose/docker-compose-mongodb.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | image: mongo 6 | container_name: mongodb 7 | restart: always 8 | ports: 9 | - '27017:27017' 10 | volumes: 11 | - ./mongodb:/data/db 12 | environment: 13 | MONGO_INITDB_ROOT_USERNAME: chatgpt 14 | MONGO_INITDB_ROOT_PASSWORD: password 15 | MONGO_INITDB_DATABASE: chatgpt 16 | -------------------------------------------------------------------------------- /src/icons/Prompt.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /service/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": false, 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | }, 7 | "eslint.validate": [ 8 | "javascript", 9 | "typescript", 10 | "json", 11 | "jsonc", 12 | "json5", 13 | "yaml" 14 | ], 15 | "cSpell.words": [ 16 | "antfu", 17 | "chatgpt", 18 | "esno", 19 | "GPTAPI", 20 | "OPENAI" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/common/SvgIcon/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 21 | -------------------------------------------------------------------------------- /src/icons/Spinner.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.vue' 2 | import { setupI18n } from './locales' 3 | import { setupAssets, setupScrollbarStyle } from './plugins' 4 | import { setupRouter } from './router' 5 | import { setupStore } from './store' 6 | 7 | async function bootstrap() { 8 | const app = createApp(App) 9 | setupAssets() 10 | 11 | setupScrollbarStyle() 12 | 13 | setupStore(app) 14 | 15 | setupI18n(app) 16 | 17 | await setupRouter(app) 18 | 19 | app.mount('#app') 20 | } 21 | 22 | bootstrap() 23 | -------------------------------------------------------------------------------- /service/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/extensions.json 24 | .idea 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | 31 | build 32 | public 33 | uploads 34 | -------------------------------------------------------------------------------- /src/utils/functions/debounce.ts: -------------------------------------------------------------------------------- 1 | type CallbackFunc = (...args: T) => void 2 | 3 | export function debounce( 4 | func: CallbackFunc, 5 | wait: number, 6 | ): (...args: T) => void { 7 | let timeoutId: ReturnType | undefined 8 | 9 | return (...args: T) => { 10 | const later = () => { 11 | clearTimeout(timeoutId) 12 | func(...args) 13 | } 14 | 15 | clearTimeout(timeoutId) 16 | timeoutId = setTimeout(later, wait) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/common/HoverButton/Button.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/extensions.json 24 | .idea 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | 31 | # Environment variables files 32 | /service/.env 33 | /docker-compose/nginx/html 34 | 35 | local/ 36 | -------------------------------------------------------------------------------- /src/plugins/assets.ts: -------------------------------------------------------------------------------- 1 | import 'katex/dist/katex.min.css' 2 | import '@/styles/lib/tailwind.css' 3 | import '@/styles/lib/highlight.less' 4 | import '@/styles/lib/github-markdown.less' 5 | import '@/styles/global.less' 6 | import '@/views/chat/components/Message/style.less' 7 | 8 | /** Tailwind's Preflight Style Override */ 9 | function naiveStyleOverride() { 10 | const meta = document.createElement('meta') 11 | meta.name = 'naive-ui-style' 12 | document.head.appendChild(meta) 13 | } 14 | 15 | function setupAssets() { 16 | naiveStyleOverride() 17 | } 18 | 19 | export default setupAssets 20 | -------------------------------------------------------------------------------- /src/store/modules/prompt/index.ts: -------------------------------------------------------------------------------- 1 | import type { UserPrompt } from '@/components/common/Setting/model' 2 | 3 | class PromptState { 4 | promptList: UserPrompt[] = [] 5 | } 6 | 7 | export const usePromptStore = defineStore('prompt-store', () => { 8 | const state = reactive(new PromptState()) 9 | 10 | const updatePromptList = (promptList: []) => { 11 | state.promptList = promptList 12 | } 13 | 14 | const getPromptList = () => { 15 | return state.promptList 16 | } 17 | 18 | return { 19 | ...toRefs(state), 20 | 21 | updatePromptList, 22 | getPromptList, 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /kubernetes/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | annotations: 5 | kubernetes.io/ingress.class: nginx 6 | nginx.ingress.kubernetes.io/proxy-connect-timeout: '5' 7 | name: chatgpt-web 8 | spec: 9 | rules: 10 | - host: chatgpt.example.com 11 | http: 12 | paths: 13 | - backend: 14 | service: 15 | name: chatgpt-web 16 | port: 17 | number: 3002 18 | path: / 19 | pathType: ImplementationSpecific 20 | tls: 21 | - secretName: chatgpt-web-tls 22 | -------------------------------------------------------------------------------- /src/components/common/PromptTypeTag/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | -------------------------------------------------------------------------------- /src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | import GithubSite from './GitHubSite/index.vue' 2 | import HoverButton from './HoverButton/index.vue' 3 | import NaiveProvider from './NaiveProvider/index.vue' 4 | import PromptStore from './PromptStore/index.vue' 5 | import PromptTypeTag from './PromptTypeTag/index.vue' 6 | import Setting from './Setting/index.vue' 7 | import SvgIcon from './SvgIcon/index.vue' 8 | import UserAvatar from './UserAvatar/index.vue' 9 | import Watermark from './Watermark/index.vue' 10 | 11 | export { GithubSite, HoverButton, NaiveProvider, PromptStore, PromptTypeTag, Setting, SvgIcon, UserAvatar, Watermark } 12 | -------------------------------------------------------------------------------- /service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": [ 5 | "esnext" 6 | ], 7 | "baseUrl": ".", 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowJs": true, 12 | "strict": false, 13 | "outDir": "build", 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "isolatedModules": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": [ 20 | "src/**/*.ts" 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "build", 25 | "src/local" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /src/assets/recommend.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "awesome-chatgpt-prompts-zh", 4 | "desc": "ChatGPT 中文调教指南", 5 | "downloadUrl": "https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh.json", 6 | "url": "https://github.com/PlexPt/awesome-chatgpt-prompts-zh" 7 | }, 8 | { 9 | "title": "awesome-chatgpt-prompts-zh-TW", 10 | "desc": "ChatGPT 中文調教指南 (透過 OpenAI / OpenCC 協助,從簡體中文轉換為繁體中文的版本)", 11 | "downloadUrl": "https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh-TW.json", 12 | "url": "https://github.com/PlexPt/awesome-chatgpt-prompts-zh" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /service/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | interface SendResponseOptions { 2 | type: 'Success' | 'Fail' 3 | message?: string 4 | data?: T 5 | } 6 | 7 | export function sendResponse(options: SendResponseOptions) { 8 | if (options.type === 'Success') { 9 | return Promise.resolve({ 10 | message: options.message ?? null, 11 | data: options.data ?? null, 12 | status: options.type, 13 | }) 14 | } 15 | 16 | // eslint-disable-next-line prefer-promise-reject-errors 17 | return Promise.reject({ 18 | message: options.message ?? 'Failed', 19 | data: options.data ?? null, 20 | status: options.type, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/common/GitHubSite/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 19 | -------------------------------------------------------------------------------- /src/components/common/Watermark/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "lib": ["DOM", "ESNext"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "jsx": "preserve", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": true, 14 | "strictNullChecks": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "skipLibCheck": true, 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | }, 20 | "types": ["vite/client", "node", "naive-ui/volar"] 21 | }, 22 | "exclude": ["node_modules", "dist", "service"] 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/useLanguage.ts: -------------------------------------------------------------------------------- 1 | import { enUS, koKR, zhCN, zhTW } from 'naive-ui' 2 | import { setLocale } from '@/locales' 3 | import { useAppStore } from '@/store' 4 | 5 | export function useLanguage() { 6 | const appStore = useAppStore() 7 | 8 | const language = computed(() => { 9 | switch (appStore.language) { 10 | case 'en-US': 11 | setLocale('en-US') 12 | return enUS 13 | case 'zh-CN': 14 | setLocale('zh-CN') 15 | return zhCN 16 | case 'zh-TW': 17 | setLocale('zh-TW') 18 | return zhTW 19 | case 'ko-KR': 20 | setLocale('ko-KR') 21 | return koKR 22 | default: 23 | setLocale('zh-CN') 24 | return zhCN 25 | } 26 | }) 27 | 28 | return { language } 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useIconRender.ts: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from '@/components/common' 2 | 3 | export function useIconRender() { 4 | interface IconConfig { 5 | icon: string 6 | color?: string 7 | fontSize?: number 8 | } 9 | 10 | interface IconStyle { 11 | color?: string 12 | fontSize?: string 13 | } 14 | 15 | const iconRender = (config: IconConfig) => { 16 | const { color, fontSize, icon } = config 17 | 18 | const style: IconStyle = {} 19 | 20 | if (color) 21 | style.color = color 22 | 23 | if (fontSize) 24 | style.fontSize = `${fontSize}px` 25 | 26 | if (!icon) 27 | window.console.warn('iconRender: icon is required') 28 | 29 | return () => h(SvgIcon, { icon, style }) 30 | } 31 | 32 | return { 33 | iconRender, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/views/chat/hooks/useChat.ts: -------------------------------------------------------------------------------- 1 | import { useChatStore } from '@/store' 2 | 3 | export function useChat() { 4 | const chatStore = useChatStore() 5 | 6 | const getChatByUuidAndIndex = (uuid: number, index: number) => { 7 | return chatStore.getChatByUuidAndIndex(uuid, index) 8 | } 9 | 10 | const addChat = async (roomId: number, chat: Chat.Chat) => { 11 | await chatStore.addChatMessage(roomId, chat) 12 | } 13 | 14 | const updateChat = (roomId: number, index: number, chat: Chat.Chat) => { 15 | chatStore.updateChatByUuid(roomId, index, chat) 16 | } 17 | 18 | const updateChatSome = (uuid: number, index: number, chat: Partial) => { 19 | chatStore.updateChatSomeByUuid(uuid, index, chat) 20 | } 21 | 22 | return { 23 | addChat, 24 | updateChat, 25 | updateChatSome, 26 | getChatByUuidAndIndex, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/store/modules/app/helper.ts: -------------------------------------------------------------------------------- 1 | import { ss } from '@/utils/storage' 2 | 3 | const LOCAL_NAME = 'appSetting' 4 | 5 | export type Theme = 'light' | 'dark' | 'auto' 6 | 7 | export type Language = 'zh-CN' | 'zh-TW' | 'en-US' | 'ko-KR' 8 | 9 | export type FastDelMsg = '0' | '1' 10 | 11 | export interface AppState { 12 | siderCollapsed: boolean 13 | theme: Theme 14 | language: Language 15 | fastDelMsg: FastDelMsg 16 | } 17 | 18 | export function defaultSetting(): AppState { 19 | return { siderCollapsed: false, theme: 'auto', language: 'zh-CN', fastDelMsg: '0' } 20 | } 21 | 22 | export function getLocalSetting(): AppState { 23 | const localSetting: AppState | undefined = ss.get(LOCAL_NAME) 24 | return { ...defaultSetting(), ...localSetting } 25 | } 26 | 27 | export function setLocalSetting(setting: AppState): void { 28 | ss.set(LOCAL_NAME, setting) 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/request/axios.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios' 2 | import axios from 'axios' 3 | import { useAuthStore } from '@/store' 4 | 5 | const service = axios.create({ 6 | baseURL: import.meta.env.VITE_GLOB_API_URL, 7 | }) 8 | 9 | service.interceptors.request.use( 10 | (config) => { 11 | const token = useAuthStore().token 12 | if (token) 13 | config.headers.Authorization = `Bearer ${token}` 14 | return config 15 | }, 16 | (error) => { 17 | return Promise.reject(error.response) 18 | }, 19 | ) 20 | 21 | service.interceptors.response.use( 22 | (response: AxiosResponse): AxiosResponse => { 23 | if (response.status === 200) 24 | return response 25 | 26 | throw new Error(response.status.toString()) 27 | }, 28 | (error) => { 29 | return Promise.reject(error) 30 | }, 31 | ) 32 | 33 | export default service 34 | -------------------------------------------------------------------------------- /service/src/routes/upload.ts: -------------------------------------------------------------------------------- 1 | import Router from 'express' 2 | import multer from 'multer' 3 | import { auth } from '../middleware/auth' 4 | 5 | export const router = Router() 6 | 7 | // 配置multer的存储选项 8 | const storage = multer.diskStorage({ 9 | destination(req, file, cb) { 10 | cb(null, 'uploads/') // 确保这个文件夹存在 11 | }, 12 | filename(req, file, cb) { 13 | cb(null, `${file.fieldname}-${Date.now()}`) 14 | }, 15 | }) 16 | 17 | const upload = multer({ storage }) 18 | router.post('/upload-image', auth, upload.single('file'), async (req, res) => { 19 | try { 20 | if (!req.file) 21 | res.send({ status: 'Fail', message: '没有文件被上传', data: null }) 22 | const data = { 23 | fileKey: req.file.filename, 24 | } 25 | // 文件已上传 26 | res.send({ status: 'Success', message: '文件上传成功', data }) 27 | } 28 | catch (error) { 29 | res.send(error) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /src/store/modules/settings/helper.ts: -------------------------------------------------------------------------------- 1 | import { ss } from '@/utils/storage' 2 | 3 | const LOCAL_NAME = 'settingsStorage' 4 | 5 | export interface SettingsState { 6 | systemMessage: string 7 | temperature: number 8 | top_p: number 9 | } 10 | 11 | export function defaultSetting(): SettingsState { 12 | return { 13 | systemMessage: 'You are a large language model. Follow the user\'s instructions carefully. Respond using markdown(latex start with $).', 14 | temperature: 0.8, 15 | top_p: 1, 16 | } 17 | } 18 | 19 | export function getLocalState(): SettingsState { 20 | const localSetting: SettingsState | undefined = ss.get(LOCAL_NAME) 21 | return { ...defaultSetting(), ...localSetting } 22 | } 23 | 24 | export function setLocalState(setting: SettingsState): void { 25 | ss.set(LOCAL_NAME, setting) 26 | } 27 | 28 | export function removeLocalState() { 29 | ss.remove(LOCAL_NAME) 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/copy.ts: -------------------------------------------------------------------------------- 1 | document.addEventListener('copy', (e) => { 2 | const selectedText = window.getSelection()?.toString() ?? '' 3 | const cleanedText = selectedText?.replace(/\n+$/, '\n') 4 | e.clipboardData?.setData('text/plain', cleanedText) 5 | 6 | e.preventDefault() 7 | }) 8 | 9 | export async function copyToClip(text: string) { 10 | // https://stackoverflow.com/questions/51805395/navigator-clipboard-is-undefined 11 | if (navigator.clipboard && window.isSecureContext) { 12 | await navigator.clipboard.writeText(text) 13 | } 14 | else { 15 | const input: HTMLTextAreaElement = document.createElement('textarea') 16 | input.setAttribute('readonly', 'readonly') 17 | input.value = text 18 | document.body.appendChild(input) 19 | input.select() 20 | if (document.execCommand('copy')) 21 | document.execCommand('copy') 22 | document.body.removeChild(input) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Web App", 11 | "url": "http://localhost:1002", 12 | "webRoot": "${workspaceFolder}" 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Launch Service Server", 18 | "runtimeExecutable": "${workspaceFolder}/service/node_modules/.bin/tsx", 19 | "skipFiles": ["/**"], 20 | "program": "${workspaceFolder}/service/src/index.ts", 21 | "outFiles": ["${workspaceFolder}/service/**/*.js"], 22 | "envFile": "${workspaceFolder}/service/.env" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /service/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { JwtPayload } from 'jsonwebtoken' 2 | 3 | export interface RequestProps { 4 | roomId: number 5 | uuid: number 6 | regenerate: boolean 7 | prompt: string 8 | uploadFileKeys?: string[] 9 | options?: ChatContext 10 | systemMessage: string 11 | temperature?: number 12 | top_p?: number 13 | } 14 | 15 | export interface ChatContext { 16 | conversationId?: string 17 | parentMessageId?: string 18 | } 19 | 20 | export interface AuthJwtPayload extends JwtPayload { 21 | name: string 22 | avatar: string 23 | description: string 24 | userId: string 25 | root: boolean 26 | config: any 27 | } 28 | 29 | export class TwoFAConfig { 30 | enaled: boolean 31 | userName: string 32 | secretKey: string 33 | otpauthUrl: string 34 | constructor() { 35 | this.enaled = false 36 | this.userName = '' 37 | this.secretKey = '' 38 | this.otpauthUrl = '' 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/lib/tailwind.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | @theme { 6 | --animate-blink: blink 1.2s infinite steps(1, start); 7 | 8 | @keyframes blink { 9 | 0%, 10 | 100% { 11 | background-color: currentColor; 12 | } 13 | 50% { 14 | background-color: transparent; 15 | } 16 | } 17 | } 18 | 19 | /* 20 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 21 | so we've added these compatibility styles to make sure everything still 22 | looks the same as it did with Tailwind CSS v3. 23 | 24 | If we ever want to remove these styles, we need to add an explicit border 25 | color utility to any element that depends on these defaults. 26 | */ 27 | @layer base { 28 | *, 29 | ::after, 30 | ::before, 31 | ::backdrop, 32 | ::file-selector-button { 33 | border-color: var(--color-gray-200, currentcolor); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/views/exception/500/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 31 | -------------------------------------------------------------------------------- /src/views/exception/404/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | -------------------------------------------------------------------------------- /docker-compose/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | charset utf-8; 5 | error_page 500 502 503 504 /50x.html; 6 | 7 | # 防止爬虫抓取 8 | if ($http_user_agent ~* "360Spider|JikeSpider|Spider|spider|bot|Bot|2345Explorer|curl|wget|webZIP|qihoobot|Baiduspider|Googlebot|Googlebot-Mobile|Googlebot-Image|Mediapartners-Google|Adsbot-Google|Feedfetcher-Google|Yahoo! Slurp|Yahoo! Slurp China|YoudaoBot|Sosospider|Sogou spider|Sogou web spider|MSNBot|ia_archiver|Tomato Bot|NSPlayer|bingbot"){ 9 | return 403; 10 | } 11 | 12 | 13 | location / { 14 | root /usr/share/nginx/html; 15 | try_files $uri /index.html; 16 | } 17 | 18 | location /api { 19 | proxy_set_header X-Real-IP $remote_addr; #转发用户IP 20 | proxy_pass http://app:3002; 21 | } 22 | 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header REMOTE-HOST $remote_addr; 26 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 27 | } 28 | -------------------------------------------------------------------------------- /src/router/permission.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'vue-router' 2 | import { useUserStore } from '@/store' 3 | import { useAuthStoreWithout } from '@/store/modules/auth' 4 | 5 | export function setupPageGuard(router: Router) { 6 | router.beforeEach(async (to, from, next) => { 7 | const authStore = useAuthStoreWithout() 8 | if (!authStore.session) { 9 | try { 10 | const data = await authStore.getSession() 11 | document.title = data.title 12 | if (String(data.auth) === 'false' && authStore.token) 13 | await authStore.removeToken() 14 | else 15 | await useUserStore().updateUserInfo(false, data.userInfo) 16 | 17 | if (to.path === '/500') 18 | next({ name: 'Root' }) 19 | else 20 | next() 21 | } 22 | catch { 23 | if (to.path !== '/500') 24 | next({ name: '500' }) 25 | else 26 | next() 27 | } 28 | } 29 | else { 30 | next() 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /service/.env.example: -------------------------------------------------------------------------------- 1 | # set `true` to disable OpenAI API debug log 2 | OPENAI_API_DISABLE_DEBUG= 3 | 4 | # Rate Limit 5 | MAX_REQUEST_PER_HOUR= 6 | 7 | # Auth Rate Limit 8 | AUTH_MAX_REQUEST_PER_MINUTE=5 9 | 10 | # Title for site 11 | SITE_TITLE="ChatGpt Web" 12 | 13 | # Databse connection string 14 | # MONGODB_URL=mongodb://chatgpt:xxxx@yourip:port 15 | MONGODB_URL=mongodb://chatgpt:xxxx@database:27017 16 | 17 | # Secret key for jwt 18 | # If not empty, will need login 19 | AUTH_SECRET_KEY= 20 | 21 | # ----- Only valid after setting AUTH_SECRET_KEY begin ---- 22 | 23 | # The roon user only email 24 | ROOT_USER= 25 | 26 | # Password salt 27 | PASSWORD_MD5_SALT=anysalt 28 | 29 | # Allow anyone register, Must be turned on, otherwise administrators cannot register, can be turned off later. 30 | REGISTER_ENABLED=true 31 | 32 | # ----- Only valid after setting AUTH_SECRET_KEY end ---- 33 | 34 | 35 | # 更多配置, 在运行后, 注册管理员, 在管理员页面中设置 36 | # More configurations, register an administrator after running and set it in the administrator page. 37 | -------------------------------------------------------------------------------- /src/plugins/scrollbarStyle.ts: -------------------------------------------------------------------------------- 1 | import { darkTheme, lightTheme } from 'naive-ui' 2 | 3 | function setupScrollbarStyle() { 4 | const style = document.createElement('style') 5 | const styleContent = ` 6 | ::-webkit-scrollbar { 7 | background-color: transparent; 8 | width: ${lightTheme.Scrollbar.common?.scrollbarWidth}; 9 | } 10 | ::-webkit-scrollbar-thumb { 11 | background-color: ${lightTheme.Scrollbar.common?.scrollbarColor}; 12 | border-radius: ${lightTheme.Scrollbar.common?.scrollbarBorderRadius}; 13 | } 14 | html.dark ::-webkit-scrollbar { 15 | background-color: transparent; 16 | width: ${darkTheme.Scrollbar.common?.scrollbarWidth}; 17 | } 18 | html.dark ::-webkit-scrollbar-thumb { 19 | background-color: ${darkTheme.Scrollbar.common?.scrollbarColor}; 20 | border-radius: ${darkTheme.Scrollbar.common?.scrollbarBorderRadius}; 21 | } 22 | ` 23 | 24 | style.innerHTML = styleContent 25 | document.head.appendChild(style) 26 | } 27 | 28 | export default setupScrollbarStyle 29 | -------------------------------------------------------------------------------- /src/components/common/NaiveProvider/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 43 | -------------------------------------------------------------------------------- /service/src/utils/image.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from 'node:buffer' 2 | import fs from 'node:fs/promises' 3 | import * as fileType from 'file-type' 4 | 5 | fs.mkdir('uploads').then(() => { 6 | globalThis.console.log('Directory uploads created') 7 | }).catch((e) => { 8 | if (e.code === 'EEXIST') { 9 | globalThis.console.log('Directory uploads already exists') 10 | return 11 | } 12 | globalThis.console.error('Error creating directory uploads, ', e) 13 | }) 14 | 15 | export async function convertImageUrl(uploadFileKey: string): Promise { 16 | let imageData: Buffer 17 | try { 18 | imageData = await fs.readFile(`uploads/${uploadFileKey}`) 19 | } 20 | catch (e) { 21 | globalThis.console.error(`Error open uploads file ${uploadFileKey}, ${e.message}`) 22 | return 23 | } 24 | // 判断文件格式 25 | const imageType = await fileType.fileTypeFromBuffer(imageData) 26 | const mimeType = imageType.mime 27 | // 将图片数据转换为 Base64 编码的字符串 28 | const base64Image = imageData.toString('base64') 29 | return `data:${mimeType};base64,${base64Image}` 30 | } 31 | -------------------------------------------------------------------------------- /src/store/modules/app/index.ts: -------------------------------------------------------------------------------- 1 | import type { AppState, FastDelMsg, Language, Theme } from './helper' 2 | import { store } from '@/store/helper' 3 | import { getLocalSetting, setLocalSetting } from './helper' 4 | 5 | export const useAppStore = defineStore('app-store', { 6 | state: (): AppState => getLocalSetting(), 7 | actions: { 8 | setSiderCollapsed(collapsed: boolean) { 9 | this.siderCollapsed = collapsed 10 | this.recordState() 11 | }, 12 | 13 | setTheme(theme: Theme) { 14 | this.theme = theme 15 | this.recordState() 16 | }, 17 | 18 | setLanguage(language: Language) { 19 | if (this.language !== language) { 20 | this.language = language 21 | this.recordState() 22 | } 23 | }, 24 | 25 | setFastDelMsg(fastDelMsg: FastDelMsg) { 26 | if (this.fastDelMsg !== fastDelMsg) { 27 | this.fastDelMsg = fastDelMsg 28 | this.recordState() 29 | } 30 | }, 31 | 32 | recordState() { 33 | setLocalSetting(this.$state) 34 | }, 35 | }, 36 | }) 37 | 38 | export function useAppStoreWithOut() { 39 | return useAppStore(store) 40 | } 41 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | import type { Language } from '@/store/modules/app/helper' 3 | import { createI18n } from 'vue-i18n' 4 | import { useAppStoreWithOut } from '@/store/modules/app' 5 | import enUS from './en-US.json' 6 | import koKR from './ko-KR.json' 7 | import zhCN from './zh-CN.json' 8 | import zhTW from './zh-TW.json' 9 | 10 | const appStore = useAppStoreWithOut() 11 | 12 | const defaultLocale = appStore.language || 'zh-CN' 13 | 14 | // Type-define 'en-US' as the master schema for the resource 15 | type MessageSchema = typeof enUS 16 | 17 | const i18n = createI18n<[MessageSchema], Language>({ 18 | legacy: false, 19 | globalInjection: false, 20 | locale: defaultLocale, 21 | fallbackLocale: 'en-US', 22 | messages: { 23 | 'en-US': enUS, 24 | 'zh-CN': zhCN, 25 | 'zh-TW': zhTW, 26 | 'ko-KR': koKR, 27 | }, 28 | }) 29 | 30 | export function setLocale(locale: Language) { 31 | // @ts-expect-error i18n.global.locale is ComputedRefImpl 32 | i18n.global.locale.value = locale 33 | appStore.setLanguage(locale) 34 | } 35 | 36 | export function setupI18n(app: App) { 37 | app.use(i18n) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/common/HoverButton/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ChenZhaoYu 4 | Copyright (c) 2023 Kerwin1202 5 | Copyright (c) 2023 - Present github.com/chatgpt-web-dev Contributors 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /service/src/chatgpt/types.ts: -------------------------------------------------------------------------------- 1 | import type OpenAI from 'openai' 2 | import type { ChatRoom, SearchResult, UserInfo } from 'src/storage/model' 3 | 4 | export interface ChatMessage { 5 | id: string 6 | content: string | OpenAI.Chat.ChatCompletionContentPart[] | OpenAI.Responses.ResponseInputContent[] 7 | role: OpenAI.Chat.ChatCompletionRole 8 | name?: string 9 | delta?: string 10 | detail?: string 11 | parentMessageId?: string 12 | } 13 | 14 | export interface ResponseChunk { 15 | id?: string 16 | searching?: boolean 17 | generating?: boolean 18 | searchQuery?: string 19 | searchResults?: SearchResult[] 20 | searchUsageTime?: number 21 | text?: string 22 | reasoning?: string 23 | role?: string 24 | finish_reason?: string 25 | // 支持增量响应 26 | delta?: { 27 | reasoning?: string 28 | text?: string 29 | } 30 | } 31 | 32 | export interface RequestOptions { 33 | message: string 34 | uploadFileKeys?: string[] 35 | parentMessageId?: string 36 | process?: (chunk: ResponseChunk) => void 37 | systemMessage?: string 38 | temperature?: number 39 | top_p?: number 40 | user: UserInfo 41 | messageId: string 42 | room: ChatRoom 43 | chatUuid: number 44 | } 45 | 46 | export interface BalanceResponse { 47 | total_usage: number 48 | } 49 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | import type { RouteRecordRaw } from 'vue-router' 3 | import { createRouter, createWebHashHistory } from 'vue-router' 4 | import { ChatLayout } from '@/views/chat/layout' 5 | import { setupPageGuard } from './permission' 6 | 7 | const routes: RouteRecordRaw[] = [ 8 | { 9 | path: '/', 10 | name: 'Root', 11 | component: ChatLayout, 12 | redirect: '/chat', 13 | children: [ 14 | { 15 | path: '/chat/:uuid?', 16 | name: 'Chat', 17 | component: () => import('@/views/chat/index.vue'), 18 | }, 19 | ], 20 | }, 21 | 22 | { 23 | path: '/404', 24 | name: '404', 25 | component: () => import('@/views/exception/404/index.vue'), 26 | }, 27 | 28 | { 29 | path: '/500', 30 | name: '500', 31 | component: () => import('@/views/exception/500/index.vue'), 32 | }, 33 | 34 | { 35 | path: '/:pathMatch(.*)*', 36 | name: 'notFound', 37 | redirect: '/404', 38 | }, 39 | ] 40 | 41 | export const router = createRouter({ 42 | history: createWebHashHistory(), 43 | routes, 44 | scrollBehavior: () => ({ left: 0, top: 0 }), 45 | }) 46 | 47 | setupPageGuard(router) 48 | 49 | export async function setupRouter(app: App) { 50 | app.use(router) 51 | await router.isReady() 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 24 21 | - name: Enable corepack 22 | run: corepack enable 23 | - name: Install dependencies 24 | run: pnpm install 25 | - name: Lint 26 | run: pnpm lint 27 | - name: Install service dependencies 28 | run: cd service && pnpm install 29 | - name: Lint service 30 | run: cd service && pnpm lint 31 | 32 | typecheck: 33 | name: Typecheck 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-node@v4 38 | with: 39 | node-version: 24 40 | - name: Enable corepack 41 | run: corepack enable 42 | - name: Install dependencies 43 | run: pnpm install 44 | - name: Typecheck 45 | run: pnpm type-check 46 | - name: Install service dependencies 47 | run: cd service && pnpm install 48 | - name: Typecheck service 49 | run: cd service && pnpm type-check 50 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalThemeOverrides } from 'naive-ui' 2 | import { darkTheme, useOsTheme } from 'naive-ui' 3 | import { useAppStore } from '@/store' 4 | 5 | export function useTheme() { 6 | const appStore = useAppStore() 7 | 8 | const OsTheme = useOsTheme() 9 | 10 | const isDark = computed(() => { 11 | if (appStore.theme === 'auto') 12 | return OsTheme.value === 'dark' 13 | else 14 | return appStore.theme === 'dark' 15 | }) 16 | 17 | const theme = computed(() => { 18 | return isDark.value ? darkTheme : undefined 19 | }) 20 | 21 | const themeOverrides = computed(() => { 22 | if (isDark.value) { 23 | return { 24 | common: {}, 25 | } 26 | } 27 | return {} 28 | }) 29 | 30 | watch( 31 | () => isDark.value, 32 | (dark) => { 33 | if (dark) { 34 | document.documentElement.classList.add('dark') 35 | document.querySelector('head meta[name="theme-color"]')?.setAttribute('content', '#121212') 36 | } 37 | else { 38 | document.documentElement.classList.remove('dark') 39 | document.querySelector('head meta[name="theme-color"]')?.setAttribute('content', '#eee') 40 | } 41 | }, 42 | { immediate: true }, 43 | ) 44 | 45 | return { theme, themeOverrides } 46 | } 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build front-end 2 | FROM node:24-alpine AS frontend 3 | 4 | ARG GIT_COMMIT_HASH=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 5 | ARG RELEASE_VERSION=v0.0.0 6 | 7 | ENV VITE_GIT_COMMIT_HASH=$GIT_COMMIT_HASH 8 | ENV VITE_RELEASE_VERSION=$RELEASE_VERSION 9 | 10 | WORKDIR /app 11 | 12 | COPY ./package.json /app 13 | 14 | COPY ./pnpm-lock.yaml /app 15 | 16 | RUN corepack enable 17 | 18 | RUN pnpm install 19 | 20 | COPY . /app 21 | 22 | RUN pnpm run build 23 | 24 | # build backend 25 | FROM node:24-alpine AS backend 26 | 27 | WORKDIR /app 28 | 29 | COPY /service/package.json /app 30 | 31 | COPY /service/pnpm-lock.yaml /app 32 | 33 | RUN corepack enable 34 | 35 | RUN pnpm install 36 | 37 | COPY /service /app 38 | 39 | RUN pnpm build 40 | 41 | # service 42 | FROM node:24-alpine 43 | 44 | RUN apk add --no-cache tini 45 | 46 | WORKDIR /app 47 | 48 | COPY /service/package.json /app 49 | 50 | COPY /service/pnpm-lock.yaml /app 51 | 52 | RUN corepack enable 53 | 54 | RUN pnpm install --production && rm -rf /root/.npm /root/.pnpm-store /usr/local/share/.cache /tmp/* 55 | 56 | COPY /service /app 57 | 58 | COPY --from=frontend /app/dist /app/public 59 | 60 | COPY --from=backend /app/build /app/build 61 | 62 | EXPOSE 3002 63 | 64 | ENTRYPOINT ["/sbin/tini", "--"] 65 | 66 | CMD ["node", "--import", "tsx/esm", "./build/index.js"] 67 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": false, 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact", 12 | "vue", 13 | "html", 14 | "json", 15 | "jsonc", 16 | "json5", 17 | "yaml", 18 | "yml", 19 | "markdown" 20 | ], 21 | "cSpell.words": [ 22 | "antfu", 23 | "axios", 24 | "bumpp", 25 | "chatgpt", 26 | "chenzhaoyu", 27 | "commitlint", 28 | "davinci", 29 | "dockerhub", 30 | "esno", 31 | "GPTAPI", 32 | "highlightjs", 33 | "hljs", 34 | "iconify", 35 | "katex", 36 | "katexmath", 37 | "linkify", 38 | "logprobs", 39 | "mdhljs", 40 | "mila", 41 | "nodata", 42 | "OPENAI", 43 | "pinia", 44 | "Popconfirm", 45 | "rushstack", 46 | "Sider", 47 | "tailwindcss", 48 | "traptitech", 49 | "tsup", 50 | "Typecheck", 51 | "unplugin", 52 | "VITE", 53 | "vueuse", 54 | "Zhao" 55 | ], 56 | "i18n-ally.enabledParsers": [ 57 | "ts" 58 | ], 59 | "i18n-ally.sortKeys": true, 60 | "i18n-ally.keepFulfilled": true, 61 | "i18n-ally.localesPaths": [ 62 | "src/locales" 63 | ], 64 | "i18n-ally.keystyle": "nested" 65 | } 66 | -------------------------------------------------------------------------------- /src/views/chat/layout/sider/Footer.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 感谢你的宝贵时间。你的贡献将使这个项目变得更好!在提交贡献之前,请务必花点时间阅读下面的入门指南。 3 | 4 | ## 语义化版本 5 | 该项目遵循语义化版本。我们对重要的漏洞修复发布修订号,对新特性或不重要的变更发布次版本号,对重大且不兼容的变更发布主版本号。 6 | 7 | 每个重大更改都将记录在 `changelog` 中。 8 | 9 | ## 提交 Pull Request 10 | 1. Fork [此仓库](https://github.com/chatgpt-web-dev/chatgpt-web),从 `main` 创建分支。新功能实现请发 pull request 到 `feature` 分支。其他更改发到 `main` 分支。 11 | 2. 使用 `npm install pnpm -g` 安装 `pnpm` 工具。 12 | 3. `vscode` 安装了 `Eslint` 插件,其它编辑器如 `webStorm` 打开了 `eslint` 功能。 13 | 4. 根目录下执行 `pnpm bootstrap`。 14 | 5. `/service/` 目录下执行 `pnpm install`。 15 | 6. 对代码库进行更改。如果适用的话,请确保进行了相应的测试。 16 | 7. 请在根目录下执行 `pnpm lint:fix` 进行代码格式检查。 17 | 8. 请在根目录下执行 `pnpm type-check` 进行类型检查。 18 | 9. 提交 git commit, 请同时遵守 [Commit 规范](#commit-指南) 19 | 10. 提交 `pull request`, 如果有对应的 `issue`,请进行[关联](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)。 20 | 21 | ## Commit 指南 22 | 23 | Commit messages 请遵循[conventional-changelog 标准](https://www.conventionalcommits.org/en/v1.0.0/): 24 | 25 | ```bash 26 | <类型>[可选 范围]: <描述> 27 | 28 | [可选 正文] 29 | 30 | [可选 脚注] 31 | ``` 32 | 33 | ### Commit 类型 34 | 35 | 以下是 commit 类型列表: 36 | 37 | - feat: 新特性或功能 38 | - fix: 缺陷修复 39 | - docs: 文档更新 40 | - style: 代码风格或者组件样式更新 41 | - refactor: 代码重构,不引入新功能和缺陷修复 42 | - perf: 性能优化 43 | - test: 单元测试 44 | - chore: 其他不修改 src 或测试文件的提交 45 | 46 | 47 | ## License 48 | 49 | [MIT © github.com/chatgpt-web-dev Contributors](./LICENSE) 50 | -------------------------------------------------------------------------------- /src/views/chat/layout/Layout.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 44 | -------------------------------------------------------------------------------- /src/views/chat/hooks/useScroll.ts: -------------------------------------------------------------------------------- 1 | type ScrollElement = HTMLDivElement | null 2 | 3 | interface ScrollReturn { 4 | scrollRef: Ref 5 | scrollTo: (top: number) => Promise 6 | scrollToBottom: () => Promise 7 | scrollToTop: () => Promise 8 | scrollToBottomIfAtBottom: () => Promise 9 | } 10 | 11 | export function useScroll(): ScrollReturn { 12 | const scrollRef = ref(null) 13 | 14 | const scrollTo = async (top: number) => { 15 | await nextTick() 16 | if (scrollRef.value) 17 | scrollRef.value.scrollTop = top 18 | } 19 | 20 | const scrollToBottom = async () => { 21 | await nextTick() 22 | if (scrollRef.value) 23 | scrollRef.value.scrollTop = scrollRef.value.scrollHeight 24 | } 25 | 26 | const scrollToTop = async () => { 27 | await nextTick() 28 | if (scrollRef.value) 29 | scrollRef.value.scrollTop = 0 30 | } 31 | 32 | const scrollToBottomIfAtBottom = async () => { 33 | await nextTick() 34 | if (scrollRef.value) { 35 | const threshold = 100 // 阈值,表示滚动条到底部的距离阈值 36 | const distanceToBottom = scrollRef.value.scrollHeight - scrollRef.value.scrollTop - scrollRef.value.clientHeight 37 | if (distanceToBottom <= threshold) 38 | scrollRef.value.scrollTop = scrollRef.value.scrollHeight 39 | } 40 | } 41 | 42 | return { 43 | scrollRef, 44 | scrollTo, 45 | scrollToBottom, 46 | scrollToTop, 47 | scrollToBottomIfAtBottom, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/storage/index.ts: -------------------------------------------------------------------------------- 1 | interface StorageData { 2 | data: T 3 | expire: number | null 4 | } 5 | 6 | export function createLocalStorage(options?: { expire?: number | null }) { 7 | const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7 8 | 9 | const { expire } = Object.assign({ expire: DEFAULT_CACHE_TIME }, options) 10 | 11 | function set(key: string, data: T) { 12 | const storageData: StorageData = { 13 | data, 14 | expire: expire !== null ? new Date().getTime() + expire * 1000 : null, 15 | } 16 | 17 | const json = JSON.stringify(storageData) 18 | window.localStorage.setItem(key, json) 19 | } 20 | 21 | function get(key: string) { 22 | const json = window.localStorage.getItem(key) 23 | if (json) { 24 | let storageData: StorageData | null = null 25 | 26 | try { 27 | storageData = JSON.parse(json) 28 | } 29 | catch { 30 | // Prevent failure 31 | } 32 | 33 | if (storageData) { 34 | const { data, expire } = storageData 35 | if (expire === null || expire >= Date.now()) 36 | return data 37 | } 38 | 39 | remove(key) 40 | return null 41 | } 42 | } 43 | 44 | function remove(key: string) { 45 | window.localStorage.removeItem(key) 46 | } 47 | 48 | function clear() { 49 | window.localStorage.clear() 50 | } 51 | 52 | return { set, get, remove, clear } 53 | } 54 | 55 | export const ls = createLocalStorage() 56 | 57 | export const ss = createLocalStorage({ expire: null }) 58 | -------------------------------------------------------------------------------- /src/store/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | import type { UserInfo, UserState } from './helper' 2 | import { fetchResetAdvanced, fetchUpdateAdvanced, fetchUpdateUserAmt, fetchUpdateUserInfo, fetchUserAmt } from '@/api' 3 | import { defaultSetting, getLocalState, setLocalState } from './helper' 4 | 5 | export const useUserStore = defineStore('user-store', { 6 | state: (): UserState => getLocalState(), 7 | actions: { 8 | async updateUserInfo(update: boolean, userInfo: Partial) { 9 | this.userInfo = { ...this.userInfo, ...userInfo } 10 | this.recordState() 11 | if (update) { 12 | await fetchUpdateUserInfo(userInfo.name ?? '', userInfo.avatar ?? '', userInfo.description ?? '') 13 | // 更新用户信息和额度写一起了,如果传了额度则更新 14 | if (userInfo.useAmount) 15 | await fetchUpdateUserAmt(userInfo.useAmount) 16 | } 17 | }, 18 | async updateSetting(sync: boolean) { 19 | await fetchUpdateAdvanced(sync, this.userInfo.advanced) 20 | this.recordState() 21 | }, 22 | // 对应页面加载时的读取,为空送10个 23 | async readUserAmt() { 24 | const data = (await fetchUserAmt()).data 25 | this.userInfo.limit = data?.limit 26 | this.userInfo.useAmount = data?.amount ?? 10 27 | }, 28 | 29 | async resetSetting() { 30 | await fetchResetAdvanced() 31 | this.userInfo.advanced = { ...defaultSetting().userInfo.advanced } 32 | this.recordState() 33 | }, 34 | resetUserInfo() { 35 | this.userInfo = { ...defaultSetting().userInfo } 36 | this.recordState() 37 | }, 38 | 39 | recordState() { 40 | setLocalState(this.$state) 41 | }, 42 | }, 43 | }) 44 | -------------------------------------------------------------------------------- /kubernetes/deploy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: chatgpt-web 5 | labels: 6 | app: chatgpt-web 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: chatgpt-web 12 | strategy: 13 | type: RollingUpdate 14 | template: 15 | metadata: 16 | labels: 17 | app: chatgpt-web 18 | spec: 19 | containers: 20 | - image: chenzhaoyu94/chatgpt-web 21 | name: chatgpt-web 22 | imagePullPolicy: Always 23 | ports: 24 | - containerPort: 3002 25 | env: 26 | - name: OPENAI_API_KEY 27 | value: sk-xxx 28 | - name: OPENAI_API_BASE_URL 29 | value: 'https://api.openai.com' 30 | - name: OPENAI_API_MODEL 31 | value: ChatGPTAPI 32 | - name: API_REVERSE_PROXY 33 | value: https://ai.fakeopen.com/api/conversation 34 | - name: AUTH_SECRET_KEY 35 | value: '123456' 36 | - name: TIMEOUT_MS 37 | value: '600000' 38 | - name: SOCKS_PROXY_HOST 39 | value: '' 40 | - name: SOCKS_PROXY_PORT 41 | value: '' 42 | - name: HTTPS_PROXY 43 | value: '' 44 | resources: 45 | limits: 46 | cpu: 500m 47 | memory: 500Mi 48 | requests: 49 | cpu: 300m 50 | memory: 300Mi 51 | --- 52 | apiVersion: v1 53 | kind: Service 54 | metadata: 55 | labels: 56 | app: chatgpt-web 57 | name: chatgpt-web 58 | spec: 59 | ports: 60 | - name: chatgpt-web 61 | port: 3002 62 | protocol: TCP 63 | targetPort: 3002 64 | selector: 65 | app: chatgpt-web 66 | type: ClusterIP 67 | -------------------------------------------------------------------------------- /src/typings/chat.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Chat { 2 | interface SearchResult { 3 | title: string 4 | url: string 5 | content: string 6 | } 7 | 8 | interface Chat { 9 | uuid?: number 10 | dateTime: string 11 | searching?: boolean 12 | searchQuery?: string 13 | searchResults?: SearchResult[] 14 | searchUsageTime?: number 15 | reasoning?: string 16 | finish_reason?: string 17 | text: string 18 | images?: string[] 19 | inversion?: boolean 20 | responseCount?: number 21 | error?: boolean 22 | loading?: boolean 23 | conversationOptions?: ConversationRequest | null 24 | requestOptions: { prompt: string, options?: ConversationRequest | null } 25 | usage?: { 26 | completion_tokens: number 27 | prompt_tokens: number 28 | total_tokens: number 29 | estimated: boolean 30 | } 31 | } 32 | 33 | interface ChatRoom { 34 | title: string 35 | isEdit: boolean 36 | roomId: number 37 | loading?: boolean 38 | all?: boolean 39 | prompt?: string 40 | usingContext: boolean 41 | maxContextCount: number 42 | chatModel: string 43 | searchEnabled: boolean 44 | thinkEnabled: boolean 45 | } 46 | 47 | interface ConversationRequest { 48 | conversationId?: string 49 | parentMessageId?: string 50 | } 51 | 52 | interface ConversationResponse { 53 | conversationId: string 54 | detail: { 55 | choices: { finish_reason: string, index: number, logprobs: any, text: string }[] 56 | created: number 57 | id: string 58 | model: string 59 | object: string 60 | usage: { completion_tokens: number, prompt_tokens: number, total_tokens: number } 61 | } 62 | id: string 63 | parentMessageId: string 64 | role: string 65 | text: string 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/views/chat/components/Message/Avatar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | image: chatgptweb/chatgpt-web # 总是使用latest,更新时重新pull该tag镜像即可 6 | container_name: chatgptweb 7 | restart: unless-stopped 8 | ports: 9 | - 3002:3002 10 | depends_on: 11 | - database 12 | environment: 13 | TZ: Asia/Shanghai 14 | # 访问jwt加密参数,可选 不为空则允许登录 同时需要设置 MONGODB_URL 15 | AUTH_SECRET_KEY: 16 | # 每小时最大请求次数,可选,默认无限 17 | MAX_REQUEST_PER_HOUR: 0 18 | # 网站名称 19 | SITE_TITLE: ChatGpt Web 20 | # mongodb 的连接字符串 21 | MONGODB_URL: 'mongodb://chatgpt:xxxx@database:27017' 22 | # 开启注册之后 密码加密的盐 23 | PASSWORD_MD5_SALT: anysalt 24 | # 开启注册之后 超级管理邮箱 25 | ROOT_USER: xxx@qq.com 26 | # 网站是否开启注册 必须开启, 否则管理员都没法注册, 可后续关闭 27 | REGISTER_ENABLED: true 28 | # 更多配置, 在运行后, 注册管理员, 在管理员页面中设置 29 | links: 30 | - database 31 | 32 | database: 33 | image: mongo 34 | ports: 35 | - '27017:27017' 36 | expose: 37 | - '27017' 38 | volumes: 39 | - mongodb:/data/db 40 | environment: 41 | MONGO_INITDB_ROOT_USERNAME: chatgpt 42 | MONGO_INITDB_ROOT_PASSWORD: xxxx 43 | MONGO_INITDB_DATABASE: chatgpt 44 | 45 | mongo-gui: 46 | container_name: mongo-gui 47 | image: ugleiton/mongo-gui 48 | restart: always 49 | ports: 50 | - '4321:4321' 51 | environment: 52 | - MONGO_URL=mongodb://chatgpt:xxxx@database:27017 53 | links: 54 | - database 55 | depends_on: 56 | - database 57 | 58 | nginx: 59 | image: nginx:alpine 60 | container_name: chatgptweb-database 61 | restart: unless-stopped 62 | ports: 63 | - '80:80' 64 | expose: 65 | - '80' 66 | volumes: 67 | - ./nginx/html:/usr/share/nginx/html 68 | - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf 69 | links: 70 | - app 71 | 72 | volumes: 73 | mongodb: {} 74 | -------------------------------------------------------------------------------- /service/src/utils/is.ts: -------------------------------------------------------------------------------- 1 | import type { UserRole } from '../storage/model' 2 | import type { TextAuditServiceProvider } from './textAudit' 3 | import { TextAudioType } from '../storage/model' 4 | 5 | export function isNumber(value: T | unknown): value is number { 6 | return Object.prototype.toString.call(value) === '[object Number]' 7 | } 8 | 9 | export function isString(value: T | unknown): value is string { 10 | return Object.prototype.toString.call(value) === '[object String]' 11 | } 12 | 13 | export function isNotEmptyString(value: any): boolean { 14 | return typeof value === 'string' && value.length > 0 15 | } 16 | 17 | export function isBoolean(value: T | unknown): value is boolean { 18 | return Object.prototype.toString.call(value) === '[object Boolean]' 19 | } 20 | 21 | export function isFunction any | void | never>(value: T | unknown): value is T { 22 | return Object.prototype.toString.call(value) === '[object Function]' 23 | } 24 | 25 | export function isEmail(value: any): boolean { 26 | return isNotEmptyString(value) && /^[\w.%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value) 27 | } 28 | 29 | export function isTextAuditServiceProvider(value: any): value is TextAuditServiceProvider { 30 | return value === 'baidu' // || value === 'ali' 31 | } 32 | 33 | export function isTextAudioType(value: any): value is TextAudioType { 34 | return ( 35 | value === TextAudioType.None 36 | || value === TextAudioType.Request 37 | || value === TextAudioType.Response 38 | || value === TextAudioType.All 39 | ) 40 | } 41 | 42 | export function hasAnyRole(userRoles: UserRole[] | undefined, roles: UserRole[]): boolean { 43 | if (!userRoles || userRoles.length === 0 || !roles || roles.length === 0) 44 | return false 45 | 46 | const roleNames = roles.map(role => role.toString()) 47 | return roleNames.some(roleName => userRoles.some(userRole => userRole.toString() === roleName)) 48 | } 49 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | Loading -ˋˏ✄┈┈┈┈ 12 | 13 | 14 | 15 |
16 | 73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath, URL } from 'node:url' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import AutoImport from 'unplugin-auto-import/vite' 6 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 7 | import Components from 'unplugin-vue-components/vite' 8 | import { defineConfig, loadEnv } from 'vite' 9 | 10 | // https://vite.dev/config/#using-environment-variables-in-config 11 | export default defineConfig(({ mode }) => { 12 | const cwd = fileURLToPath(new URL('./', import.meta.url)) 13 | const viteEnv = loadEnv(mode, cwd) as ImportMetaEnv 14 | 15 | return { 16 | resolve: { 17 | alias: { 18 | '@': path.resolve(cwd, 'src'), 19 | }, 20 | }, 21 | plugins: [ 22 | vue(), 23 | tailwindcss(), 24 | AutoImport({ 25 | imports: [ 26 | 'vue', 27 | 'vue-router', 28 | 'pinia', 29 | 'vue-i18n', 30 | { 31 | 'naive-ui': [ 32 | 'useDialog', 33 | 'useMessage', 34 | 'useNotification', 35 | 'useLoadingBar', 36 | ], 37 | }, 38 | ], 39 | }), 40 | Components({ 41 | dirs: [], 42 | resolvers: [ 43 | NaiveUiResolver(), 44 | ], 45 | }), 46 | ], 47 | server: { 48 | host: '0.0.0.0', 49 | port: 1002, 50 | open: false, 51 | proxy: { 52 | '/api': { 53 | target: viteEnv.VITE_APP_API_BASE_URL, 54 | changeOrigin: true, // 允许跨域 55 | rewrite: path => path.replace('/api/', '/'), 56 | }, 57 | '/uploads': { 58 | target: viteEnv.VITE_APP_API_BASE_URL, 59 | changeOrigin: true, // 允许跨域 60 | }, 61 | }, 62 | }, 63 | build: { 64 | reportCompressedSize: false, 65 | sourcemap: false, 66 | commonjsOptions: { 67 | ignoreTryCatch: false, 68 | }, 69 | }, 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-web-service", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": false, 6 | "packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67", 7 | "description": "ChatGPT Web Service", 8 | "author": "ChatGPT Web Contributors ", 9 | "keywords": [ 10 | "chatgpt-web", 11 | "chatgpt", 12 | "chatbot", 13 | "express", 14 | "mongodb" 15 | ], 16 | "engines": { 17 | "node": "^20 || ^22 || ^24" 18 | }, 19 | "scripts": { 20 | "start": "tsx ./src/index.ts", 21 | "dev": "tsx watch --inspect ./src/index.ts", 22 | "prod": "node --import tsx/esm ./build/index.js", 23 | "type-check": "tsc --noEmit", 24 | "build": "pnpm clean && tsc && pnpm copy", 25 | "copy": "copyfiles -u 1 src/utils/templates/* build", 26 | "clean": "rimraf build", 27 | "lint": "eslint .", 28 | "lint:fix": "eslint . --fix", 29 | "common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml" 30 | }, 31 | "dependencies": { 32 | "@tavily/core": "^0.5.13", 33 | "axios": "1.12.0", 34 | "dayjs": "^1.11.19", 35 | "dotenv": "^17.2.3", 36 | "express": "^5.2.1", 37 | "express-rate-limit": "^8.2.1", 38 | "file-type": "^21.1.1", 39 | "jsonwebtoken": "^9.0.2", 40 | "mongodb": "^7.0.0", 41 | "multer": "^2.0.2", 42 | "nodemailer": "7.0.7", 43 | "openai": "^6.9.1", 44 | "request-ip": "^3.3.0", 45 | "speakeasy": "^2.0.0", 46 | "tsx": "^4.21.0", 47 | "undici": "^7.16.0" 48 | }, 49 | "devDependencies": { 50 | "@antfu/eslint-config": "^6.3.0", 51 | "@types/express": "^5.0.6", 52 | "@types/jsonwebtoken": "^9.0.10", 53 | "@types/multer": "^2.0.0", 54 | "@types/node": "^24.10.1", 55 | "@types/nodemailer": "^7.0.4", 56 | "@types/request-ip": "^0.0.41", 57 | "@types/speakeasy": "^2.0.10", 58 | "copyfiles": "^2.4.1", 59 | "eslint": "^9.39.1", 60 | "jiti": "^2.6.1", 61 | "rimraf": "^6.1.2", 62 | "typescript": "~5.9.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /service/src/middleware/rootAuth.ts: -------------------------------------------------------------------------------- 1 | import type { AuthJwtPayload } from '../types' 2 | import jwt from 'jsonwebtoken' 3 | import { authProxyHeaderName, getCacheConfig } from '../storage/config' 4 | import { Status, UserRole } from '../storage/model' 5 | import { getUser, getUserById } from '../storage/mongo' 6 | 7 | async function rootAuth(req, res, next) { 8 | const config = await getCacheConfig() 9 | 10 | if (config.siteConfig.authProxyEnabled) { 11 | try { 12 | const username = req.header(authProxyHeaderName) 13 | const user = await getUser(username) 14 | req.headers.userId = user._id 15 | if (user == null || user.status !== Status.Normal || !user.roles.includes(UserRole.Admin)) 16 | res.send({ status: 'Fail', message: '无权限 | No permission.', data: null }) 17 | else 18 | next() 19 | } 20 | catch (error) { 21 | res.send({ status: 'Unauthorized', message: error.message ?? `Please config auth proxy (usually is nginx) add set proxy header ${authProxyHeaderName}.`, data: null }) 22 | } 23 | return 24 | } 25 | 26 | if (config.siteConfig.loginEnabled) { 27 | try { 28 | const token = req.header('Authorization').replace('Bearer ', '') 29 | const info = jwt.verify(token, config.siteConfig.loginSalt.trim()) as AuthJwtPayload 30 | req.headers.userId = info.userId 31 | const user = await getUserById(info.userId) 32 | if (user == null || user.status !== Status.Normal || !user.roles.includes(UserRole.Admin)) 33 | res.send({ status: 'Fail', message: '无权限 | No permission.', data: null }) 34 | else 35 | next() 36 | } 37 | catch (error) { 38 | res.send({ status: 'Unauthorized', message: error.message ?? 'Please authenticate.', data: null }) 39 | } 40 | } 41 | else { 42 | res.send({ status: 'Fail', message: '无权限 | No permission.', data: null }) 43 | } 44 | } 45 | 46 | async function isAdmin(userId: string) { 47 | const user = await getUserById(userId) 48 | return user != null && user.status === Status.Normal && user.roles.includes(UserRole.Admin) 49 | } 50 | 51 | export { isAdmin, rootAuth } 52 | -------------------------------------------------------------------------------- /src/store/modules/user/helper.ts: -------------------------------------------------------------------------------- 1 | import type { UserRole } from '@/components/common/Setting/model' 2 | import { UserConfig } from '@/components/common/Setting/model' 3 | import { ss } from '@/utils/storage' 4 | 5 | const LOCAL_NAME = 'userStorage' 6 | 7 | export interface UserInfo { 8 | avatar: string 9 | name: string 10 | description: string 11 | root: boolean 12 | config: UserConfig 13 | roles: UserRole[] 14 | advanced: SettingsState 15 | limit?: boolean 16 | useAmount?: number // chat usage amount 17 | redeemCardNo?: string // add giftcard info 18 | } 19 | 20 | export interface UserState { 21 | userInfo: UserInfo 22 | } 23 | 24 | export interface SettingsState { 25 | systemMessage: string 26 | temperature: number 27 | top_p: number 28 | } 29 | 30 | export function defaultSetting(): UserState { 31 | return { 32 | userInfo: { 33 | avatar: '', 34 | name: '', 35 | description: '', 36 | root: false, 37 | config: { chatModel: '' }, 38 | roles: [], 39 | advanced: { 40 | systemMessage: 'You are a large language model. Follow the user\'s instructions carefully. Respond using markdown (latex start with $).', 41 | temperature: 0.8, 42 | top_p: 1, 43 | }, 44 | useAmount: 1, // chat usage amount 45 | }, 46 | } 47 | } 48 | 49 | export function getLocalState(): UserState { 50 | const localSetting: UserState | undefined = ss.get(LOCAL_NAME) 51 | if (localSetting != null && localSetting.userInfo != null) { 52 | if (localSetting.userInfo.config == null) { 53 | localSetting.userInfo.config = new UserConfig() 54 | localSetting.userInfo.config.chatModel = '' 55 | } 56 | if (!localSetting.userInfo.advanced) { 57 | localSetting.userInfo.advanced = { 58 | systemMessage: 'You are a large language model. Follow the user\'s instructions carefully. Respond using markdown (latex start with $).', 59 | temperature: 0.8, 60 | top_p: 1, 61 | } 62 | } 63 | } 64 | return { ...defaultSetting(), ...localSetting } 65 | } 66 | 67 | export function setLocalState(setting: UserState): void { 68 | ss.set(LOCAL_NAME, setting) 69 | } 70 | -------------------------------------------------------------------------------- /src/components/common/Setting/Advanced.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 60 | -------------------------------------------------------------------------------- /src/components/common/Setting/Prompt.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 74 | -------------------------------------------------------------------------------- /service/src/middleware/limiter.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from 'express' 2 | import type { Options } from 'express-rate-limit' 3 | import * as process from 'node:process' 4 | import { rateLimit } from 'express-rate-limit' 5 | import requestIp from 'request-ip' 6 | import { isNotEmptyString } from '../utils/is' 7 | 8 | const MAX_REQUEST_PER_HOUR = process.env.MAX_REQUEST_PER_HOUR 9 | const AUTH_MAX_REQUEST_PER_MINUTE = process.env.AUTH_MAX_REQUEST_PER_MINUTE 10 | 11 | function parsePositiveInt(value?: string | null): number | null { 12 | if (!isNotEmptyString(value)) { 13 | return null 14 | } 15 | 16 | const parsedValue = Number.parseInt(value, 10) 17 | 18 | return Number.isNaN(parsedValue) || parsedValue <= 0 ? null : parsedValue 19 | } 20 | 21 | const noopLimiter: RequestHandler = (_req, _res, next) => { 22 | next() 23 | } 24 | 25 | type LimiterOptions = Partial> 26 | 27 | function buildLimiter( 28 | count: number | null, 29 | options: LimiterOptions, 30 | ): RequestHandler { 31 | if (!count) { 32 | return noopLimiter 33 | } 34 | 35 | return rateLimit({ 36 | ...options, 37 | limit: count, 38 | }) 39 | } 40 | 41 | const limiter = buildLimiter(parsePositiveInt(MAX_REQUEST_PER_HOUR), { 42 | windowMs: 60 * 60 * 1000, // Maximum number of accesses within an hour 43 | statusCode: 200, // 200 means success,but the message is 'Too many request from this IP in 1 hour' 44 | keyGenerator: (req, _) => { 45 | return requestIp.getClientIp(req) // IP address from requestIp.mw(), as opposed to req.ip 46 | }, 47 | message: async (req, res) => { 48 | res.send({ status: 'Fail', message: 'Too many request from this IP in 1 hour', data: null }) 49 | }, 50 | }) 51 | 52 | const authLimiter = buildLimiter(parsePositiveInt(AUTH_MAX_REQUEST_PER_MINUTE), { 53 | windowMs: 60 * 1000, // Maximum number of accesses within a minute 54 | statusCode: 200, // 200 means success,but the message is 'Too many request from this IP in 1 minute' 55 | keyGenerator: (req, _) => { 56 | return requestIp.getClientIp(req) // IP address from requestIp.mw(), as opposed to req.ip 57 | }, 58 | message: async (req, res) => { 59 | res.send({ status: 'Fail', message: 'About Auth limiter, Too many request from this IP in 1 minute', data: null }) 60 | }, 61 | }) 62 | 63 | export { authLimiter, limiter } 64 | -------------------------------------------------------------------------------- /src/components/common/Setting/Anonuncement.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 76 | -------------------------------------------------------------------------------- /src/utils/is/index.ts: -------------------------------------------------------------------------------- 1 | export function isNumber(value: T | unknown): value is number { 2 | return Object.prototype.toString.call(value) === '[object Number]' 3 | } 4 | 5 | export function isString(value: T | unknown): value is string { 6 | return Object.prototype.toString.call(value) === '[object String]' 7 | } 8 | 9 | export function isBoolean(value: T | unknown): value is boolean { 10 | return Object.prototype.toString.call(value) === '[object Boolean]' 11 | } 12 | 13 | export function isNull(value: T | unknown): value is null { 14 | return Object.prototype.toString.call(value) === '[object Null]' 15 | } 16 | 17 | export function isUndefined(value: T | unknown): value is undefined { 18 | return Object.prototype.toString.call(value) === '[object Undefined]' 19 | } 20 | 21 | export function isObject(value: T | unknown): value is object { 22 | return Object.prototype.toString.call(value) === '[object Object]' 23 | } 24 | 25 | export function isArray(value: T | unknown): value is T { 26 | return Object.prototype.toString.call(value) === '[object Array]' 27 | } 28 | 29 | export function isFunction any | void | never>(value: T | unknown): value is T { 30 | return Object.prototype.toString.call(value) === '[object Function]' 31 | } 32 | 33 | export function isDate(value: T | unknown): value is T { 34 | return Object.prototype.toString.call(value) === '[object Date]' 35 | } 36 | 37 | export function isRegExp(value: T | unknown): value is T { 38 | return Object.prototype.toString.call(value) === '[object RegExp]' 39 | } 40 | 41 | export function isPromise>(value: T | unknown): value is T { 42 | return Object.prototype.toString.call(value) === '[object Promise]' 43 | } 44 | 45 | export function isSet>(value: T | unknown): value is T { 46 | return Object.prototype.toString.call(value) === '[object Set]' 47 | } 48 | 49 | export function isMap>(value: T | unknown): value is T { 50 | return Object.prototype.toString.call(value) === '[object Map]' 51 | } 52 | 53 | export function isFile(value: T | unknown): value is T { 54 | return Object.prototype.toString.call(value) === '[object File]' 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/request/index.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios' 2 | import { useAuthStore } from '@/store' 3 | import request from './axios' 4 | 5 | export interface HttpOption { 6 | url: string 7 | data?: any 8 | method?: string 9 | headers?: any 10 | onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void 11 | signal?: GenericAbortSignal 12 | beforeRequest?: () => void 13 | afterRequest?: () => void 14 | } 15 | 16 | export interface Response { 17 | data: T 18 | message: string | null 19 | status: string 20 | } 21 | 22 | function http( 23 | { url, data, method, headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, 24 | ) { 25 | const successHandler = (res: AxiosResponse>) => { 26 | const authStore = useAuthStore() 27 | 28 | if (res.data.status === 'Success' || typeof res.data === 'string') 29 | return res.data 30 | 31 | if (res.data.status === 'Unauthorized') 32 | authStore.removeToken() 33 | 34 | return Promise.reject(res.data) 35 | } 36 | 37 | const failHandler = (error: Response) => { 38 | afterRequest?.() 39 | throw new Error(error?.message || 'Error') 40 | } 41 | 42 | beforeRequest?.() 43 | 44 | method = method || 'GET' 45 | 46 | const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {}) 47 | 48 | return method === 'GET' 49 | ? request.get(url, { params, signal, onDownloadProgress }).then(successHandler, failHandler) 50 | : request.post(url, params, { headers, signal, onDownloadProgress }).then(successHandler, failHandler) 51 | } 52 | 53 | export function get( 54 | { url, data, method = 'GET', onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, 55 | ): Promise> { 56 | return http({ 57 | url, 58 | method, 59 | data, 60 | onDownloadProgress, 61 | signal, 62 | beforeRequest, 63 | afterRequest, 64 | }) 65 | } 66 | 67 | export function post( 68 | { url, data, method = 'POST', headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption, 69 | ): Promise> { 70 | return http({ 71 | url, 72 | method, 73 | data, 74 | headers, 75 | onDownloadProgress, 76 | signal, 77 | beforeRequest, 78 | afterRequest, 79 | }) 80 | } 81 | 82 | export default post 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-web", 3 | "type": "module", 4 | "version": "2.13.3", 5 | "private": false, 6 | "description": "ChatGPT Web", 7 | "author": "ChatGPT Web Contributors ", 8 | "keywords": [ 9 | "chatgpt-web", 10 | "chatgpt", 11 | "chatbot", 12 | "vue" 13 | ], 14 | "scripts": { 15 | "dev": "vite", 16 | "build": "pnpm run type-check && pnpm run build-only", 17 | "preview": "vite preview", 18 | "build-only": "vite build", 19 | "type-check": "vue-tsc --noEmit", 20 | "lint": "eslint .", 21 | "lint:fix": "eslint . --fix", 22 | "bootstrap": "pnpm install && pnpm run common:prepare", 23 | "common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml", 24 | "common:prepare": "husky install" 25 | }, 26 | "devDependencies": { 27 | "@antfu/eslint-config": "^6.3.0", 28 | "@commitlint/cli": "^20.1.0", 29 | "@commitlint/config-conventional": "^20.0.0", 30 | "@iconify/vue": "^5.0.0", 31 | "@tailwindcss/vite": "^4.1.17", 32 | "@types/katex": "^0.16.7", 33 | "@types/markdown-it": "^14.1.2", 34 | "@types/markdown-it-link-attributes": "^3.0.5", 35 | "@types/node": "^24.10.1", 36 | "@vitejs/plugin-vue": "^6.0.2", 37 | "@vscode/markdown-it-katex": "^1.1.2", 38 | "@vueuse/core": "^14.1.0", 39 | "axios": "^1.13.2", 40 | "chart.js": "^4.5.1", 41 | "dayjs": "^1.11.19", 42 | "eslint": "^9.39.1", 43 | "highlight.js": "^11.11.1", 44 | "html2canvas": "^1.4.1", 45 | "husky": "^9.1.7", 46 | "jwt-decode": "^4.0.0", 47 | "katex": "0.16.25", 48 | "less": "^4.4.2", 49 | "lint-staged": "^16.2.7", 50 | "markdown-it": "^14.1.0", 51 | "markdown-it-link-attributes": "^4.0.1", 52 | "naive-ui": "^2.43.2", 53 | "pinia": "^3.0.4", 54 | "qrcode.vue": "^3.6.0", 55 | "rimraf": "^6.1.2", 56 | "tailwindcss": "^4.1.17", 57 | "typescript": "~5.9.3", 58 | "unplugin-auto-import": "^20.3.0", 59 | "unplugin-vue-components": "^30.0.0", 60 | "vite": "^7.2.6", 61 | "vue": "^3.5.25", 62 | "vue-chartjs": "^5.3.3", 63 | "vue-i18n": "^11.2.2", 64 | "vue-router": "^4.6.3", 65 | "vue-tsc": "^3.1.5" 66 | }, 67 | "lint-staged": { 68 | "*.{ts,tsx,vue}": [ 69 | "pnpm lint:fix" 70 | ] 71 | }, 72 | "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a" 73 | } 74 | -------------------------------------------------------------------------------- /service/src/utils/security.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto' 2 | import * as process from 'node:process' 3 | import { getCacheConfig } from '../storage/config' 4 | 5 | export function md5(input: string) { 6 | input = input + process.env.PASSWORD_MD5_SALT 7 | const md5 = createHash('md5') 8 | md5.update(input) 9 | return md5.digest('hex') 10 | } 11 | 12 | // 可以换 aes 等方式 13 | export async function getUserVerifyUrl(username: string) { 14 | const sign = getUserVerify(username) 15 | const config = await getCacheConfig() 16 | return `${config.siteConfig.siteDomain}/#/chat/?verifytoken=${sign}` 17 | } 18 | 19 | function getUserVerify(username: string) { 20 | return getVerify(username, '') 21 | } 22 | function getVerify(username: string, key: string) { 23 | const expired = new Date().getTime() + (12 * 60 * 60 * 1000) 24 | const sign = `${username}${key}-${expired}` 25 | return `${sign}-${md5(sign)}` 26 | } 27 | 28 | function checkVerify(verify: string) { 29 | const vs = verify.split('-') 30 | const sign = vs[vs.length - 1] 31 | const expired = vs[vs.length - 2] 32 | vs.splice(vs.length - 2, 2) 33 | const prefix = vs.join('-') 34 | // 简单点没校验有效期 35 | if (sign === md5(`${prefix}-${expired}`)) 36 | return prefix.split('|')[0] 37 | throw new Error('Verify failed') 38 | } 39 | 40 | export function checkUserVerify(verify: string) { 41 | return checkVerify(verify) 42 | } 43 | 44 | // 可以换 aes 等方式 45 | export async function getUserVerifyUrlAdmin(username: string) { 46 | const sign = getUserVerifyAdmin(username) 47 | const config = await getCacheConfig() 48 | return `${config.siteConfig.siteDomain}/#/chat/?verifytokenadmin=${sign}` 49 | } 50 | 51 | function getUserVerifyAdmin(username: string) { 52 | return getVerify(username, `|${process.env.ROOT_USER}`) 53 | } 54 | 55 | export function checkUserVerifyAdmin(verify: string) { 56 | return checkVerify(verify) 57 | } 58 | 59 | export async function getUserResetPasswordUrl(username: string) { 60 | const sign = getUserResetPassword(username) 61 | const config = await getCacheConfig() 62 | return `${config.siteConfig.siteDomain}/#/chat/?verifyresetpassword=${sign}` 63 | } 64 | 65 | function getUserResetPassword(username: string) { 66 | return getVerify(username, '|rp') 67 | } 68 | 69 | export function checkUserResetPassword(verify: string, username: string) { 70 | const name = checkVerify(verify) 71 | if (name === username) 72 | return name 73 | throw new Error('Verify failed') 74 | } 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.en.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | Thank you for your valuable time. Your contributions will make this project better! Before submitting a contribution, please take some time to read the getting started guide below. 3 | 4 | ## Semantic Versioning 5 | This project follows semantic versioning. We release patch versions for important bug fixes, minor versions for new features or non-important changes, and major versions for significant and incompatible changes. 6 | 7 | Each major change will be recorded in the `changelog`. 8 | 9 | ## Submitting Pull Request 10 | 1. Fork [this repository](https://github.com/chatgpt-web-dev/chatgpt-web) and create a branch from `main`. For new feature implementations, submit a pull request to the `feature` branch. For other changes, submit to the `main` branch. 11 | 2. Install the `pnpm` tool using `npm install pnpm -g`. 12 | 3. Install the `Eslint` plugin for `VSCode`, or enable `eslint` functionality for other editors such as `WebStorm`. 13 | 4. Execute `pnpm bootstrap` in the root directory. 14 | 5. Execute `pnpm install` in the `/service/` directory. 15 | 6. Make changes to the codebase. If applicable, ensure that appropriate testing has been done. 16 | 7. Execute `pnpm lint:fix` in the root directory to perform a code formatting check. 17 | 8. Execute `pnpm type-check` in the root directory to perform a type check. 18 | 9. Submit a git commit, following the [Commit Guidelines](#commit-guidelines). 19 | 10. Submit a `pull request`. If there is a corresponding `issue`, please link it using the [linking-a-pull-request-to-an-issue keyword](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword). 20 | 21 | ## Commit Guidelines 22 | 23 | Commit messages should follow the [conventional-changelog standard](https://www.conventionalcommits.org/en/v1.0.0/): 24 | 25 | ```bash 26 | [optional scope]: 27 | 28 | [optional body] 29 | 30 | [optional footer] 31 | ``` 32 | 33 | ### Commit Types 34 | 35 | The following is a list of commit types: 36 | 37 | - feat: New feature or functionality 38 | - fix: Bug fix 39 | - docs: Documentation update 40 | - style: Code style or component style update 41 | - refactor: Code refactoring, no new features or bug fixes introduced 42 | - perf: Performance optimization 43 | - test: Unit test 44 | - chore: Other commits that do not modify src or test files 45 | 46 | 47 | ## License 48 | 49 | [MIT © github.com/chatgpt-web-dev Contributors](./LICENSE) 50 | -------------------------------------------------------------------------------- /src/store/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | import type { UserInfo } from '../user/helper' 2 | import { jwtDecode } from 'jwt-decode' 3 | import { fetchLogout, fetchSession } from '@/api' 4 | import { UserConfig } from '@/components/common/Setting/model' 5 | import { store } from '@/store/helper' 6 | import { useChatStore } from '../chat' 7 | import { useUserStore } from '../user' 8 | import { getToken, removeToken, setToken } from './helper' 9 | 10 | interface SessionResponse { 11 | auth: boolean 12 | authProxyEnabled: boolean 13 | allowRegister: boolean 14 | title: string 15 | chatModels: { 16 | label: string 17 | key: string 18 | value: string 19 | }[] 20 | allChatModels: { 21 | label: string 22 | key: string 23 | value: string 24 | }[] 25 | usageCountLimit: boolean 26 | showWatermark: boolean 27 | adminViewChatHistoryEnabled?: boolean 28 | userInfo: { name: string, description: string, avatar: string, userId: string, root: boolean, config: UserConfig } 29 | } 30 | 31 | export interface AuthState { 32 | token: string | undefined 33 | session: SessionResponse | null 34 | } 35 | 36 | export const useAuthStore = defineStore('auth-store', { 37 | state: (): AuthState => ({ 38 | token: getToken(), 39 | session: null, 40 | }), 41 | 42 | actions: { 43 | async getSession() { 44 | try { 45 | const { data } = await fetchSession() 46 | this.session = { ...data } 47 | return Promise.resolve(data) 48 | } 49 | catch (error) { 50 | return Promise.reject(error) 51 | } 52 | }, 53 | 54 | async setToken(token: string) { 55 | this.token = token 56 | const decoded = jwtDecode(token) as UserInfo 57 | const userStore = useUserStore() 58 | if (decoded.config === undefined || decoded.config === null) 59 | decoded.config = new UserConfig() 60 | 61 | await userStore.updateUserInfo(false, { 62 | avatar: decoded.avatar, 63 | name: decoded.name, 64 | description: decoded.description, 65 | root: decoded.root, 66 | config: decoded.config, 67 | }) 68 | setToken(token) 69 | }, 70 | 71 | async removeToken() { 72 | this.token = undefined 73 | const userStore = useUserStore() 74 | userStore.resetUserInfo() 75 | const chatStore = useChatStore() 76 | await chatStore.clearLocalChat() 77 | removeToken() 78 | await fetchLogout() 79 | }, 80 | }, 81 | }) 82 | 83 | export function useAuthStoreWithout() { 84 | return useAuthStore(store) 85 | } 86 | -------------------------------------------------------------------------------- /src/components/common/UserAvatar/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 70 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // biome-ignore lint: disable 4 | // oxlint-disable 5 | // ------ 6 | // Generated by unplugin-vue-components 7 | // Read more: https://github.com/vuejs/core/pull/3399 8 | 9 | export {} 10 | 11 | /* prettier-ignore */ 12 | declare module 'vue' { 13 | export interface GlobalComponents { 14 | NAutoComplete: typeof import('naive-ui')['NAutoComplete'] 15 | NAvatar: typeof import('naive-ui')['NAvatar'] 16 | NButton: typeof import('naive-ui')['NButton'] 17 | NButtonGroup: typeof import('naive-ui')['NButtonGroup'] 18 | NCard: typeof import('naive-ui')['NCard'] 19 | NCol: typeof import('naive-ui')['NCol'] 20 | NDataTable: typeof import('naive-ui')['NDataTable'] 21 | NDatePicker: typeof import('naive-ui')['NDatePicker'] 22 | NDivider: typeof import('naive-ui')['NDivider'] 23 | NDropdown: typeof import('naive-ui')['NDropdown'] 24 | NEllipsis: typeof import('naive-ui')['NEllipsis'] 25 | NIcon: typeof import('naive-ui')['NIcon'] 26 | NInput: typeof import('naive-ui')['NInput'] 27 | NInputNumber: typeof import('naive-ui')['NInputNumber'] 28 | NLayout: typeof import('naive-ui')['NLayout'] 29 | NLayoutContent: typeof import('naive-ui')['NLayoutContent'] 30 | NLayoutSider: typeof import('naive-ui')['NLayoutSider'] 31 | NList: typeof import('naive-ui')['NList'] 32 | NListItem: typeof import('naive-ui')['NListItem'] 33 | NModal: typeof import('naive-ui')['NModal'] 34 | NNumberAnimation: typeof import('naive-ui')['NNumberAnimation'] 35 | NP: typeof import('naive-ui')['NP'] 36 | NPopconfirm: typeof import('naive-ui')['NPopconfirm'] 37 | NPopover: typeof import('naive-ui')['NPopover'] 38 | NRow: typeof import('naive-ui')['NRow'] 39 | NScrollbar: typeof import('naive-ui')['NScrollbar'] 40 | NSelect: typeof import('naive-ui')['NSelect'] 41 | NSlider: typeof import('naive-ui')['NSlider'] 42 | NSpace: typeof import('naive-ui')['NSpace'] 43 | NSpin: typeof import('naive-ui')['NSpin'] 44 | NStatistic: typeof import('naive-ui')['NStatistic'] 45 | NStep: typeof import('naive-ui')['NStep'] 46 | NSteps: typeof import('naive-ui')['NSteps'] 47 | NSwitch: typeof import('naive-ui')['NSwitch'] 48 | NTabPane: typeof import('naive-ui')['NTabPane'] 49 | NTabs: typeof import('naive-ui')['NTabs'] 50 | NTag: typeof import('naive-ui')['NTag'] 51 | NText: typeof import('naive-ui')['NText'] 52 | NThing: typeof import('naive-ui')['NThing'] 53 | NTooltip: typeof import('naive-ui')['NTooltip'] 54 | NUpload: typeof import('naive-ui')['NUpload'] 55 | NUploadDragger: typeof import('naive-ui')['NUploadDragger'] 56 | NWatermark: typeof import('naive-ui')['NWatermark'] 57 | RouterLink: typeof import('vue-router')['RouterLink'] 58 | RouterView: typeof import('vue-router')['RouterView'] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /service/src/utils/textAudit.ts: -------------------------------------------------------------------------------- 1 | export interface TextAuditServiceOptions { 2 | apiKey: string 3 | apiSecret: string 4 | label: string 5 | } 6 | 7 | export interface TextAuditService { 8 | containsSensitiveWords: (text: string) => Promise 9 | } 10 | 11 | /** 12 | * https://ai.baidu.com/ai-doc/ANTIPORN/Vk3h6xaga 13 | */ 14 | export class BaiduTextAuditService implements TextAuditService { 15 | private accessToken: string 16 | private expiredTime: number 17 | 18 | constructor(private options: TextAuditServiceOptions) { } 19 | 20 | async containsSensitiveWords(text: string): Promise { 21 | if (!await this.refreshAccessToken()) 22 | throw new Error('Access Token Error') 23 | const url = `https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token=${this.accessToken}` 24 | let headers: { 25 | 'Content-Type': 'application/x-www-form-urlencoded' 26 | 'Accept': 'application/json' 27 | } 28 | const response = await fetch(url, { headers, method: 'POST', body: `text=${encodeURIComponent(text)}` }) 29 | const data = await response.json() as { conclusionType: number, data: any, error_msg: string } 30 | 31 | if (data.error_msg) 32 | throw new Error(data.error_msg) 33 | 34 | // 审核结果类型,可取值1、2、3、4,分别代表1:合规,2:不合规,3:疑似,4:审核失败 35 | if (data.conclusionType === 1) 36 | return false 37 | 38 | // https://ai.baidu.com/ai-doc/ANTIPORN/Nk3h6xbb2#%E7%BB%86%E5%88%86%E6%A0%87%E7%AD%BE%E5%AF%B9%E7%85%A7%E8%A1%A8 39 | 40 | // 3 仅政治 41 | const sensitive = data.data.filter(d => d.subType === 3).length > 0 42 | if (sensitive || !this.options.label) 43 | return sensitive 44 | const str = JSON.stringify(data) 45 | for (const l of this.options.label.split(',')) { 46 | if (str.includes(l)) 47 | return true 48 | } 49 | return false 50 | } 51 | 52 | async refreshAccessToken() { 53 | if (!this.options.apiKey || !this.options.apiSecret) 54 | throw new Error('未配置 | Not configured.') 55 | 56 | try { 57 | if (this.accessToken && Math.floor(new Date().getTime() / 1000) <= this.expiredTime) 58 | return true 59 | 60 | const url = `https://aip.baidubce.com/oauth/2.0/token?client_id=${this.options.apiKey}&client_secret=${this.options.apiSecret}&grant_type=client_credentials` 61 | let headers: { 62 | 'Content-Type': 'application/json' 63 | 'Accept': 'application/json' 64 | } 65 | const response = await fetch(url, { headers }) 66 | const data = (await response.json()) as { access_token: string, expires_in: number } 67 | 68 | this.accessToken = data.access_token 69 | this.expiredTime = Math.floor(new Date().getTime() / 1000) + (+data.expires_in) 70 | return true 71 | } 72 | catch (error) { 73 | globalThis.console.error(`百度审核${error}`) 74 | } 75 | return false 76 | } 77 | } 78 | 79 | export type TextAuditServiceProvider = 'baidu' // | 'ali' 80 | 81 | export type TextAuditServices = { 82 | [key in TextAuditServiceProvider]: new ( 83 | options: TextAuditServiceOptions, 84 | ) => TextAuditService; 85 | } 86 | 87 | export const textAuditServices: TextAuditServices = { 88 | baidu: BaiduTextAuditService, 89 | } 90 | -------------------------------------------------------------------------------- /service/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express' 2 | import type { AuthJwtPayload } from '../types' 3 | import * as process from 'node:process' 4 | import jwt from 'jsonwebtoken' 5 | import { authProxyHeaderName, getCacheConfig } from '../storage/config' 6 | import { Status, UserRole } from '../storage/model' 7 | import { createUser, getUser, getUserById } from '../storage/mongo' 8 | 9 | async function auth(req, res, next) { 10 | const config = await getCacheConfig() 11 | 12 | if (config.siteConfig.authProxyEnabled) { 13 | try { 14 | const username = req.header(authProxyHeaderName) 15 | if (!username) { 16 | res.send({ status: 'Unauthorized', message: `Please config auth proxy (usually is nginx) add set proxy header ${authProxyHeaderName}.`, data: null }) 17 | return 18 | } 19 | const user = await getUser(username) 20 | req.headers.userId = user._id.toString() 21 | next() 22 | } 23 | catch (error) { 24 | res.send({ status: 'Unauthorized', message: error.message ?? `Please config auth proxy (usually is nginx) add set proxy header ${authProxyHeaderName}.`, data: null }) 25 | } 26 | return 27 | } 28 | 29 | if (config.siteConfig.loginEnabled) { 30 | try { 31 | const token = req.header('Authorization').replace('Bearer ', '') 32 | const info = jwt.verify(token, config.siteConfig.loginSalt.trim()) as AuthJwtPayload 33 | req.headers.userId = info.userId 34 | const user = await getUserById(info.userId) 35 | if (user == null || user.status !== Status.Normal) 36 | throw new Error('用户不存在 | User does not exist.') 37 | else 38 | next() 39 | } 40 | catch (error) { 41 | res.send({ status: 'Unauthorized', message: error.message ?? 'Please authenticate.', data: null }) 42 | } 43 | } 44 | else { 45 | // fake userid 46 | req.headers.userId = '6406d8c50aedd633885fa16f' 47 | next() 48 | } 49 | } 50 | 51 | async function getUserId(req: Request): Promise { 52 | let token: string 53 | try { 54 | const config = await getCacheConfig() 55 | if (config.siteConfig.authProxyEnabled) { 56 | const username = req.header(authProxyHeaderName) 57 | if (!username) { 58 | globalThis.console.error(`Please config auth proxy (usually is nginx) add set proxy header ${authProxyHeaderName}.`) 59 | return null 60 | } 61 | let user = await getUser(username) 62 | if (user == null) { 63 | const isRoot = username.toLowerCase() === process.env.ROOT_USER 64 | user = await createUser(username, '', isRoot ? [UserRole.Admin] : [UserRole.User], Status.Normal, 'Created by auth proxy.') 65 | } 66 | return user._id.toString() 67 | } 68 | 69 | // no Authorization info is received without login 70 | if (!(req.header('Authorization') as string)) 71 | return null // '6406d8c50aedd633885fa16f' 72 | token = req.header('Authorization').replace('Bearer ', '') 73 | 74 | const info = jwt.verify(token, config.siteConfig.loginSalt.trim()) as AuthJwtPayload 75 | return info.userId 76 | } 77 | catch (error) { 78 | globalThis.console.error(`auth middleware getUserId err from token ${token} `, error.message) 79 | } 80 | return null 81 | } 82 | 83 | export { auth, getUserId } 84 | -------------------------------------------------------------------------------- /src/components/common/Setting/Password.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 90 | -------------------------------------------------------------------------------- /service/src/utils/templates/mail.notice.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 |
15 | 16 |
17 |
30 |
31 |
41 | ${SITE_TITLE} 账号开通 42 |
43 | 44 | 45 | 46 |
55 |
66 |
67 | 感谢您使用 ${SITE_TITLE}, 您的邮箱账号已开通 68 |
69 | 70 |
71 | 72 |
73 |
请点击以下按钮进行登陆
74 | 75 | 95 |
96 | 97 |
98 | 99 |
100 |
或者复制链接,并去浏览器打开
101 |
102 | ${SITE_DOMAIN} 111 |
112 |
113 |
114 |
115 | 116 | 117 | 118 | 121 | 122 |
123 | 124 | 125 | -------------------------------------------------------------------------------- /service/src/utils/templates/mail.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 |
15 | 16 |
17 |
30 |
31 |
41 | ${SITE_TITLE} 账号验证 42 |
43 | 44 | 45 | 46 |
55 |
66 |
67 | 感谢您使用 ${SITE_TITLE}, 这是您的邮箱验证链接 (12小时内有效) 68 |
69 | 70 |
71 | 72 |
73 |
请点击以下按钮进行验证
74 | 75 | 95 |
96 | 97 |
98 | 99 |
100 |
或者复制链接,并去浏览器打开
101 |
102 | ${VERIFY_URL} 111 |
112 |
113 |
114 |
115 | 116 | 117 | 118 | 121 | 122 |
123 | 124 | 125 | -------------------------------------------------------------------------------- /service/src/utils/templates/mail.admin.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 |
15 | 16 |
17 |
30 |
31 |
41 | ${SITE_TITLE} 账号申请 42 |
43 | 44 | 45 | 46 |
55 |
66 |
67 | 账号申请邮箱:${TO_MAIL},这是您的账号开通链接 (12小时内有效) 68 |
69 | 70 |
71 | 72 |
73 |
请点击以下按钮进行开通
74 | 75 | 95 |
96 | 97 |
98 | 99 |
100 |
或者复制链接,并去浏览器打开
101 |
102 | ${VERIFY_URL} 111 |
112 |
113 |
114 |
115 | 116 | 117 | 118 | 121 | 122 |
123 | 124 | 125 | -------------------------------------------------------------------------------- /service/src/utils/templates/mail.resetpassword.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 |
15 | 16 |
17 |
30 |
31 |
41 | ${SITE_TITLE} 重置密码 42 |
43 | 44 | 45 | 46 |
55 |
66 |
67 | 感谢您使用 ${SITE_TITLE}, 这是您的重置密码链接 (12小时内有效) 68 |
69 | 70 |
71 | 72 |
73 |
请点击以下按钮进行重置密码
74 | 75 | 95 |
96 | 97 |
98 | 99 |
100 |
或者复制链接,并去浏览器打开
101 |
102 | ${VERIFY_URL} 111 |
112 |
113 |
114 |
115 | 116 | 117 | 118 | 121 | 122 |
123 | 124 | 125 | -------------------------------------------------------------------------------- /src/views/chat/components/Message/style.less: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | background-color: transparent; 3 | font-size: 14px; 4 | 5 | p { 6 | white-space: pre-wrap; 7 | } 8 | 9 | ol { 10 | list-style-type: decimal; 11 | } 12 | 13 | ul { 14 | list-style-type: disc; 15 | } 16 | 17 | pre code, 18 | pre tt { 19 | line-height: 1.65; 20 | } 21 | 22 | .highlight pre, 23 | pre { 24 | background-color: #fff; 25 | } 26 | 27 | code.hljs { 28 | padding: 0; 29 | } 30 | 31 | .code-block { 32 | &-wrapper { 33 | position: relative; 34 | padding-top: 24px; 35 | } 36 | 37 | &-header { 38 | position: absolute; 39 | top: 5px; 40 | right: 0; 41 | width: 100%; 42 | padding: 0 1rem; 43 | display: flex; 44 | justify-content: flex-end; 45 | align-items: center; 46 | color: #b3b3b3; 47 | 48 | &__copy { 49 | cursor: pointer; 50 | margin-left: 0.5rem; 51 | user-select: none; 52 | 53 | &:hover { 54 | color: #65a665; 55 | } 56 | } 57 | } 58 | } 59 | 60 | 61 | &.markdown-body-generate>dd:last-child:after, 62 | &.markdown-body-generate>dl:last-child:after, 63 | &.markdown-body-generate>dt:last-child:after, 64 | &.markdown-body-generate>h1:last-child:after, 65 | &.markdown-body-generate>h2:last-child:after, 66 | &.markdown-body-generate>h3:last-child:after, 67 | &.markdown-body-generate>h4:last-child:after, 68 | &.markdown-body-generate>h5:last-child:after, 69 | &.markdown-body-generate>h6:last-child:after, 70 | &.markdown-body-generate>li:last-child:after, 71 | &.markdown-body-generate>ol:last-child li:last-child:after, 72 | &.markdown-body-generate>p:last-child:after, 73 | &.markdown-body-generate>pre:last-child code:after, 74 | &.markdown-body-generate>td:last-child:after, 75 | &.markdown-body-generate>ul:last-child li:last-child:after { 76 | animation: blink 1s steps(5, start) infinite; 77 | color: #000; 78 | content: '_'; 79 | font-weight: 700; 80 | margin-left: 3px; 81 | vertical-align: baseline; 82 | } 83 | 84 | @keyframes blink { 85 | to { 86 | visibility: hidden; 87 | } 88 | } 89 | } 90 | 91 | html.dark { 92 | 93 | .markdown-body { 94 | 95 | &.markdown-body-generate>dd:last-child:after, 96 | &.markdown-body-generate>dl:last-child:after, 97 | &.markdown-body-generate>dt:last-child:after, 98 | &.markdown-body-generate>h1:last-child:after, 99 | &.markdown-body-generate>h2:last-child:after, 100 | &.markdown-body-generate>h3:last-child:after, 101 | &.markdown-body-generate>h4:last-child:after, 102 | &.markdown-body-generate>h5:last-child:after, 103 | &.markdown-body-generate>h6:last-child:after, 104 | &.markdown-body-generate>li:last-child:after, 105 | &.markdown-body-generate>ol:last-child li:last-child:after, 106 | &.markdown-body-generate>p:last-child:after, 107 | &.markdown-body-generate>pre:last-child code:after, 108 | &.markdown-body-generate>td:last-child:after, 109 | &.markdown-body-generate>ul:last-child li:last-child:after { 110 | color: #65a665; 111 | } 112 | } 113 | 114 | .message-reply { 115 | .whitespace-pre-wrap { 116 | white-space: pre-wrap; 117 | color: var(--n-text-color); 118 | } 119 | } 120 | 121 | .highlight pre, 122 | pre { 123 | background-color: #282c34; 124 | } 125 | } 126 | 127 | @media screen and (max-width: 533px) { 128 | .markdown-body .code-block-wrapper { 129 | padding: unset; 130 | 131 | code { 132 | padding: 24px 16px 16px 16px; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/common/Setting/About.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 95 | -------------------------------------------------------------------------------- /service/src/utils/mail.ts: -------------------------------------------------------------------------------- 1 | import type { MailConfig } from '../storage/model' 2 | import * as fs from 'node:fs' 3 | import * as path from 'node:path' 4 | import * as url from 'node:url' 5 | import nodemailer from 'nodemailer' 6 | import { getCacheConfig } from '../storage/config' 7 | 8 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) 9 | 10 | export async function sendVerifyMail(toMail: string, verifyUrl: string) { 11 | const config = (await getCacheConfig()) 12 | 13 | const templatesPath = path.join(__dirname, 'templates') 14 | const mailTemplatePath = path.join(templatesPath, 'mail.template.html') 15 | let mailHtml = fs.readFileSync(mailTemplatePath, 'utf8') 16 | mailHtml = mailHtml.replace(/\$\{VERIFY_URL\}/g, verifyUrl) 17 | mailHtml = mailHtml.replace(/\$\{SITE_TITLE\}/g, config.siteConfig.siteTitle) 18 | await sendMail(toMail, `${config.siteConfig.siteTitle} 账号验证`, mailHtml, config.mailConfig) 19 | } 20 | 21 | export async function sendVerifyMailAdmin(toMail: string, verifyName: string, verifyUrl: string) { 22 | const config = (await getCacheConfig()) 23 | 24 | const templatesPath = path.join(__dirname, 'templates') 25 | const mailTemplatePath = path.join(templatesPath, 'mail.admin.template.html') 26 | let mailHtml = fs.readFileSync(mailTemplatePath, 'utf8') 27 | mailHtml = mailHtml.replace(/\$\{TO_MAIL\}/g, verifyName) 28 | mailHtml = mailHtml.replace(/\$\{VERIFY_URL\}/g, verifyUrl) 29 | mailHtml = mailHtml.replace(/\$\{SITE_TITLE\}/g, config.siteConfig.siteTitle) 30 | await sendMail(toMail, `${config.siteConfig.siteTitle} 账号申请`, mailHtml, config.mailConfig) 31 | } 32 | 33 | export async function sendResetPasswordMail(toMail: string, verifyUrl: string) { 34 | const config = (await getCacheConfig()) 35 | const templatesPath = path.join(__dirname, 'templates') 36 | const mailTemplatePath = path.join(templatesPath, 'mail.resetpassword.template.html') 37 | let mailHtml = fs.readFileSync(mailTemplatePath, 'utf8') 38 | mailHtml = mailHtml.replace(/\$\{VERIFY_URL\}/g, verifyUrl) 39 | mailHtml = mailHtml.replace(/\$\{SITE_TITLE\}/g, config.siteConfig.siteTitle) 40 | await sendMail(toMail, `${config.siteConfig.siteTitle} 密码重置`, mailHtml, config.mailConfig) 41 | } 42 | 43 | export async function sendNoticeMail(toMail: string) { 44 | const config = (await getCacheConfig()) 45 | 46 | const templatesPath = path.join(__dirname, 'templates') 47 | const mailTemplatePath = path.join(templatesPath, 'mail.notice.template.html') 48 | let mailHtml = fs.readFileSync(mailTemplatePath, 'utf8') 49 | mailHtml = mailHtml.replace(/\$\{SITE_DOMAIN\}/g, config.siteConfig.siteDomain) 50 | mailHtml = mailHtml.replace(/\$\{SITE_TITLE\}/g, config.siteConfig.siteTitle) 51 | await sendMail(toMail, `${config.siteConfig.siteTitle} 账号开通`, mailHtml, config.mailConfig) 52 | } 53 | 54 | export async function sendTestMail(toMail: string, config: MailConfig) { 55 | await sendMail(toMail, '测试邮件|Test mail', '这是一封测试邮件|This is test mail', config) 56 | } 57 | 58 | async function sendMail(toMail: string, subject: string, html: string, config: MailConfig): Promise { 59 | try { 60 | const mailOptions = { 61 | from: config.smtpFrom || config.smtpUserName, 62 | to: toMail, 63 | subject, 64 | html, 65 | } 66 | 67 | const transporter = nodemailer.createTransport({ 68 | host: config.smtpHost, 69 | port: config.smtpPort, 70 | secure: config.smtpTsl, 71 | auth: { 72 | user: config.smtpUserName, 73 | pass: config.smtpPassword, 74 | }, 75 | }) 76 | await transporter.sendMail(mailOptions) 77 | } 78 | catch (e) { 79 | globalThis.console.error('Error send email, ', e) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/styles/lib/highlight.less: -------------------------------------------------------------------------------- 1 | html.dark { 2 | pre code.hljs { 3 | display: block; 4 | overflow-x: auto; 5 | padding: 1em 6 | } 7 | 8 | code.hljs { 9 | padding: 3px 5px 10 | } 11 | 12 | .hljs { 13 | color: #abb2bf; 14 | background: #282c34 15 | } 16 | 17 | .hljs-keyword, 18 | .hljs-operator, 19 | .hljs-pattern-match { 20 | color: #f92672 21 | } 22 | 23 | .hljs-function, 24 | .hljs-pattern-match .hljs-constructor { 25 | color: #61aeee 26 | } 27 | 28 | .hljs-function .hljs-params { 29 | color: #a6e22e 30 | } 31 | 32 | .hljs-function .hljs-params .hljs-typing { 33 | color: #fd971f 34 | } 35 | 36 | .hljs-module-access .hljs-module { 37 | color: #7e57c2 38 | } 39 | 40 | .hljs-constructor { 41 | color: #e2b93d 42 | } 43 | 44 | .hljs-constructor .hljs-string { 45 | color: #9ccc65 46 | } 47 | 48 | .hljs-comment, 49 | .hljs-quote { 50 | color: #b18eb1; 51 | font-style: italic 52 | } 53 | 54 | .hljs-doctag, 55 | .hljs-formula { 56 | color: #c678dd 57 | } 58 | 59 | .hljs-deletion, 60 | .hljs-name, 61 | .hljs-section, 62 | .hljs-selector-tag, 63 | .hljs-subst { 64 | color: #e06c75 65 | } 66 | 67 | .hljs-literal { 68 | color: #56b6c2 69 | } 70 | 71 | .hljs-addition, 72 | .hljs-attribute, 73 | .hljs-meta .hljs-string, 74 | .hljs-regexp, 75 | .hljs-string { 76 | color: #98c379 77 | } 78 | 79 | .hljs-built_in, 80 | .hljs-class .hljs-title, 81 | .hljs-title.class_ { 82 | color: #e6c07b 83 | } 84 | 85 | .hljs-attr, 86 | .hljs-number, 87 | .hljs-selector-attr, 88 | .hljs-selector-class, 89 | .hljs-selector-pseudo, 90 | .hljs-template-variable, 91 | .hljs-type, 92 | .hljs-variable { 93 | color: #d19a66 94 | } 95 | 96 | .hljs-bullet, 97 | .hljs-link, 98 | .hljs-meta, 99 | .hljs-selector-id, 100 | .hljs-symbol, 101 | .hljs-title { 102 | color: #61aeee 103 | } 104 | 105 | .hljs-emphasis { 106 | font-style: italic 107 | } 108 | 109 | .hljs-strong { 110 | font-weight: 700 111 | } 112 | 113 | .hljs-link { 114 | text-decoration: underline 115 | } 116 | } 117 | 118 | html { 119 | pre code.hljs { 120 | display: block; 121 | overflow-x: auto; 122 | padding: 1em 123 | } 124 | 125 | code.hljs { 126 | padding: 3px 5px; 127 | &::-webkit-scrollbar { 128 | height: 4px; 129 | } 130 | } 131 | 132 | .hljs { 133 | color: #383a42; 134 | background: #fafafa 135 | } 136 | 137 | .hljs-comment, 138 | .hljs-quote { 139 | color: #a0a1a7; 140 | font-style: italic 141 | } 142 | 143 | .hljs-doctag, 144 | .hljs-formula, 145 | .hljs-keyword { 146 | color: #a626a4 147 | } 148 | 149 | .hljs-deletion, 150 | .hljs-name, 151 | .hljs-section, 152 | .hljs-selector-tag, 153 | .hljs-subst { 154 | color: #e45649 155 | } 156 | 157 | .hljs-literal { 158 | color: #0184bb 159 | } 160 | 161 | .hljs-addition, 162 | .hljs-attribute, 163 | .hljs-meta .hljs-string, 164 | .hljs-regexp, 165 | .hljs-string { 166 | color: #50a14f 167 | } 168 | 169 | .hljs-attr, 170 | .hljs-number, 171 | .hljs-selector-attr, 172 | .hljs-selector-class, 173 | .hljs-selector-pseudo, 174 | .hljs-template-variable, 175 | .hljs-type, 176 | .hljs-variable { 177 | color: #986801 178 | } 179 | 180 | .hljs-bullet, 181 | .hljs-link, 182 | .hljs-meta, 183 | .hljs-selector-id, 184 | .hljs-symbol, 185 | .hljs-title { 186 | color: #4078f2 187 | } 188 | 189 | .hljs-built_in, 190 | .hljs-class .hljs-title, 191 | .hljs-title.class_ { 192 | color: #c18401 193 | } 194 | 195 | .hljs-emphasis { 196 | font-style: italic 197 | } 198 | 199 | .hljs-strong { 200 | font-weight: 700 201 | } 202 | 203 | .hljs-link { 204 | text-decoration: underline 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /service/src/routes/prompt.ts: -------------------------------------------------------------------------------- 1 | import type { UserPrompt } from '../storage/model' 2 | import Router from 'express' 3 | import { ObjectId } from 'mongodb' 4 | import { auth } from '../middleware/auth' 5 | import { 6 | clearUserPrompt, 7 | deleteUserPrompt, 8 | getBuiltInPromptList, 9 | getUserPromptList, 10 | importUserPrompt, 11 | upsertUserPrompt, 12 | } from '../storage/mongo' 13 | 14 | export const router = Router() 15 | 16 | router.get('/prompt-list', auth, async (req, res) => { 17 | try { 18 | const userId = req.headers.userId as string 19 | 20 | // 获取用户自定义提示词 21 | const userPrompts = await getUserPromptList(userId) 22 | const userResult = [] 23 | userPrompts.data.forEach((p) => { 24 | userResult.push({ 25 | _id: p._id, 26 | title: p.title, 27 | value: p.value, 28 | type: 'user-defined', 29 | }) 30 | }) 31 | 32 | // 获取平台预置提示词 33 | const builtInPrompts = await getBuiltInPromptList() 34 | const builtInResult = [] 35 | builtInPrompts.data.forEach((p) => { 36 | builtInResult.push({ 37 | _id: p._id, 38 | title: p.title, 39 | value: p.value, 40 | type: 'built-in', 41 | }) 42 | }) 43 | 44 | // 合并两种类型的提示词 45 | const allPrompts = [...userResult, ...builtInResult] 46 | const totalCount = userPrompts.total + builtInPrompts.total 47 | 48 | res.send({ 49 | status: 'Success', 50 | message: null, 51 | data: { 52 | data: allPrompts, 53 | total: totalCount, 54 | userTotal: userPrompts.total, 55 | builtInTotal: builtInPrompts.total, 56 | }, 57 | }) 58 | } 59 | catch (error) { 60 | res.send({ status: 'Fail', message: error.message, data: null }) 61 | } 62 | }) 63 | 64 | router.post('/prompt-upsert', auth, async (req, res) => { 65 | try { 66 | const userId = req.headers.userId as string 67 | const userPrompt = req.body as UserPrompt 68 | if (userPrompt._id !== undefined) 69 | userPrompt._id = new ObjectId(userPrompt._id) 70 | userPrompt.userId = userId 71 | const newUserPrompt = await upsertUserPrompt(userPrompt) 72 | res.send({ status: 'Success', message: '成功 | Successfully', data: { _id: newUserPrompt._id.toHexString() } }) 73 | } 74 | catch (error) { 75 | res.send({ status: 'Fail', message: error.message, data: null }) 76 | } 77 | }) 78 | 79 | router.post('/prompt-delete', auth, async (req, res) => { 80 | try { 81 | const { id } = req.body as { id: string } 82 | await deleteUserPrompt(id) 83 | res.send({ status: 'Success', message: '成功 | Successfully' }) 84 | } 85 | catch (error) { 86 | res.send({ status: 'Fail', message: error.message, data: null }) 87 | } 88 | }) 89 | 90 | router.post('/prompt-clear', auth, async (req, res) => { 91 | try { 92 | const userId = req.headers.userId as string 93 | await clearUserPrompt(userId) 94 | res.send({ status: 'Success', message: '成功 | Successfully' }) 95 | } 96 | catch (error) { 97 | res.send({ status: 'Fail', message: error.message, data: null }) 98 | } 99 | }) 100 | 101 | router.post('/prompt-import', auth, async (req, res) => { 102 | try { 103 | const userId = req.headers.userId as string 104 | const userPrompt = req.body as UserPrompt[] 105 | const updatedUserPrompt = userPrompt.map((prompt) => { 106 | return { 107 | ...prompt, 108 | userId, 109 | } 110 | }) 111 | await importUserPrompt(updatedUserPrompt) 112 | res.send({ status: 'Success', message: '成功 | Successfully' }) 113 | } 114 | catch (error) { 115 | res.send({ status: 'Fail', message: error.message, data: null }) 116 | } 117 | }) 118 | -------------------------------------------------------------------------------- /src/views/chat/components/Message/Text.vue: -------------------------------------------------------------------------------- 1 | 109 | 110 | 122 | 123 | 126 | -------------------------------------------------------------------------------- /src/views/chat/components/Header/index.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 117 | -------------------------------------------------------------------------------- /src/utils/request/fetchService.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from '@/store' 2 | 3 | export interface FetchRequestConfig { 4 | url: string 5 | method?: string 6 | headers?: Record 7 | body?: any 8 | signal?: AbortSignal 9 | } 10 | 11 | export interface FetchResponse { 12 | data: T 13 | status: number 14 | statusText: string 15 | headers: Headers 16 | } 17 | 18 | export interface SSEStreamOptions { 19 | onChunk?: (chunk: string) => void 20 | onError?: (error: Error) => void 21 | onComplete?: () => void 22 | } 23 | 24 | class FetchService { 25 | private baseURL: string 26 | private defaultHeaders: Record 27 | 28 | constructor() { 29 | this.baseURL = import.meta.env.VITE_GLOB_API_URL || '' 30 | this.defaultHeaders = { 31 | 'Content-Type': 'application/json', 32 | } 33 | } 34 | 35 | // Request interceptor - automatically add authentication headers and other configurations 36 | private requestInterceptor(config: FetchRequestConfig): FetchRequestConfig { 37 | const token = useAuthStore().token 38 | const headers = { ...this.defaultHeaders, ...config.headers } 39 | 40 | if (token) { 41 | headers.Authorization = `Bearer ${token}` 42 | } 43 | 44 | return { 45 | ...config, 46 | headers, 47 | } 48 | } 49 | 50 | // Response interceptor - handle error status 51 | private async responseInterceptor(response: Response): Promise { 52 | if (!response.ok) { 53 | throw new Error(`HTTP error! status: ${response.status}`) 54 | } 55 | return response 56 | } 57 | 58 | // POST request 59 | async post(config: FetchRequestConfig): Promise> { 60 | const processedConfig = this.requestInterceptor(config) 61 | const url = `${this.baseURL}${processedConfig.url}` 62 | 63 | const response = await fetch(url, { 64 | method: 'POST', 65 | headers: processedConfig.headers, 66 | body: typeof processedConfig.body === 'object' 67 | ? JSON.stringify(processedConfig.body) 68 | : processedConfig.body, 69 | signal: processedConfig.signal, 70 | }) 71 | 72 | const processedResponse = await this.responseInterceptor(response) 73 | const data = await processedResponse.json() 74 | 75 | return { 76 | data, 77 | status: processedResponse.status, 78 | statusText: processedResponse.statusText, 79 | headers: processedResponse.headers, 80 | } 81 | } 82 | 83 | // SSE streaming request 84 | async postStream(config: FetchRequestConfig, options: SSEStreamOptions): Promise { 85 | const processedConfig = this.requestInterceptor(config) 86 | const url = `${this.baseURL}${processedConfig.url}` 87 | 88 | try { 89 | const response = await fetch(url, { 90 | method: 'POST', 91 | headers: processedConfig.headers, 92 | body: typeof processedConfig.body === 'object' 93 | ? JSON.stringify(processedConfig.body) 94 | : processedConfig.body, 95 | signal: processedConfig.signal, 96 | }) 97 | 98 | await this.responseInterceptor(response) 99 | 100 | if (!response.body) { 101 | throw new Error('ReadableStream not supported') 102 | } 103 | 104 | const reader = response.body.getReader() 105 | const decoder = new TextDecoder() 106 | let buffer = '' 107 | 108 | try { 109 | while (true) { 110 | const { done, value } = await reader.read() 111 | 112 | if (done) { 113 | options.onComplete?.() 114 | break 115 | } 116 | 117 | // Decode the chunk and add to buffer 118 | buffer += decoder.decode(value, { stream: true }) 119 | 120 | // Process complete lines 121 | const lines = buffer.split('\n') 122 | // Keep the last potentially incomplete line 123 | buffer = lines.pop() || '' 124 | 125 | for (const line of lines) { 126 | if (line.trim()) { 127 | options.onChunk?.(line) 128 | } 129 | } 130 | } 131 | } 132 | catch (error) { 133 | options.onError?.(error as Error) 134 | throw error 135 | } 136 | } 137 | catch (error) { 138 | options.onError?.(error as Error) 139 | throw error 140 | } 141 | } 142 | } 143 | 144 | // Create singleton instance 145 | const fetchService = new FetchService() 146 | 147 | export default fetchService 148 | -------------------------------------------------------------------------------- /.github/workflows/build_docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and push docker image 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | release: 7 | types: [created] # This will only run on new releases 8 | 9 | jobs: 10 | docker_hub_description: 11 | name: Docker Hub description 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | sparse-checkout: | 18 | README.en.md 19 | - name: Docker Hub Description 20 | uses: peter-evans/dockerhub-description@v4 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | repository: ${{ vars.DOCKERHUB_NAMESPACE }}/chatgpt-web 25 | # Description length should be no longer than 100 characters. 26 | short-description: A third-party ChatGPT Web UI page, through the official OpenAI completion API. 27 | readme-filepath: README.en.md 28 | enable-url-completion: true 29 | 30 | build: 31 | name: Build multi-platform images 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | include: 36 | - platform: linux/amd64 37 | os: ubuntu-latest 38 | - platform: linux/arm64 39 | os: ubuntu-24.04-arm 40 | runs-on: ${{ matrix.os }} 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: Prepare 46 | run: | 47 | platform=${{ matrix.platform }} 48 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 49 | 50 | - name: Docker meta 51 | id: meta 52 | uses: docker/metadata-action@v5 53 | with: 54 | images: ${{ vars.DOCKERHUB_NAMESPACE }}/chatgpt-web 55 | 56 | - name: Login to Docker Hub 57 | uses: docker/login-action@v3 58 | with: 59 | username: ${{ secrets.DOCKERHUB_USERNAME }} 60 | password: ${{ secrets.DOCKERHUB_TOKEN }} 61 | 62 | - name: Set up Docker Buildx 63 | uses: docker/setup-buildx-action@v3 64 | 65 | - name: Build and push by digest 66 | id: build 67 | uses: docker/build-push-action@v6 68 | with: 69 | context: . 70 | platforms: ${{ matrix.platform }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | build-args: | 73 | GIT_COMMIT_HASH=${{ github.sha }} 74 | RELEASE_VERSION=${{ github.ref_name }} 75 | outputs: type=image,name=${{ vars.DOCKERHUB_NAMESPACE }}/chatgpt-web,push-by-digest=true,name-canonical=true,push=true 76 | 77 | - name: Export digest 78 | run: | 79 | mkdir -p ${{ runner.temp }}/digests 80 | digest="${{ steps.build.outputs.digest }}" 81 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 82 | 83 | - name: Upload digest 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: digests-${{ env.PLATFORM_PAIR }} 87 | path: ${{ runner.temp }}/digests/* 88 | if-no-files-found: error 89 | retention-days: 1 90 | 91 | merge: 92 | name: Merge multi-platform images 93 | runs-on: ubuntu-latest 94 | needs: 95 | - build 96 | steps: 97 | - name: Download digests 98 | uses: actions/download-artifact@v4 99 | with: 100 | path: ${{ runner.temp }}/digests 101 | pattern: digests-* 102 | merge-multiple: true 103 | 104 | - name: Login to Docker Hub 105 | uses: docker/login-action@v3 106 | with: 107 | username: ${{ secrets.DOCKERHUB_USERNAME }} 108 | password: ${{ secrets.DOCKERHUB_TOKEN }} 109 | 110 | - name: Set up Docker Buildx 111 | uses: docker/setup-buildx-action@v3 112 | 113 | - name: Docker meta 114 | id: meta 115 | uses: docker/metadata-action@v5 116 | with: 117 | images: ${{ vars.DOCKERHUB_NAMESPACE }}/chatgpt-web 118 | 119 | - name: Create manifest list and push 120 | working-directory: ${{ runner.temp }}/digests 121 | run: | 122 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 123 | $(printf '${{ vars.DOCKERHUB_NAMESPACE }}/chatgpt-web@sha256:%s ' *) 124 | 125 | - name: Inspect image 126 | run: | 127 | docker buildx imagetools inspect ${{ vars.DOCKERHUB_NAMESPACE }}/chatgpt-web:${{ steps.meta.outputs.version }} 128 | -------------------------------------------------------------------------------- /src/views/chat/layout/sider/List.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 117 | -------------------------------------------------------------------------------- /src/components/common/Setting/Mail.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 131 | -------------------------------------------------------------------------------- /src/components/common/Setting/Gift.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 163 | -------------------------------------------------------------------------------- /src/views/chat/components/Message/Reasoning.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 128 | 129 | 132 | -------------------------------------------------------------------------------- /src/views/chat/layout/sider/index.vue: -------------------------------------------------------------------------------- 1 | 106 | 107 | 162 | 163 | 174 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const EffectScope: typeof import('vue').EffectScope 10 | const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate 11 | const computed: typeof import('vue').computed 12 | const createApp: typeof import('vue').createApp 13 | const createPinia: typeof import('pinia').createPinia 14 | const customRef: typeof import('vue').customRef 15 | const defineAsyncComponent: typeof import('vue').defineAsyncComponent 16 | const defineComponent: typeof import('vue').defineComponent 17 | const defineStore: typeof import('pinia').defineStore 18 | const effectScope: typeof import('vue').effectScope 19 | const getActivePinia: typeof import('pinia').getActivePinia 20 | const getCurrentInstance: typeof import('vue').getCurrentInstance 21 | const getCurrentScope: typeof import('vue').getCurrentScope 22 | const getCurrentWatcher: typeof import('vue').getCurrentWatcher 23 | const h: typeof import('vue').h 24 | const inject: typeof import('vue').inject 25 | const isProxy: typeof import('vue').isProxy 26 | const isReactive: typeof import('vue').isReactive 27 | const isReadonly: typeof import('vue').isReadonly 28 | const isRef: typeof import('vue').isRef 29 | const isShallow: typeof import('vue').isShallow 30 | const mapActions: typeof import('pinia').mapActions 31 | const mapGetters: typeof import('pinia').mapGetters 32 | const mapState: typeof import('pinia').mapState 33 | const mapStores: typeof import('pinia').mapStores 34 | const mapWritableState: typeof import('pinia').mapWritableState 35 | const markRaw: typeof import('vue').markRaw 36 | const nextTick: typeof import('vue').nextTick 37 | const onActivated: typeof import('vue').onActivated 38 | const onBeforeMount: typeof import('vue').onBeforeMount 39 | const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave 40 | const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate 41 | const onBeforeUnmount: typeof import('vue').onBeforeUnmount 42 | const onBeforeUpdate: typeof import('vue').onBeforeUpdate 43 | const onDeactivated: typeof import('vue').onDeactivated 44 | const onErrorCaptured: typeof import('vue').onErrorCaptured 45 | const onMounted: typeof import('vue').onMounted 46 | const onRenderTracked: typeof import('vue').onRenderTracked 47 | const onRenderTriggered: typeof import('vue').onRenderTriggered 48 | const onScopeDispose: typeof import('vue').onScopeDispose 49 | const onServerPrefetch: typeof import('vue').onServerPrefetch 50 | const onUnmounted: typeof import('vue').onUnmounted 51 | const onUpdated: typeof import('vue').onUpdated 52 | const onWatcherCleanup: typeof import('vue').onWatcherCleanup 53 | const provide: typeof import('vue').provide 54 | const reactive: typeof import('vue').reactive 55 | const readonly: typeof import('vue').readonly 56 | const ref: typeof import('vue').ref 57 | const resolveComponent: typeof import('vue').resolveComponent 58 | const setActivePinia: typeof import('pinia').setActivePinia 59 | const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix 60 | const shallowReactive: typeof import('vue').shallowReactive 61 | const shallowReadonly: typeof import('vue').shallowReadonly 62 | const shallowRef: typeof import('vue').shallowRef 63 | const storeToRefs: typeof import('pinia').storeToRefs 64 | const toRaw: typeof import('vue').toRaw 65 | const toRef: typeof import('vue').toRef 66 | const toRefs: typeof import('vue').toRefs 67 | const toValue: typeof import('vue').toValue 68 | const triggerRef: typeof import('vue').triggerRef 69 | const unref: typeof import('vue').unref 70 | const useAttrs: typeof import('vue').useAttrs 71 | const useCssModule: typeof import('vue').useCssModule 72 | const useCssVars: typeof import('vue').useCssVars 73 | const useDialog: typeof import('naive-ui').useDialog 74 | const useI18n: typeof import('vue-i18n').useI18n 75 | const useId: typeof import('vue').useId 76 | const useLink: typeof import('vue-router').useLink 77 | const useLoadingBar: typeof import('naive-ui').useLoadingBar 78 | const useMessage: typeof import('naive-ui').useMessage 79 | const useModel: typeof import('vue').useModel 80 | const useNotification: typeof import('naive-ui').useNotification 81 | const useRoute: typeof import('vue-router').useRoute 82 | const useRouter: typeof import('vue-router').useRouter 83 | const useSlots: typeof import('vue').useSlots 84 | const useTemplateRef: typeof import('vue').useTemplateRef 85 | const watch: typeof import('vue').watch 86 | const watchEffect: typeof import('vue').watchEffect 87 | const watchPostEffect: typeof import('vue').watchPostEffect 88 | const watchSyncEffect: typeof import('vue').watchSyncEffect 89 | } 90 | // for type re-export 91 | declare global { 92 | // @ts-ignore 93 | export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' 94 | import('vue') 95 | } 96 | -------------------------------------------------------------------------------- /src/components/common/Setting/TwoFA.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 141 | -------------------------------------------------------------------------------- /src/components/common/Setting/model.ts: -------------------------------------------------------------------------------- 1 | export class ConfigState { 2 | timeoutMs?: number 3 | apiKey?: string 4 | apiBaseUrl?: string 5 | reverseProxy?: string 6 | socksProxy?: string 7 | socksAuth?: string 8 | httpsProxy?: string 9 | balance?: number 10 | siteConfig?: SiteConfig 11 | mailConfig?: MailConfig 12 | auditConfig?: AuditConfig 13 | searchConfig?: SearchConfig 14 | announceConfig?: AnnounceConfig 15 | } 16 | 17 | export class UserConfig { 18 | chatModel?: string 19 | maxContextCount?: number 20 | } 21 | 22 | // https://platform.openai.com/docs/models/overview 23 | export class SiteConfig { 24 | siteTitle?: string 25 | loginEnabled?: boolean 26 | loginSalt?: string 27 | registerEnabled?: boolean 28 | registerReview?: boolean 29 | registerMails?: string 30 | siteDomain?: string 31 | chatModels?: string 32 | globalAmount?: number 33 | usageCountLimit?: boolean 34 | showWatermark?: boolean 35 | } 36 | 37 | export class MailConfig { 38 | smtpHost?: string 39 | smtpPort?: number 40 | smtpTsl?: boolean 41 | smtpUserName?: string 42 | smtpPassword?: string 43 | smtpFrom?: string 44 | } 45 | export type TextAuditServiceProvider = 'baidu' // | 'ali' 46 | 47 | export interface TextAuditServiceOptions { 48 | apiKey: string 49 | apiSecret: string 50 | label?: string 51 | } 52 | export enum TextAudioType { 53 | None = 0, 54 | Request = 1, // 二进制 01 55 | Response = 2, // 二进制 10 56 | All = 3, // 二进制 11 57 | } 58 | 59 | export class AuditConfig { 60 | enabled?: boolean 61 | provider?: TextAuditServiceProvider 62 | options?: TextAuditServiceOptions 63 | textType?: TextAudioType 64 | customizeEnabled?: boolean 65 | sensitiveWords?: string 66 | } 67 | 68 | export class AnnounceConfig { 69 | enabled?: boolean 70 | announceWords?: string 71 | } 72 | 73 | export enum Status { 74 | Normal = 0, 75 | Deleted = 1, 76 | InversionDeleted = 2, 77 | ResponseDeleted = 3, 78 | PreVerify = 4, 79 | AdminVerify = 5, 80 | Disabled = 6, 81 | } 82 | 83 | export enum UserRole { 84 | Admin = 0, 85 | User = 1, 86 | Guest = 2, 87 | Support = 3, 88 | Viewer = 4, 89 | Contributor = 5, 90 | Developer = 6, 91 | Tester = 7, 92 | Partner = 8, 93 | } 94 | 95 | export class KeyConfig { 96 | _id?: string 97 | key: string 98 | keyModel: APIMODEL 99 | chatModels: string[] 100 | userRoles: UserRole[] 101 | status: Status 102 | remark: string 103 | baseUrl?: string 104 | constructor(key: string, keyModel: APIMODEL, chatModels: string[], userRoles: UserRole[], remark: string) { 105 | this.key = key 106 | this.keyModel = keyModel 107 | this.chatModels = chatModels 108 | this.userRoles = userRoles 109 | this.status = Status.Normal 110 | this.remark = remark 111 | } 112 | } 113 | 114 | export class UserPrompt { 115 | _id?: string 116 | title: string 117 | value: string 118 | type?: 'built-in' | 'user-defined' 119 | constructor(title: string, value: string) { 120 | this.title = title 121 | this.value = value 122 | } 123 | } 124 | 125 | export type APIMODEL = 'ChatGPTAPI' | 'VLLM' | 'FastDeploy' | 'ResponsesAPI' 126 | 127 | export const apiModelOptions = ['ChatGPTAPI', 'VLLM', 'FastDeploy', 'ResponsesAPI'].map((model: string) => { 128 | return { 129 | label: model, 130 | key: model, 131 | value: model, 132 | } 133 | }) 134 | 135 | export const userRoleOptions = Object.values(UserRole).filter(d => Number.isNaN(Number(d))).map((role) => { 136 | return { 137 | label: role as string, 138 | key: role as string, 139 | value: UserRole[role as keyof typeof UserRole], 140 | } 141 | }) 142 | 143 | export class UserInfo { 144 | _id?: string 145 | email?: string 146 | password?: string 147 | roles: UserRole[] 148 | remark?: string 149 | useAmount?: number 150 | // 配合改造,增加额度信息 and it's switch 151 | limit_switch?: boolean 152 | constructor(roles: UserRole[]) { 153 | this.roles = roles 154 | } 155 | } 156 | 157 | export class UserPassword { 158 | oldPassword?: string 159 | newPassword?: string 160 | confirmPassword?: string 161 | } 162 | 163 | export class TwoFAConfig { 164 | enaled: boolean 165 | userName: string 166 | secretKey: string 167 | otpauthUrl: string 168 | testCode: string 169 | constructor() { 170 | this.enaled = false 171 | this.userName = '' 172 | this.secretKey = '' 173 | this.otpauthUrl = '' 174 | this.testCode = '' 175 | } 176 | } 177 | 178 | export interface GiftCard { 179 | cardno: string 180 | amount: number 181 | redeemed: number 182 | } 183 | 184 | export type SearchServiceProvider = 'tavily' | '' 185 | 186 | export interface SearchServiceOptions { 187 | apiKey: string 188 | maxResults?: number 189 | includeRawContent?: boolean 190 | } 191 | 192 | export class SearchConfig { 193 | enabled: boolean 194 | provider: SearchServiceProvider 195 | options: SearchServiceOptions 196 | systemMessageWithSearchResult: string 197 | systemMessageGetSearchQuery: string 198 | constructor(enabled: boolean, provider: SearchServiceProvider, options: SearchServiceOptions, systemMessageWithSearchResult: string, systemMessageGetSearchQuery: string) { 199 | this.enabled = enabled 200 | this.provider = provider 201 | this.options = options 202 | this.systemMessageWithSearchResult = systemMessageWithSearchResult 203 | this.systemMessageGetSearchQuery = systemMessageGetSearchQuery 204 | if (!this.options.maxResults) { 205 | this.options.maxResults = 10 206 | } 207 | } 208 | } 209 | --------------------------------------------------------------------------------