├── vercel.json ├── public ├── pwa-192.png ├── pwa-512.png ├── apple-touch-icon.png └── icon.svg ├── .eslintignore ├── .npmrc ├── .dockerignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── src ├── types.ts ├── components │ ├── icons │ │ ├── Clear.tsx │ │ ├── X.tsx │ │ ├── Env.tsx │ │ └── Refresh.tsx │ ├── Footer.astro │ ├── Header.astro │ ├── ErrorMessageItem.tsx │ ├── Logo.astro │ ├── SystemRoleSettings.tsx │ ├── MessageItem.tsx │ ├── Themetoggle.astro │ └── Generator.tsx ├── env.d.ts ├── pages │ ├── api │ │ ├── auth.ts │ │ └── generate.ts │ ├── index.astro │ └── password.astro ├── message.css ├── utils │ ├── auth.ts │ └── openAI.ts └── layouts │ └── Layout.astro ├── tsconfig.json ├── netlify.toml ├── .gitignore ├── shims.d.ts ├── Dockerfile ├── docker-compose.yml ├── hack ├── docker-entrypoint.sh └── docker-env-replace.sh ├── .env.example ├── plugins └── disableBlocks.ts ├── .eslintrc.js ├── LICENSE ├── package.json ├── astro.config.mjs ├── unocss.config.ts ├── README.zh-CN.md └── README.md /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "OUTPUT=vercel astro build" 3 | } -------------------------------------------------------------------------------- /public/pwa-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wscats/ACEM/main/public/pwa-192.png -------------------------------------------------------------------------------- /public/pwa-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wscats/ACEM/main/public/pwa-512.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | public 3 | node_modules 4 | .netlify 5 | .vercel 6 | .github 7 | .changeset 8 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wscats/ACEM/main/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | strict-peer-dependencies=false 3 | auto-install-peers=true 4 | shamefully-hoist=true 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | Dockerfile 3 | docker-compose.yml 4 | LICENSE 5 | netlify.toml 6 | vercel.json 7 | node_modules 8 | .vscode 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode","dbaeumer.vscode-eslint","antfu.unocss"], 3 | "unwantedRecommendations": [], 4 | } 5 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ChatMessage { 2 | role: 'system' | 'user' | 'assistant' 3 | content: string 4 | } 5 | 6 | export interface ErrorMessage { 7 | code: string 8 | message: string 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "jsx": "preserve", 6 | "jsxImportSource": "solid-js", 7 | "types": ["vite-plugin-pwa/info"], 8 | "paths": { 9 | "@/*": ["src/*"], 10 | }, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NETLIFY_USE_PNPM = "true" 3 | NODE_VERSION = "18" 4 | 5 | [build] 6 | command = "OUTPUT=netlify astro build" 7 | publish = "dist" 8 | 9 | [[headers]] 10 | for = "/manifest.webmanifest" 11 | [headers.values] 12 | Content-Type = "application/manifest+json" 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "editor.formatOnSave": false, 6 | "eslint.validate": [ 7 | "javascript", 8 | "javascriptreact", 9 | "astro", // Enable .astro 10 | "typescript", // Enable .ts 11 | "typescriptreact" // Enable .tsx 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/components/icons/Clear.tsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 | 4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Footer.astro: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly OPENAI_API_KEY: string 5 | readonly HTTPS_PROXY: string 6 | readonly OPENAI_API_BASE_URL: string 7 | readonly HEAD_SCRIPTS: string 8 | readonly SECRET_KEY: string 9 | readonly SITE_PASSWORD: string 10 | readonly OPENAI_API_MODEL: string 11 | } 12 | 13 | interface ImportMeta { 14 | readonly env: ImportMetaEnv 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Header.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Logo from './Logo.astro' 3 | import Themetoggle from './Themetoggle.astro' 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ChatGPT 13 | Demo 14 | 15 | Based on OpenAI API (gpt-3.5-turbo). 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .vercel/ 4 | .netlify/ 5 | 6 | # generated types 7 | .astro/ 8 | 9 | # dependencies 10 | node_modules/ 11 | 12 | # logs 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | 18 | # environment variables 19 | .env 20 | .env.production 21 | 22 | # macOS-specific files 23 | .DS_Store 24 | 25 | # Local 26 | *.local 27 | 28 | **/.DS_Store 29 | 30 | # Editor directories and files 31 | .idea 32 | -------------------------------------------------------------------------------- /src/components/icons/X.tsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 | 4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/api/auth.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from 'astro' 2 | 3 | const realPassword = import.meta.env.SITE_PASSWORD || '' 4 | const passList = realPassword.split(',') || [] 5 | 6 | export const post: APIRoute = async(context) => { 7 | const body = await context.request.json() 8 | 9 | const { pass } = body 10 | return new Response(JSON.stringify({ 11 | code: (!realPassword || pass === realPassword || passList.includes(pass)) ? 0 : -1, 12 | })) 13 | } 14 | -------------------------------------------------------------------------------- /shims.d.ts: -------------------------------------------------------------------------------- 1 | import type { AttributifyAttributes } from '@unocss/preset-attributify' 2 | 3 | // declare module 'solid-js' { 4 | // namespace JSX { 5 | // interface HTMLAttributes extends AttributifyAttributes {} 6 | // } 7 | // } 8 | 9 | declare global { 10 | namespace astroHTML.JSX { 11 | interface HTMLAttributes extends AttributifyAttributes { } 12 | } 13 | namespace JSX { 14 | interface HTMLAttributes<> extends AttributifyAttributes {} 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as builder 2 | WORKDIR /usr/src 3 | RUN npm install -g pnpm 4 | COPY package.json pnpm-lock.yaml ./ 5 | RUN pnpm install 6 | COPY . . 7 | RUN pnpm run build 8 | 9 | FROM node:alpine 10 | WORKDIR /usr/src 11 | RUN npm install -g pnpm 12 | COPY --from=builder /usr/src/dist ./dist 13 | COPY --from=builder /usr/src/hack ./ 14 | COPY package.json pnpm-lock.yaml ./ 15 | RUN pnpm install 16 | ENV HOST=0.0.0.0 PORT=3000 NODE_ENV=production 17 | EXPOSE $PORT 18 | CMD ["/bin/sh", "docker-entrypoint.sh"] 19 | -------------------------------------------------------------------------------- /src/components/icons/Env.tsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 | 4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /src/message.css: -------------------------------------------------------------------------------- 1 | .message pre { 2 | background-color: #64748b10; 3 | font-size: 0.8rem; 4 | padding: 0.4rem 1rem; 5 | } 6 | 7 | .message .hljs { 8 | background-color: transparent; 9 | } 10 | 11 | .message table { 12 | font-size: 0.8em; 13 | } 14 | 15 | .message table thead tr { 16 | background-color: #64748b40; 17 | text-align: left; 18 | } 19 | 20 | .message table th, .message table td { 21 | padding: 0.6rem 1rem; 22 | } 23 | 24 | .message table tbody tr:last-of-type { 25 | border-bottom: 2px solid #64748b40; 26 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | chatgpt-demo: 5 | image: ddiu8081/chatgpt-demo:latest 6 | container_name: chatgpt-demo 7 | restart: always 8 | ports: 9 | - "3000:3000" 10 | environment: 11 | - OPENAI_API_KEY=YOUR_OPENAI_API_KEY 12 | # - HTTPS_PROXY=YOUR_HTTPS_PROXY 13 | # - OPENAI_API_BASE_URL=YOUR_OPENAI_API_BASE_URL 14 | # - HEAD_SCRIPTS=YOUR_HEAD_SCRIPTS 15 | # - SECRET_KEY=YOUR_SECRET_KEY 16 | # - SITE_PASSWORD=YOUR_SITE_PASSWORD 17 | # - OPENAI_API_MODEL=YOUR_OPENAI_API_MODEL 18 | 19 | -------------------------------------------------------------------------------- /hack/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | sub_service_pid="" 4 | 5 | sub_service_command="node dist/server/entry.mjs" 6 | 7 | function init() { 8 | /bin/sh ./docker-env-replace.sh 9 | } 10 | 11 | function main { 12 | init 13 | 14 | echo "Starting service..." 15 | eval "$sub_service_command &" 16 | sub_service_pid=$! 17 | 18 | trap cleanup SIGTERM SIGINT 19 | echo "Running script..." 20 | while [ true ]; do 21 | sleep 5 22 | done 23 | } 24 | 25 | function cleanup { 26 | echo "Cleaning up!" 27 | kill -TERM $sub_service_pid 28 | } 29 | 30 | main 31 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Your API Key for OpenAI 2 | OPENAI_API_KEY= 3 | # Provide proxy for OpenAI API. e.g. http://127.0.0.1:7890 4 | HTTPS_PROXY= 5 | # Custom base url for OpenAI API. default: https://api.openai.com 6 | OPENAI_API_BASE_URL= 7 | # Inject analytics or other scripts before of the page 8 | HEAD_SCRIPTS= 9 | # Secret string for the project. Use for generating signatures for API calls 10 | SECRET_KEY= 11 | # Set password for site, support multiple password separated by comma. If not set, site will be public 12 | SITE_PASSWORD= 13 | # ID of the model to use. https://platform.openai.com/docs/api-reference/models/list 14 | OPENAI_API_MODEL= 15 | -------------------------------------------------------------------------------- /plugins/disableBlocks.ts: -------------------------------------------------------------------------------- 1 | export default function plugin(platform?: string) { 2 | const transform = (code: string, id: string) => { 3 | if (id.includes('pages/api/generate.ts')) { 4 | return { 5 | code: code.replace(/^.*?#vercel-disable-blocks([\s\S]+?)#vercel-end.*?$/gm, ''), 6 | map: null, 7 | } 8 | } 9 | if (platform === 'netlify' && id.includes('layouts/Layout.astro')) { 10 | return { 11 | code: code.replace(/^.*?([\s\S]+?).*?$/gm, ''), 12 | map: null, 13 | } 14 | } 15 | } 16 | 17 | return { 18 | name: 'vercel-disable-blocks', 19 | enforce: 'pre', 20 | transform, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ErrorMessageItem.tsx: -------------------------------------------------------------------------------- 1 | import IconRefresh from './icons/Refresh' 2 | import type { ErrorMessage } from '@/types' 3 | 4 | interface Props { 5 | data: ErrorMessage 6 | onRetry?: () => void 7 | } 8 | 9 | export default ({ data, onRetry }: Props) => { 10 | return ( 11 | 12 | {data.code && {data.code}} 13 | {data.message} 14 | {onRetry && ( 15 | 16 | 17 | 18 | Regenerate 19 | 20 | 21 | )} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@evan-yang', 'plugin:astro/recommended'], 3 | rules: { 4 | 'no-console': ['error', { allow: ['error'] }], 5 | 'react/display-name': 'off', 6 | 'react-hooks/rules-of-hooks': 'off', 7 | '@typescript-eslint/no-use-before-define': 'off', 8 | }, 9 | overrides: [ 10 | { 11 | files: ['*.astro'], 12 | parser: 'astro-eslint-parser', 13 | parserOptions: { 14 | parser: '@typescript-eslint/parser', 15 | extraFileExtensions: ['.astro'], 16 | }, 17 | rules: { 18 | 'no-mixed-spaces-and-tabs': ['error', 'smart-tabs'], 19 | }, 20 | }, 21 | { 22 | // Define the configuration for ` 37 | -------------------------------------------------------------------------------- /src/components/icons/Refresh.tsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 | 4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Diu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Logo.astro: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from 'js-sha256' 2 | interface AuthPayload { 3 | t: number 4 | m: string 5 | } 6 | 7 | async function digestMessage(message: string) { 8 | if (typeof crypto !== 'undefined' && crypto?.subtle?.digest) { 9 | const msgUint8 = new TextEncoder().encode(message) 10 | const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) 11 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 12 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') 13 | } else { 14 | return sha256(message).toString() 15 | } 16 | } 17 | 18 | export const generateSignature = async(payload: AuthPayload) => { 19 | const { t: timestamp, m: lastMessage } = payload 20 | const secretKey = import.meta.env.PUBLIC_SECRET_KEY as string 21 | const signText = `${timestamp}:${lastMessage}:${secretKey}` 22 | // eslint-disable-next-line no-return-await 23 | return await digestMessage(signText) 24 | } 25 | 26 | export const verifySignature = async(payload: AuthPayload, sign: string) => { 27 | // if (Math.abs(payload.t - Date.now()) > 1000 * 60 * 5) { 28 | // return false 29 | // } 30 | const payloadSign = await generateSignature(payload) 31 | return payloadSign === sign 32 | } 33 | -------------------------------------------------------------------------------- /hack/docker-env-replace.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Your API Key for OpenAI 4 | openai_api_key=$OPENAI_API_KEY 5 | # Provide proxy for OpenAI API. e.g. http://127.0.0.1:7890 6 | https_proxy=$HTTPS_PROXY 7 | # Custom base url for OpenAI API. default: https://api.openai.com 8 | openai_api_base_url=$OPENAI_API_BASE_URL 9 | # Inject analytics or other scripts before of the page 10 | head_scripts=$HEAD_SCRIPTS 11 | # Secret string for the project. Use for generating signatures for API calls 12 | secret_key=$SECRET_KEY 13 | # Set password for site, support multiple password separated by comma. If not set, site will be public 14 | site_password=$SITE_PASSWORD 15 | # ID of the model to use. https://platform.openai.com/docs/api-reference/models/list 16 | openai_api_model=$OPENAI_API_MODEL 17 | 18 | for file in $(find ./dist -type f -name "*.mjs"); do 19 | sed "s/({}).OPENAI_API_KEY/\"$openai_api_key\"/g; 20 | s/({}).HTTPS_PROXY/\"$https_proxy\"/g; 21 | s/({}).OPENAI_API_BASE_URL/\"$openai_api_base_url\"/g; 22 | s/({}).HEAD_SCRIPTS/\"$head_scripts\"/g; 23 | s/({}).SECRET_KEY/\"$secret_key\"/g; 24 | s/({}).OPENAI_API_MODEL/\"$openai_api_model\"/g; 25 | s/process.env.SITE_PASSWORD/\"$site_password\"/g" $file > tmp 26 | mv tmp $file 27 | done 28 | 29 | rm -rf tmp 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-api-demo", 3 | "version": "0.0.1", 4 | "packageManager": "pnpm@7.28.0", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "build:vercel": "OUTPUT=vercel astro build", 10 | "build:netlify": "OUTPUT=netlify astro build", 11 | "preview": "astro preview", 12 | "astro": "astro", 13 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro", 14 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix" 15 | }, 16 | "dependencies": { 17 | "@astrojs/netlify": "2.0.0", 18 | "@astrojs/node": "^5.0.4", 19 | "@astrojs/solid-js": "^2.0.2", 20 | "@astrojs/vercel": "^3.1.3", 21 | "@unocss/reset": "^0.50.1", 22 | "astro": "^2.0.15", 23 | "eslint": "^8.36.0", 24 | "eventsource-parser": "^0.1.0", 25 | "highlight.js": "^11.7.0", 26 | "js-sha256": "^0.9.0", 27 | "katex": "^0.6.0", 28 | "markdown-it": "^13.0.1", 29 | "markdown-it-highlightjs": "^4.0.1", 30 | "markdown-it-katex": "^2.0.3", 31 | "solid-js": "1.6.12", 32 | "solidjs-use": "^1.2.0", 33 | "undici": "^5.20.0" 34 | }, 35 | "devDependencies": { 36 | "@iconify-json/carbon": "^1.1.16", 37 | "@types/markdown-it": "^12.2.3", 38 | "@typescript-eslint/parser": "^5.54.1", 39 | "@unocss/preset-attributify": "^0.50.1", 40 | "@unocss/preset-icons": "^0.50.4", 41 | "@unocss/preset-typography": "^0.50.3", 42 | "eslint-plugin-astro": "^0.24.0", 43 | "punycode": "^2.3.0", 44 | "unocss": "^0.50.1", 45 | "@evan-yang/eslint-config": "^1.0.1", 46 | "vite-plugin-pwa": "^0.14.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/pages/password.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro' 3 | --- 4 | 5 | 6 | 7 | Please input password 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 51 | 52 | 72 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config' 2 | import unocss from 'unocss/astro' 3 | import solidJs from '@astrojs/solid-js' 4 | 5 | import node from '@astrojs/node' 6 | import { VitePWA } from 'vite-plugin-pwa' 7 | import vercel from '@astrojs/vercel/edge' 8 | import netlify from '@astrojs/netlify/edge-functions' 9 | import disableBlocks from './plugins/disableBlocks' 10 | 11 | const envAdapter = () => { 12 | if (process.env.OUTPUT === 'vercel') { 13 | return vercel() 14 | } else if (process.env.OUTPUT === 'netlify') { 15 | return netlify() 16 | } else { 17 | return node({ 18 | mode: 'standalone', 19 | }) 20 | } 21 | } 22 | 23 | // https://astro.build/config 24 | export default defineConfig({ 25 | integrations: [ 26 | unocss(), 27 | solidJs(), 28 | ], 29 | output: 'server', 30 | adapter: envAdapter(), 31 | vite: { 32 | plugins: [ 33 | process.env.OUTPUT === 'vercel' && disableBlocks(), 34 | process.env.OUTPUT === 'netlify' && disableBlocks('netlify'), 35 | process.env.OUTPUT !== 'netlify' && VitePWA({ 36 | registerType: 'autoUpdate', 37 | manifest: { 38 | name: 'ChatGPT-API Demo', 39 | short_name: 'ChatGPT Demo', 40 | description: 'A demo repo based on OpenAI API', 41 | theme_color: '#212129', 42 | background_color: '#ffffff', 43 | icons: [ 44 | { 45 | src: 'pwa-192.png', 46 | sizes: '192x192', 47 | type: 'image/png', 48 | }, 49 | { 50 | src: 'pwa-512.png', 51 | sizes: '512x512', 52 | type: 'image/png', 53 | }, 54 | { 55 | src: 'icon.svg', 56 | sizes: '32x32', 57 | type: 'image/svg', 58 | purpose: 'any maskable', 59 | }, 60 | ], 61 | }, 62 | client: { 63 | installPrompt: true, 64 | periodicSyncForUpdates: 20, 65 | }, 66 | devOptions: { 67 | enabled: true, 68 | }, 69 | }), 70 | ], 71 | }, 72 | }) 73 | -------------------------------------------------------------------------------- /src/pages/api/generate.ts: -------------------------------------------------------------------------------- 1 | // #vercel-disable-blocks 2 | import { ProxyAgent, fetch } from 'undici' 3 | // #vercel-end 4 | import { generatePayload, parseOpenAIStream } from '@/utils/openAI' 5 | import { verifySignature } from '@/utils/auth' 6 | import type { APIRoute } from 'astro' 7 | 8 | const apiKey = import.meta.env.OPENAI_API_KEY 9 | const httpsProxy = import.meta.env.HTTPS_PROXY 10 | const baseUrl = ((import.meta.env.OPENAI_API_BASE_URL) || 'https://api.openai.com').trim().replace(/\/$/, '') 11 | const sitePassword = import.meta.env.SITE_PASSWORD || '' 12 | const passList = sitePassword.split(',') || [] 13 | 14 | export const post: APIRoute = async(context) => { 15 | const body = await context.request.json() 16 | const { sign, time, messages, pass } = body 17 | if (!messages) { 18 | return new Response(JSON.stringify({ 19 | error: { 20 | message: 'No input text.', 21 | }, 22 | }), { status: 400 }) 23 | } 24 | if (sitePassword && !(sitePassword === pass || passList.includes(pass))) { 25 | return new Response(JSON.stringify({ 26 | error: { 27 | message: 'Invalid password.', 28 | }, 29 | }), { status: 401 }) 30 | } 31 | if (import.meta.env.PROD && !await verifySignature({ t: time, m: messages?.[messages.length - 1]?.content || '' }, sign)) { 32 | return new Response(JSON.stringify({ 33 | error: { 34 | message: 'Invalid signature.', 35 | }, 36 | }), { status: 401 }) 37 | } 38 | const initOptions = generatePayload(apiKey, messages) 39 | // #vercel-disable-blocks 40 | if (httpsProxy) 41 | initOptions.dispatcher = new ProxyAgent(httpsProxy) 42 | // #vercel-end 43 | 44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 45 | // @ts-expect-error 46 | const response = await fetch(`${baseUrl}/v1/chat/completions`, initOptions).catch((err: Error) => { 47 | console.error(err) 48 | return new Response(JSON.stringify({ 49 | error: { 50 | code: err.name, 51 | message: err.message, 52 | }, 53 | }), { status: 500 }) 54 | }) as Response 55 | 56 | return parseOpenAIStream(response) as Response 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/openAI.ts: -------------------------------------------------------------------------------- 1 | import { createParser } from 'eventsource-parser' 2 | import type { ParsedEvent, ReconnectInterval } from 'eventsource-parser' 3 | import type { ChatMessage } from '@/types' 4 | 5 | const model = import.meta.env.OPENAI_API_MODEL || 'gpt-3.5-turbo' 6 | 7 | export const generatePayload = (apiKey: string, messages: ChatMessage[]): RequestInit & { dispatcher?: any } => ({ 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | 'Authorization': `Bearer ${apiKey}`, 11 | }, 12 | method: 'POST', 13 | body: JSON.stringify({ 14 | model, 15 | messages, 16 | temperature: 0.6, 17 | stream: true, 18 | }), 19 | }) 20 | 21 | export const parseOpenAIStream = (rawResponse: Response) => { 22 | const encoder = new TextEncoder() 23 | const decoder = new TextDecoder() 24 | if (!rawResponse.ok) { 25 | return new Response(rawResponse.body, { 26 | status: rawResponse.status, 27 | statusText: rawResponse.statusText, 28 | }) 29 | } 30 | 31 | const stream = new ReadableStream({ 32 | async start(controller) { 33 | const streamParser = (event: ParsedEvent | ReconnectInterval) => { 34 | if (event.type === 'event') { 35 | const data = event.data 36 | if (data === '[DONE]') { 37 | controller.close() 38 | return 39 | } 40 | try { 41 | // response = { 42 | // id: 'chatcmpl-6pULPSegWhFgi0XQ1DtgA3zTa1WR6', 43 | // object: 'chat.completion.chunk', 44 | // created: 1677729391, 45 | // model: 'gpt-3.5-turbo-0301', 46 | // choices: [ 47 | // { delta: { content: '你' }, index: 0, finish_reason: null } 48 | // ], 49 | // } 50 | const json = JSON.parse(data) 51 | const text = json.choices[0].delta?.content || '' 52 | const queue = encoder.encode(text) 53 | controller.enqueue(queue) 54 | } catch (e) { 55 | controller.error(e) 56 | } 57 | } 58 | } 59 | 60 | const parser = createParser(streamParser) 61 | for await (const chunk of rawResponse.body as any) 62 | parser.feed(decoder.decode(chunk)) 63 | }, 64 | }) 65 | 66 | return new Response(stream) 67 | } 68 | -------------------------------------------------------------------------------- /src/components/SystemRoleSettings.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from 'solid-js' 2 | import IconEnv from './icons/Env' 3 | import IconX from './icons/X' 4 | import type { Accessor, Setter } from 'solid-js' 5 | 6 | interface Props { 7 | canEdit: Accessor 8 | systemRoleEditing: Accessor 9 | setSystemRoleEditing: Setter 10 | currentSystemRoleSettings: Accessor 11 | setCurrentSystemRoleSettings: Setter 12 | } 13 | 14 | export default (props: Props) => { 15 | let systemInputRef: HTMLTextAreaElement 16 | 17 | const handleButtonClick = () => { 18 | props.setCurrentSystemRoleSettings(systemInputRef.value) 19 | props.setSystemRoleEditing(false) 20 | } 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | }> 29 | props.setCurrentSystemRoleSettings('')} class="sys-edit-btn p-1 rd-50%" > 30 | 31 | System Role: 32 | 33 | 34 | {props.currentSystemRoleSettings()} 35 | 36 | 37 | 38 | 39 | props.setSystemRoleEditing(!props.systemRoleEditing())} class="sys-edit-btn"> 40 | 41 | Add System Role 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | System Role: 50 | 51 | Gently instruct the assistant and set the behavior of the assistant. 52 | 53 | 61 | 62 | 63 | Set 64 | 65 | 66 | 67 | 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | transformerDirectives, 8 | transformerVariantGroup, 9 | } from 'unocss' 10 | 11 | export default defineConfig({ 12 | presets: [ 13 | presetUno(), 14 | presetAttributify(), 15 | presetIcons({ 16 | scale: 1.1, 17 | cdn: 'https://esm.sh/', 18 | }), 19 | presetTypography({ 20 | cssExtend: { 21 | 'ul,ol': { 22 | 'padding-left': '2.25em', 23 | 'position': 'relative', 24 | }, 25 | }, 26 | }), 27 | ], 28 | transformers: [transformerVariantGroup(), transformerDirectives()], 29 | shortcuts: [{ 30 | 'fc': 'flex justify-center', 31 | 'fi': 'flex items-center', 32 | 'fb': 'flex justify-between', 33 | 'fcc': 'fc items-center', 34 | 'fie': 'fi justify-end', 35 | 'col-fcc': 'flex-col fcc', 36 | 'inline-fcc': 'inline-flex items-center justify-center', 37 | 'base-focus': 'focus:(bg-op-20 ring-0 outline-none)', 38 | 'b-slate-link': 'border-b border-(slate none) hover:border-dashed', 39 | 'gpt-title': 'text-2xl font-extrabold mr-1', 40 | 'gpt-subtitle': 'text-(2xl transparent) font-extrabold bg-(clip-text gradient-to-r) from-sky-400 to-emerald-600', 41 | 'gpt-copy-btn': 'absolute top-12px right-12px z-3 fcc border b-transparent w-8 h-8 p-2 bg-light-300 dark:bg-dark-300 op-90 cursor-pointer', 42 | 'gpt-copy-tips': 'op-0 h-7 bg-black px-2.5 py-1 box-border text-xs c-white fcc rounded absolute z-1 transition duration-600 whitespace-nowrap -top-8', 43 | 'gpt-retry-btn': 'fi gap-1 px-2 py-0.5 op-70 border border-slate rounded-md text-sm cursor-pointer hover:bg-slate/10', 44 | 'gpt-back-top-btn': 'fcc p-2.5 text-base rounded-md hover:bg-slate/10 fixed bottom-60px right-20px z-10 cursor-pointer transition-colors', 45 | 'gpt-back-bottom-btn': 'gpt-back-top-btn bottom-20px transform-rotate-180deg', 46 | 'gpt-password-input': 'px-4 py-3 h-12 rounded-sm bg-(slate op-15) base-focus', 47 | 'gpt-password-submit': 'fcc h-12 w-12 bg-slate cursor-pointer bg-op-20 hover:bg-op-50', 48 | 'gen-slate-btn': 'h-12 px-4 py-2 bg-(slate op-15) hover:bg-op-20 rounded-sm', 49 | 'gen-cb-wrapper': 'h-12 my-4 fcc gap-4 bg-(slate op-15) rounded-sm', 50 | 'gen-cb-stop': 'px-2 py-0.5 border border-slate rounded-md text-sm op-70 cursor-pointer hover:bg-slate/10', 51 | 'gen-text-wrapper': 'my-4 fc gap-2 transition-opacity', 52 | 'gen-textarea': 'w-full px-3 py-3 min-h-12 max-h-36 rounded-sm bg-(slate op-15) resize-none base-focus placeholder:op-50 dark:(placeholder:op-30) scroll-pa-8px', 53 | 'sys-edit-btn': 'inline-fcc gap-1 text-sm bg-slate/20 px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-slate/50', 54 | 'stick-btn-on': '!bg-$c-fg text-$c-bg hover:op-80', 55 | }], 56 | }) 57 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export interface Props { 3 | title: string; 4 | } 5 | 6 | const { title } = Astro.props; 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {title} 20 | 21 | { 22 | import.meta.env.HEAD_SCRIPTS 23 | ? ( 24 | 25 | ) 26 | : null 27 | } 28 | 29 | { 30 | import.meta.env.PROD && ( 31 | 32 | 33 | ) 34 | } 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 85 | 86 | 104 | -------------------------------------------------------------------------------- /src/components/MessageItem.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js' 2 | import MarkdownIt from 'markdown-it' 3 | import mdKatex from 'markdown-it-katex' 4 | import mdHighlight from 'markdown-it-highlightjs' 5 | import { useClipboard, useEventListener } from 'solidjs-use' 6 | import IconRefresh from './icons/Refresh' 7 | import type { Accessor } from 'solid-js' 8 | import type { ChatMessage } from '@/types' 9 | 10 | interface Props { 11 | role: ChatMessage['role'] 12 | message: Accessor | string 13 | showRetry?: Accessor 14 | onRetry?: () => void 15 | } 16 | 17 | export default ({ role, message, showRetry, onRetry }: Props) => { 18 | const roleClass = { 19 | system: 'bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300', 20 | user: 'bg-gradient-to-r from-purple-400 to-yellow-400', 21 | assistant: 'bg-gradient-to-r from-yellow-200 via-green-200 to-green-300', 22 | } 23 | const [source] = createSignal('') 24 | const { copy, copied } = useClipboard({ source, copiedDuring: 1000 }) 25 | 26 | useEventListener('click', (e) => { 27 | const el = e.target as HTMLElement 28 | let code = null 29 | 30 | if (el.matches('div > div.copy-btn')) { 31 | code = decodeURIComponent(el.dataset.code!) 32 | copy(code) 33 | } 34 | if (el.matches('div > div.copy-btn > svg')) { 35 | // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain 36 | code = decodeURIComponent(el.parentElement?.dataset.code!) 37 | copy(code) 38 | } 39 | }) 40 | 41 | const htmlString = () => { 42 | const md = MarkdownIt({ 43 | linkify: true, 44 | breaks: true, 45 | }).use(mdKatex).use(mdHighlight) 46 | const fence = md.renderer.rules.fence! 47 | md.renderer.rules.fence = (...args) => { 48 | const [tokens, idx] = args 49 | const token = tokens[idx] 50 | const rawCode = fence(...args) 51 | 52 | return ` 53 | 54 | 55 | 56 | ${copied() ? 'Copied' : 'Copy'} 57 | 58 | 59 | ${rawCode} 60 | ` 61 | } 62 | 63 | if (typeof message === 'function') 64 | return md.render(message()) 65 | else if (typeof message === 'string') 66 | return md.render(message) 67 | 68 | return '' 69 | } 70 | 71 | return ( 72 | 73 | 74 | 75 | 76 | 77 | {showRetry?.() && onRetry && ( 78 | 79 | 80 | 81 | Regenerate 82 | 83 | 84 | )} 85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/components/Themetoggle.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 54 | 55 | 90 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # ChatGPT-API Demo 2 | 3 | [English](./README.md) | 简体中文 4 | 5 | 一个基于 [OpenAI GPT-3.5 Turbo API](https://platform.openai.com/docs/guides/chat) 的 demo。 6 | 7 | **🍿 在线预览**: https://chatgpt.ddiu.me 8 | 9 | **🏖️ V2 版本(Beta)**: https://v2.chatgpt.ddiu.me 10 | 11 | > ⚠️ 注意: 我们的API密钥限制已用尽。所以演示站点现在不可用。 12 | 13 |  14 | 15 | ## 本地运行 16 | 17 | ### 前置环境 18 | 19 | 1. **Node**: 检查您的开发环境和部署环境是否都使用 `Node v18` 或更高版本。你可以使用 [nvm](https://github.com/nvm-sh/nvm) 管理本地多个 `node` 版本。 20 | ```bash 21 | node -v 22 | ``` 23 | 2. **PNPM**: 我们推荐使用 [pnpm](https://pnpm.io/) 来管理依赖,如果你从来没有安装过 pnpm,可以使用下面的命令安装: 24 | ```bash 25 | npm i -g pnpm 26 | ``` 27 | 3. **OPENAI_API_KEY**: 在运行此应用程序之前,您需要从 OpenAI 获取 API 密钥。您可以在 [https://beta.openai.com/signup](https://beta.openai.com/signup) 注册 API 密钥。 28 | 29 | ### 起步运行 30 | 31 | 1. 安装依赖 32 | ```bash 33 | pnpm install 34 | ``` 35 | 2. 复制 `.env.example` 文件,重命名为 `.env`,并添加你的 [OpenAI API key](https://platform.openai.com/account/api-keys) 到 `.env` 文件中 36 | ```bash 37 | OPENAI_API_KEY=sk-xxx... 38 | ``` 39 | 3. 运行应用,本地项目运行在 `http://localhost:3000/` 40 | ```bash 41 | pnpm run dev 42 | ``` 43 | 44 | ## 部署 45 | 46 | ### 部署在 Vercel 47 | 48 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fddiu8081%2Fchatgpt-demo&env=OPENAI_API_KEY&envDescription=OpenAI%20API%20Key&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys) 49 | 50 | 51 | 52 | > ###### 🔒 需要站点密码? 53 | > 54 | > 携带[`SITE_PASSWORD`](#environment-variables)进行部署 55 | > 56 | > 57 | 58 |  59 | 60 | ### 部署在 Netlify 61 | 62 | [](https://app.netlify.com/start/deploy?repository=https://github.com/ddiu8081/chatgpt-demo#OPENAI_API_KEY=&HTTPS_PROXY=&OPENAI_API_BASE_URL=&HEAD_SCRIPTS=&SECRET_KEY=&OPENAI_API_MODEL=&SITE_PASSWORD=) 63 | 64 | **分步部署教程:** 65 | 66 | 1. [Fork](https://github.com/ddiu8081/chatgpt-demo/fork) 此项目,前往 [https://app.netlify.com/start](https://app.netlify.com/start) 新建站点,选择你 `fork` 完成的项目,将其与 `GitHub` 帐户连接。 67 | 68 |  69 | 70 |  71 | 72 | 73 | 2. 选择要部署的分支,选择 `main` 分支, 在项目设置中配置环境变量,环境变量配置参考下文。 74 | 75 |  76 | 77 | 3. 选择默认的构建命令和输出目录,单击 `Deploy Site` 按钮开始部署站点。 78 | 79 |  80 | 81 | ### 部署在 Docker 82 | 部署之前请确认 `.env` 文件正常配置,环境变量参考下方文档, [Docker Hub address](https://hub.docker.com/r/ddiu8081/chatgpt-demo). 83 | 84 | **一键运行** 85 | ```bash 86 | docker run --name=chatgpt-demo -e OPENAI_API_KEY=YOUR_OPEN_API_KEY -p 3000:3000 -d ddiu8081/chatgpt-demo:latest 87 | ``` 88 | `-e` 在容器中定义环境变量。 89 | 90 | **使用 Docker compose** 91 | ```yml 92 | version: '3' 93 | 94 | services: 95 | chatgpt-demo: 96 | image: ddiu8081/chatgpt-demo:latest 97 | container_name: chatgpt-demo 98 | restart: always 99 | ports: 100 | - '3000:3000' 101 | environment: 102 | - OPENAI_API_KEY=YOUR_OPEN_API_KEY 103 | # - HTTPS_PROXY=YOUR_HTTPS_PROXY 104 | # - OPENAI_API_BASE_URL=YOUR_OPENAI_API_BASE_URL 105 | # - HEAD_SCRIPTS=YOUR_HEAD_SCRIPTS 106 | # - SECRET_KEY=YOUR_SECRET_KEY 107 | # - SITE_PASSWORD=YOUR_SITE_PASSWORD 108 | # - OPENAI_API_MODEL=YOUR_OPENAI_API_MODEL 109 | ``` 110 | 111 | ```bash 112 | # start 113 | docker compose up -d 114 | # down 115 | docker-compose down 116 | ``` 117 | 118 | ### 部署在更多的服务器 119 | 120 | 请参考官方部署文档:https://docs.astro.build/en/guides/deploy 121 | 122 | ## 环境变量 123 | 124 | 配置本地或者部署的环境变量 125 | 126 | | 名称 | 描述 | 默认 | 127 | | --- | --- | --- | 128 | | `OPENAI_API_KEY` | 你的 OpenAI API Key | `null` | 129 | | `HTTPS_PROXY` | 为 OpenAI API 提供代理. e.g. `http://127.0.0.1:7890` | `null` | 130 | | `OPENAI_API_BASE_URL` | 请求 OpenAI API 的自定义 Base URL. | `https://api.openai.com` | 131 | | `HEAD_SCRIPTS` | 在页面的 `` 之前注入分析或其他脚本 | `null` | 132 | | `SECRET_KEY` | 项目的秘密字符串。用于生成 API 调用的签名 | `null` | 133 | | `SITE_PASSWORD` | 为网站设置密码,支持使用英文逗号创建多个密码。如果未设置,则该网站将是公开的 | `null` | 134 | | `OPENAI_API_MODEL` | 使用的 OpenAI 模型. [模型列表](https://platform.openai.com/docs/api-reference/models/list) | `gpt-3.5-turbo` | 135 | 136 | ## 常见问题 137 | 138 | Q: TypeError: fetch failed (can't connect to OpenAI Api) 139 | 140 | A: 配置环境变量 `HTTPS_PROXY`,参考: https://github.com/ddiu8081/chatgpt-demo/issues/34 141 | 142 | Q: throw new TypeError(${context} is not a ReadableStream.) 143 | 144 | A: Node 版本需要在 `v18` 或者更高,参考: https://github.com/ddiu8081/chatgpt-demo/issues/65 145 | 146 | Q: Accelerate domestic access without the need for proxy deployment tutorial? 147 | 148 | A: 你可以参考此教程: https://github.com/ddiu8081/chatgpt-demo/discussions/270 149 | 150 | Q: `PWA` 不工作? 151 | 152 | A: 当前的 PWA 不支持 Netlify 部署,您可以选择 vercel 或 node 部署。 153 | 154 | ## 参与贡献 155 | 156 | 这个项目的存在要感谢所有做出贡献的人。 157 | 158 | 感谢我们所有的支持者!🙏 159 | 160 | [](https://github.com/ddiu8081/chatgpt-demo/graphs/contributors) 161 | 162 | ## License 163 | 164 | MIT © [ddiu8081](https://github.com/ddiu8081/chatgpt-demo/blob/main/LICENSE) 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatGPT-API Demo 2 | 3 | English | [简体中文](./README.zh-CN.md) 4 | 5 | A demo repo based on [OpenAI GPT-3.5 Turbo API.](https://platform.openai.com/docs/guides/chat) 6 | 7 | **🍿 Live preview**: https://chatgpt.ddiu.me 8 | 9 | **🏖️ V2 Version(Beta)**: https://v2.chatgpt.ddiu.me 10 | 11 | > ⚠️ Notice: Our API Key limit has been exhausted. So the demo site is not available now. 12 | 13 |  14 | 15 | ## Running Locally 16 | 17 | ### Pre environment 18 | 1. **Node**: Check that both your development environment and deployment environment are using `Node v18` or later. You can use [nvm](https://github.com/nvm-sh/nvm) to manage multiple `node` versions locally。 19 | ```bash 20 | node -v 21 | ``` 22 | 2. **PNPM**: We recommend using [pnpm](https://pnpm.io/) to manage dependencies. If you have never installed pnpm, you can install it with the following command: 23 | ```bash 24 | npm i -g pnpm 25 | ``` 26 | 3. **OPENAI_API_KEY**: Before running this application, you need to obtain the API key from OpenAI. You can register the API key at [https://beta.openai.com/signup](https://beta.openai.com/signup). 27 | 28 | ### Getting Started 29 | 30 | 1. Install dependencies 31 | ```bash 32 | pnpm install 33 | ``` 34 | 2. Copy the `.env.example` file, then rename it to `.env`, and add your [OpenAI API key](https://platform.openai.com/account/api-keys) to the `.env` file. 35 | ```bash 36 | OPENAI_API_KEY=sk-xxx... 37 | ``` 38 | 3. Run the application, the local project runs on `http://localhost:3000/` 39 | ```bash 40 | pnpm run dev 41 | ``` 42 | 43 | ## Deploy 44 | 45 | ### Deploy With Vercel 46 | 47 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fddiu8081%2Fchatgpt-demo&env=OPENAI_API_KEY&envDescription=OpenAI%20API%20Key&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys) 48 | 49 | 50 | 51 | > #### 🔒 Need website password? 52 | > 53 | > Deploy with the [`SITE_PASSWORD`](#environment-variables) 54 | > 55 | > 56 | 57 |  58 | 59 | 60 | ### Deploy With Netlify 61 | 62 | [](https://app.netlify.com/start/deploy?repository=https://github.com/ddiu8081/chatgpt-demo#OPENAI_API_KEY=&HTTPS_PROXY=&OPENAI_API_BASE_URL=&HEAD_SCRIPTS=&SECRET_KEY=&OPENAI_API_MODEL=&SITE_PASSWORD=) 63 | 64 | **Step-by-step deployment tutorial:** 65 | 66 | 1. [Fork](https://github.com/ddiu8081/chatgpt-demo/fork) this project,Go to [https://app.netlify.com/start](https://app.netlify.com/start) new Site, select the project you `forked` done, and connect it with your `GitHub` account. 67 | 68 |  69 | 70 |  71 | 72 | 73 | 2. Select the branch you want to deploy, then configure environment variables in the project settings. 74 | 75 |  76 | 77 | 3. Select the default build command and output directory, Click the `Deploy Site` button to start deploying the site。 78 | 79 |  80 | 81 | 82 | ### Deploy with Docker 83 | 84 | Environment variables refer to the documentation below. [Docker Hub address](https://hub.docker.com/r/ddiu8081/chatgpt-demo). 85 | 86 | **Direct run** 87 | ```bash 88 | docker run --name=chatgpt-demo -e OPENAI_API_KEY=YOUR_OPEN_API_KEY -p 3000:3000 -d ddiu8081/chatgpt-demo:latest 89 | ``` 90 | `-e` define environment variables in the container. 91 | 92 | 93 | **Docker compose** 94 | ```yml 95 | version: '3' 96 | 97 | services: 98 | chatgpt-demo: 99 | image: ddiu8081/chatgpt-demo:latest 100 | container_name: chatgpt-demo 101 | restart: always 102 | ports: 103 | - '3000:3000' 104 | environment: 105 | - OPENAI_API_KEY=YOUR_OPEN_API_KEY 106 | # - HTTPS_PROXY=YOUR_HTTPS_PROXY 107 | # - OPENAI_API_BASE_URL=YOUR_OPENAI_API_BASE_URL 108 | # - HEAD_SCRIPTS=YOUR_HEAD_SCRIPTS 109 | # - SECRET_KEY=YOUR_SECRET_KEY 110 | # - SITE_PASSWORD=YOUR_SITE_PASSWORD 111 | # - OPENAI_API_MODEL=YOUR_OPENAI_API_MODEL 112 | ``` 113 | 114 | ```bash 115 | # start 116 | docker compose up -d 117 | # down 118 | docker-compose down 119 | ``` 120 | 121 | ### Deploy on more servers 122 | 123 | Please refer to the official deployment documentation:https://docs.astro.build/en/guides/deploy 124 | 125 | ## Environment Variables 126 | 127 | You can control the website through environment variables. 128 | 129 | | Name | Description | Default | 130 | | --- | --- | --- | 131 | | `OPENAI_API_KEY` | Your API Key for OpenAI. | `null` | 132 | | `HTTPS_PROXY` | Provide proxy for OpenAI API. e.g. `http://127.0.0.1:7890` | `null` | 133 | | `OPENAI_API_BASE_URL` | Custom base url for OpenAI API. | `https://api.openai.com` | 134 | | `HEAD_SCRIPTS` | Inject analytics or other scripts before `` of the page | `null` | 135 | | `SECRET_KEY` | Secret string for the project. Use for generating signatures for API calls | `null` | 136 | | `SITE_PASSWORD` | Set password for site, support multiple password separated by comma. If not set, site will be public | `null` | 137 | | `OPENAI_API_MODEL` | ID of the model to use. [List models](https://platform.openai.com/docs/api-reference/models/list) | `gpt-3.5-turbo` | 138 | 139 | 140 | ## Frequently Asked Questions 141 | 142 | Q: TypeError: fetch failed (can't connect to OpenAI Api) 143 | 144 | A: Configure environment variables `HTTPS_PROXY`,reference: https://github.com/ddiu8081/chatgpt-demo/issues/34 145 | 146 | Q: throw new TypeError(${context} is not a ReadableStream.) 147 | 148 | A: The Node version needs to be `v18` or later,reference: https://github.com/ddiu8081/chatgpt-demo/issues/65 149 | 150 | Q: Accelerate domestic access without the need for proxy deployment tutorial? 151 | 152 | A: You can refer to this tutorial: https://github.com/ddiu8081/chatgpt-demo/discussions/270 153 | 154 | Q: `PWA` is not working? 155 | 156 | A: Current `PWA` does not support deployment on Netlify, you can choose vercel or node deployment. 157 | ## Contributing 158 | 159 | This project exists thanks to all those who contributed. 160 | 161 | Thank you to all our supporters!🙏 162 | 163 | [](https://github.com/ddiu8081/chatgpt-demo/graphs/contributors) 164 | 165 | ## License 166 | 167 | MIT © [ddiu8081](https://github.com/ddiu8081/chatgpt-demo/blob/main/LICENSE) 168 | -------------------------------------------------------------------------------- /src/components/Generator.tsx: -------------------------------------------------------------------------------- 1 | import { Index, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' 2 | import { useThrottleFn } from 'solidjs-use' 3 | import { generateSignature } from '@/utils/auth' 4 | import IconClear from './icons/Clear' 5 | import MessageItem from './MessageItem' 6 | import SystemRoleSettings from './SystemRoleSettings' 7 | import ErrorMessageItem from './ErrorMessageItem' 8 | import type { ChatMessage, ErrorMessage } from '@/types' 9 | 10 | export default () => { 11 | let inputRef: HTMLTextAreaElement 12 | const [currentSystemRoleSettings, setCurrentSystemRoleSettings] = createSignal('') 13 | const [systemRoleEditing, setSystemRoleEditing] = createSignal(false) 14 | const [messageList, setMessageList] = createSignal([]) 15 | const [currentError, setCurrentError] = createSignal() 16 | const [currentAssistantMessage, setCurrentAssistantMessage] = createSignal('') 17 | const [loading, setLoading] = createSignal(false) 18 | const [controller, setController] = createSignal(null) 19 | const [isStick, setStick] = createSignal(false) 20 | 21 | createEffect(() => (isStick() && smoothToBottom())) 22 | 23 | onMount(() => { 24 | let lastPostion = window.scrollY 25 | 26 | window.addEventListener('scroll', () => { 27 | const nowPostion = window.scrollY 28 | nowPostion < lastPostion && setStick(false) 29 | lastPostion = nowPostion 30 | }) 31 | 32 | try { 33 | if (localStorage.getItem('messageList')) 34 | setMessageList(JSON.parse(localStorage.getItem('messageList'))) 35 | 36 | if (localStorage.getItem('systemRoleSettings')) 37 | setCurrentSystemRoleSettings(localStorage.getItem('systemRoleSettings')) 38 | 39 | if (localStorage.getItem('stickToBottom') === 'stick') 40 | setStick(true) 41 | } catch (err) { 42 | console.error(err) 43 | } 44 | 45 | window.addEventListener('beforeunload', handleBeforeUnload) 46 | onCleanup(() => { 47 | window.removeEventListener('beforeunload', handleBeforeUnload) 48 | }) 49 | }) 50 | 51 | const handleBeforeUnload = () => { 52 | localStorage.setItem('messageList', JSON.stringify(messageList())) 53 | localStorage.setItem('systemRoleSettings', currentSystemRoleSettings()) 54 | isStick() ? localStorage.setItem('stickToBottom', 'stick') : localStorage.removeItem('stickToBottom') 55 | } 56 | 57 | const handleButtonClick = async() => { 58 | const inputValue = inputRef.value 59 | if (!inputValue) 60 | return 61 | 62 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 63 | // @ts-expect-error 64 | if (window?.umami) umami.trackEvent('chat_generate') 65 | inputRef.value = '' 66 | setMessageList([ 67 | ...messageList(), 68 | { 69 | role: 'user', 70 | content: inputValue, 71 | }, 72 | ]) 73 | requestWithLatestMessage() 74 | instantToBottom() 75 | } 76 | 77 | const smoothToBottom = useThrottleFn(() => { 78 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }) 79 | }, 300, false, true) 80 | 81 | const instantToBottom = () => { 82 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }) 83 | } 84 | 85 | const requestWithLatestMessage = async() => { 86 | setLoading(true) 87 | setCurrentAssistantMessage('') 88 | setCurrentError(null) 89 | const storagePassword = localStorage.getItem('pass') 90 | try { 91 | const controller = new AbortController() 92 | setController(controller) 93 | const requestMessageList = [...messageList()] 94 | if (currentSystemRoleSettings()) { 95 | requestMessageList.unshift({ 96 | role: 'system', 97 | content: currentSystemRoleSettings(), 98 | }) 99 | } 100 | const timestamp = Date.now() 101 | const response = await fetch('/api/generate', { 102 | method: 'POST', 103 | body: JSON.stringify({ 104 | messages: requestMessageList, 105 | time: timestamp, 106 | pass: storagePassword, 107 | sign: await generateSignature({ 108 | t: timestamp, 109 | m: requestMessageList?.[requestMessageList.length - 1]?.content || '', 110 | }), 111 | }), 112 | signal: controller.signal, 113 | }) 114 | if (!response.ok) { 115 | const error = await response.json() 116 | console.error(error.error) 117 | setCurrentError(error.error) 118 | throw new Error('Request failed') 119 | } 120 | const data = response.body 121 | if (!data) 122 | throw new Error('No data') 123 | 124 | const reader = data.getReader() 125 | const decoder = new TextDecoder('utf-8') 126 | let done = false 127 | 128 | while (!done) { 129 | const { value, done: readerDone } = await reader.read() 130 | if (value) { 131 | const char = decoder.decode(value) 132 | if (char === '\n' && currentAssistantMessage().endsWith('\n')) 133 | continue 134 | 135 | if (char) 136 | setCurrentAssistantMessage(currentAssistantMessage() + char) 137 | 138 | isStick() && instantToBottom() 139 | } 140 | done = readerDone 141 | } 142 | } catch (e) { 143 | console.error(e) 144 | setLoading(false) 145 | setController(null) 146 | return 147 | } 148 | archiveCurrentMessage() 149 | isStick() && instantToBottom() 150 | } 151 | 152 | const archiveCurrentMessage = () => { 153 | if (currentAssistantMessage()) { 154 | setMessageList([ 155 | ...messageList(), 156 | { 157 | role: 'assistant', 158 | content: currentAssistantMessage(), 159 | }, 160 | ]) 161 | setCurrentAssistantMessage('') 162 | setLoading(false) 163 | setController(null) 164 | inputRef.focus() 165 | } 166 | } 167 | 168 | const clear = () => { 169 | inputRef.value = '' 170 | inputRef.style.height = 'auto' 171 | setMessageList([]) 172 | setCurrentAssistantMessage('') 173 | setCurrentError(null) 174 | } 175 | 176 | const stopStreamFetch = () => { 177 | if (controller()) { 178 | controller().abort() 179 | archiveCurrentMessage() 180 | } 181 | } 182 | 183 | const retryLastFetch = () => { 184 | if (messageList().length > 0) { 185 | const lastMessage = messageList()[messageList().length - 1] 186 | if (lastMessage.role === 'assistant') 187 | setMessageList(messageList().slice(0, -1)) 188 | 189 | requestWithLatestMessage() 190 | } 191 | } 192 | 193 | const handleKeydown = (e: KeyboardEvent) => { 194 | if (e.isComposing || e.shiftKey) 195 | return 196 | 197 | if (e.keyCode === 13) { 198 | e.preventDefault() 199 | handleButtonClick() 200 | } 201 | } 202 | 203 | return ( 204 | 205 | messageList().length === 0} 207 | systemRoleEditing={systemRoleEditing} 208 | setSystemRoleEditing={setSystemRoleEditing} 209 | currentSystemRoleSettings={currentSystemRoleSettings} 210 | setCurrentSystemRoleSettings={setCurrentSystemRoleSettings} 211 | /> 212 | 213 | {(message, index) => ( 214 | (message().role === 'assistant' && index === messageList().length - 1)} 218 | onRetry={retryLastFetch} 219 | /> 220 | )} 221 | 222 | {currentAssistantMessage() && ( 223 | 227 | )} 228 | { currentError() && } 229 | ( 232 | 233 | AI is thinking... 234 | Stop 235 | 236 | )} 237 | > 238 | 239 | { 247 | inputRef.style.height = 'auto' 248 | inputRef.style.height = `${inputRef.scrollHeight}px` 249 | }} 250 | rows="1" 251 | class="gen-textarea" 252 | /> 253 | 254 | Send 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | setStick(!isStick())}> 264 | 265 | 266 | 267 | 268 | 269 | ) 270 | } 271 | --------------------------------------------------------------------------------
Based on OpenAI API (gpt-3.5-turbo).
Gently instruct the assistant and set the behavior of the assistant.