diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 53e4d9b2403..00000000000 --- a/.babelrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "presets": [ - [ - "next/babel", - { - "preset-env": { - "targets": { - "browsers": ["> 0.25%, not dead"] - } - } - } - ] - ] -} diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 60da41dd8c0..00000000000 --- a/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -# local env files -.env*.local - -# docker-compose env files -.env - -*.key -*.key.pub \ No newline at end of file diff --git a/.env.template b/.env.template deleted file mode 100644 index 166cc4ef4e4..00000000000 --- a/.env.template +++ /dev/null @@ -1,49 +0,0 @@ - -# Your openai api key. (required) -OPENAI_API_KEY=sk-xxxx - -# Access passsword, separated by comma. (optional) -CODE=your-password - -# You can start service behind a proxy -PROXY_URL=http://localhost:7890 - -# (optional) -# Default: Empty -# Googel Gemini Pro API key, set if you want to use Google Gemini Pro API. -GOOGLE_API_KEY= - -# (optional) -# Default: https://generativelanguage.googleapis.com/ -# Googel Gemini Pro API url without pathname, set if you want to customize Google Gemini Pro API url. -GOOGLE_URL= - -# Override openai api request base url. (optional) -# Default: https://api.openai.com -# Examples: http://your-openai-proxy.com -BASE_URL= - -# Specify OpenAI organization ID.(optional) -# Default: Empty -OPENAI_ORG_ID= - -# (optional) -# Default: Empty -# If you do not want users to use GPT-4, set this value to 1. -DISABLE_GPT4= - -# (optional) -# Default: Empty -# If you do not want users to input their own API key, set this value to 1. -HIDE_USER_API_KEY= - -# (optional) -# Default: Empty -# If you do want users to query balance, set this value to 1. -ENABLE_BALANCE_QUERY= - -# (optional) -# Default: Empty -# If you want to disable parse settings from url, set this value to 1. -DISABLE_FAST_LINK= - diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 08975255475..00000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -public/serviceWorker.js \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d229e86f250..00000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "plugins": ["prettier"] -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index bdba257d20a..00000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Bug report -description: Create a report to help us improve -title: "[Bug] " -labels: ["bug"] - -body: - - type: markdown - attributes: - value: "## Describe the bug" - - type: textarea - id: bug-description - attributes: - label: "Bug Description" - description: "A clear and concise description of what the bug is." - placeholder: "Explain the bug..." - validations: - required: true - - - type: markdown - attributes: - value: "## To Reproduce" - - type: textarea - id: steps-to-reproduce - attributes: - label: "Steps to Reproduce" - description: "Steps to reproduce the behavior:" - placeholder: | - 1. Go to '...' - 2. Click on '....' - 3. Scroll down to '....' - 4. See error - validations: - required: true - - - type: markdown - attributes: - value: "## Expected behavior" - - type: textarea - id: expected-behavior - attributes: - label: "Expected Behavior" - description: "A clear and concise description of what you expected to happen." - placeholder: "Describe what you expected to happen..." - validations: - required: true - - - type: markdown - attributes: - value: "## Screenshots" - - type: textarea - id: screenshots - attributes: - label: "Screenshots" - description: "If applicable, add screenshots to help explain your problem." - placeholder: "Paste your screenshots here or write 'N/A' if not applicable..." - validations: - required: false - - - type: markdown - attributes: - value: "## Deployment" - - type: checkboxes - id: deployment - attributes: - label: "Deployment Method" - description: "Please select the deployment method you are using." - options: - - label: "Docker" - - label: "Vercel" - - label: "Server" - - - type: markdown - attributes: - value: "## Desktop (please complete the following information):" - - type: input - id: desktop-os - attributes: - label: "Desktop OS" - description: "Your desktop operating system." - placeholder: "e.g., Windows 10" - validations: - required: false - - type: input - id: desktop-browser - attributes: - label: "Desktop Browser" - description: "Your desktop browser." - placeholder: "e.g., Chrome, Safari" - validations: - required: false - - type: input - id: desktop-version - attributes: - label: "Desktop Browser Version" - description: "Version of your desktop browser." - placeholder: "e.g., 89.0" - validations: - required: false - - - type: markdown - attributes: - value: "## Smartphone (please complete the following information):" - - type: input - id: smartphone-device - attributes: - label: "Smartphone Device" - description: "Your smartphone device." - placeholder: "e.g., iPhone X" - validations: - required: false - - type: input - id: smartphone-os - attributes: - label: "Smartphone OS" - description: "Your smartphone operating system." - placeholder: "e.g., iOS 14.4" - validations: - required: false - - type: input - id: smartphone-browser - attributes: - label: "Smartphone Browser" - description: "Your smartphone browser." - placeholder: "e.g., Safari" - validations: - required: false - - type: input - id: smartphone-version - attributes: - label: "Smartphone Browser Version" - description: "Version of your smartphone browser." - placeholder: "e.g., 14" - validations: - required: false - - - type: markdown - attributes: - value: "## Additional Logs" - - type: textarea - id: additional-logs - attributes: - label: "Additional Logs" - description: "Add any logs about the problem here." - placeholder: "Paste any relevant logs here..." - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 49978133074..00000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Feature request -description: Suggest an idea for this project -title: "[Feature Request]: " -labels: ["enhancement"] - -body: - - type: markdown - attributes: - value: "## Is your feature request related to a problem? Please describe." - - type: textarea - id: problem-description - attributes: - label: Problem Description - description: "A clear and concise description of what the problem is. Example: I'm always frustrated when [...]" - placeholder: "Explain the problem you are facing..." - validations: - required: true - - - type: markdown - attributes: - value: "## Describe the solution you'd like" - - type: textarea - id: desired-solution - attributes: - label: Solution Description - description: A clear and concise description of what you want to happen. - placeholder: "Describe the solution you'd like..." - validations: - required: true - - - type: markdown - attributes: - value: "## Describe alternatives you've considered" - - type: textarea - id: alternatives-considered - attributes: - label: Alternatives Considered - description: A clear and concise description of any alternative solutions or features you've considered. - placeholder: "Describe any alternative solutions or features you've considered..." - validations: - required: false - - - type: markdown - attributes: - value: "## Additional context" - - type: textarea - id: additional-context - attributes: - label: Additional Context - description: Add any other context or screenshots about the feature request here. - placeholder: "Add any other context or screenshots about the feature request here..." - validations: - required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 3a3cce5763e..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml deleted file mode 100644 index 7e74cf04595..00000000000 --- a/.github/workflows/app.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Release App - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - create-release: - permissions: - contents: write - runs-on: ubuntu-latest - outputs: - release_id: ${{ steps.create-release.outputs.result }} - - steps: - - uses: actions/checkout@v3 - - name: setup node - uses: actions/setup-node@v3 - with: - node-version: 18 - - name: get version - run: echo "PACKAGE_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').package.version")" >> $GITHUB_ENV - - name: create release - id: create-release - uses: actions/github-script@v6 - with: - script: | - const { data } = await github.rest.repos.getLatestRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - }) - return data.id - - build-tauri: - needs: create-release - permissions: - contents: write - strategy: - fail-fast: false - matrix: - config: - - os: ubuntu-latest - arch: x86_64 - rust_target: x86_64-unknown-linux-gnu - - os: macos-latest - arch: aarch64 - rust_target: x86_64-apple-darwin,aarch64-apple-darwin - - os: windows-latest - arch: x86_64 - rust_target: x86_64-pc-windows-msvc - - runs-on: ${{ matrix.config.os }} - steps: - - uses: actions/checkout@v3 - - name: setup node - uses: actions/setup-node@v3 - with: - node-version: 18 - cache: 'yarn' - - name: install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.config.rust_target }} - - uses: Swatinem/rust-cache@v2 - with: - key: ${{ matrix.config.os }} - - name: install dependencies (ubuntu only) - if: matrix.config.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - - name: install frontend dependencies - run: yarn install # change this to npm or pnpm depending on which one you use - - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} - TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - with: - releaseId: ${{ needs.create-release.outputs.release_id }} - args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }} - - publish-release: - permissions: - contents: write - runs-on: ubuntu-latest - needs: [create-release, build-tauri] - - steps: - - name: publish release - id: publish-release - uses: actions/github-script@v6 - env: - release_id: ${{ needs.create-release.outputs.release_id }} - with: - script: | - github.rest.repos.updateRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: process.env.release_id, - draft: false, - prerelease: false - }) diff --git a/.github/workflows/deploy_preview.yml b/.github/workflows/deploy_preview.yml deleted file mode 100644 index bdbb78c27c5..00000000000 --- a/.github/workflows/deploy_preview.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: VercelPreviewDeployment - -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - -env: - VERCEL_TEAM: ${{ secrets.VERCEL_TEAM }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_PR_DOMAIN_SUFFIX: ${{ secrets.VERCEL_PR_DOMAIN_SUFFIX }} - -permissions: - contents: read - statuses: write - pull-requests: write - -jobs: - deploy-preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> "$GITHUB_OUTPUT" - id: extract_branch - - - name: Hash branch name - uses: pplanel/hash-calculator-action@v1.3.1 - id: hash_branch - with: - input: ${{ steps.extract_branch.outputs.branch }} - method: MD5 - - - name: Set Environment Variables - id: set_env - if: github.event_name == 'pull_request_target' - run: | - echo "VERCEL_ALIAS_DOMAIN=${{ github.event.pull_request.number }}-${{ github.workflow }}.${VERCEL_PR_DOMAIN_SUFFIX}" >> $GITHUB_OUTPUT - - - name: Install Vercel CLI - run: npm install --global vercel@latest - - - name: Cache dependencies - uses: actions/cache@v2 - id: cache-npm - with: - path: ~/.npm - key: npm-${{ hashFiles('package-lock.json') }} - restore-keys: npm- - - - name: Pull Vercel Environment Information - run: vercel pull --yes --environment=preview --token=${VERCEL_TOKEN} - - - name: Deploy Project Artifacts to Vercel - id: vercel - env: - META_TAG: ${{ steps.hash_branch.outputs.digest }}-${{ github.run_number }}-${{ github.run_attempt}} - run: | - set -e - vercel pull --yes --environment=preview --token=${VERCEL_TOKEN} - vercel build --token=${VERCEL_TOKEN} - vercel deploy --prebuilt --archive=tgz --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }} - - DEFAULT_URL=$(vercel ls --token=${VERCEL_TOKEN} --meta base_hash=${{ env.META_TAG }}) - ALIAS_URL=$(vercel alias set ${DEFAULT_URL} ${{ steps.set_env.outputs.VERCEL_ALIAS_DOMAIN }} --token=${VERCEL_TOKEN} --scope ${VERCEL_TEAM}| awk '{print $3}') - - echo "New preview URL: ${DEFAULT_URL}" - echo "New alias URL: ${ALIAS_URL}" - echo "VERCEL_URL=${ALIAS_URL}" >> "$GITHUB_OUTPUT" - - - uses: mshick/add-pr-comment@v2 - with: - message: | - Your build has completed! - - [Preview deployment](${{ steps.vercel.outputs.VERCEL_URL }}) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 8ac96f19356..00000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Publish Docker image - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - push_to_registry: - name: Push Docker image to Docker Hub - runs-on: ubuntu-latest - steps: - - - name: Check out the repo - uses: actions/checkout@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: yidadaa/chatgpt-next-web - tags: | - type=raw,value=latest - type=ref,event=tag - - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - - name: Build and push Docker image - uses: docker/build-push-action@v4 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - diff --git a/.github/workflows/issue-translator.yml b/.github/workflows/issue-translator.yml deleted file mode 100644 index 560f66d3443..00000000000 --- a/.github/workflows/issue-translator.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Issue Translator -on: - issue_comment: - types: [created] - issues: - types: [opened] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: usthe/issues-translate-action@v2.7 - with: - IS_MODIFY_TITLE: false - CUSTOM_BOT_NOTE: Bot detected the issue body's language is not English, translate it automatically. diff --git a/.github/workflows/remove_deploy_preview.yml b/.github/workflows/remove_deploy_preview.yml deleted file mode 100644 index 4846cda2d6a..00000000000 --- a/.github/workflows/remove_deploy_preview.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Removedeploypreview - -permissions: - contents: read - statuses: write - pull-requests: write - -env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - -on: - pull_request_target: - types: - - closed - -jobs: - delete-deployments: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT - id: extract_branch - - - name: Hash branch name - uses: pplanel/hash-calculator-action@v1.3.1 - id: hash_branch - with: - input: ${{ steps.extract_branch.outputs.branch }} - method: MD5 - - - name: Call the delete-deployment-preview.sh script - env: - META_TAG: ${{ steps.hash_branch.outputs.digest }} - run: | - bash ./scripts/delete-deployment-preview.sh diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml deleted file mode 100644 index e04e30adbd6..00000000000 --- a/.github/workflows/sync.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Upstream Sync - -permissions: - contents: write - -on: - schedule: - - cron: "0 0 * * *" # every day - workflow_dispatch: - -jobs: - sync_latest_from_upstream: - name: Sync latest commits from upstream repo - runs-on: ubuntu-latest - if: ${{ github.event.repository.fork }} - - steps: - # Step 1: run a standard checkout action - - name: Checkout target repo - uses: actions/checkout@v3 - - # Step 2: run the sync action - - name: Sync upstream changes - id: sync - uses: aormsby/Fork-Sync-With-Upstream-action@v3.4 - with: - upstream_sync_repo: ChatGPTNextWeb/ChatGPT-Next-Web - upstream_sync_branch: main - target_sync_branch: main - target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set - - # Set test_mode true to run tests instead of the true action!! - test_mode: false - - - name: Sync check - if: failure() - run: | - echo "[Error] 由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次,详细教程请查看:https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/README_CN.md#%E6%89%93%E5%BC%80%E8%87%AA%E5%8A%A8%E6%9B%B4%E6%96%B0" - echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork. Please refer to the detailed tutorial for instructions: https://github.com/Yidadaa/ChatGPT-Next-Web#enable-automatic-updates" - exit 1 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index b00b0e325a4..00000000000 --- a/.gitignore +++ /dev/null @@ -1,46 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts -dev - -.vscode -.idea - -# docker-compose env files -.env - -*.key -*.key.pub \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index d81f2dab15c..00000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,11 +0,0 @@ -# This configuration file was automatically generated by Gitpod. -# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) -# and commit this file to your remote git repository to share the goodness with others. - -# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart - -tasks: - - init: yarn install && yarn run dev - command: yarn run dev - - diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 0312b76025e..00000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -npx lint-staged \ No newline at end of file diff --git a/.lintstagedrc.json b/.lintstagedrc.json deleted file mode 100644 index 58784bad829..00000000000 --- a/.lintstagedrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "./app/**/*.{js,ts,jsx,tsx,json,html,css,md}": [ - "eslint --fix", - "prettier --write" - ] -} diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 95cc75ffaec..00000000000 --- a/.prettierrc.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - printWidth: 80, - tabWidth: 2, - useTabs: false, - semi: true, - singleQuote: false, - trailingComma: 'all', - bracketSpacing: true, - arrowParens: 'always', -}; diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 7712d974276..00000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -flynn.zhang@foxmail.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 436d39d821d..00000000000 --- a/Dockerfile +++ /dev/null @@ -1,64 +0,0 @@ -FROM node:18-alpine AS base - -FROM base AS deps - -RUN apk add --no-cache libc6-compat - -WORKDIR /app - -COPY package.json yarn.lock ./ - -RUN yarn config set registry 'https://registry.npmmirror.com/' -RUN yarn install - -FROM base AS builder - -RUN apk update && apk add --no-cache git - -ENV OPENAI_API_KEY="" -ENV GOOGLE_API_KEY="" -ENV CODE="" - -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . - -RUN yarn build - -FROM base AS runner -WORKDIR /app - -RUN apk add proxychains-ng - -ENV PROXY_URL="" -ENV OPENAI_API_KEY="" -ENV GOOGLE_API_KEY="" -ENV CODE="" - -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/.next/server ./.next/server - -EXPOSE 3000 - -CMD if [ -n "$PROXY_URL" ]; then \ - export HOSTNAME="127.0.0.1"; \ - protocol=$(echo $PROXY_URL | cut -d: -f1); \ - host=$(echo $PROXY_URL | cut -d/ -f3 | cut -d: -f1); \ - port=$(echo $PROXY_URL | cut -d: -f3); \ - conf=/etc/proxychains.conf; \ - echo "strict_chain" > $conf; \ - echo "proxy_dns" >> $conf; \ - echo "remote_dns_subnet 224" >> $conf; \ - echo "tcp_read_time_out 15000" >> $conf; \ - echo "tcp_connect_time_out 8000" >> $conf; \ - echo "localnet 127.0.0.0/255.0.0.0" >> $conf; \ - echo "localnet ::1/128" >> $conf; \ - echo "[ProxyList]" >> $conf; \ - echo "$protocol $host $port" >> $conf; \ - cat /etc/proxychains.conf; \ - proxychains -f $conf node server.js; \ - else \ - node server.js; \ - fi diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 542e91f4e77..00000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Zhang Yifei - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 3ac537abca8..00000000000 --- a/README.md +++ /dev/null @@ -1,372 +0,0 @@ -
-icon - -

NextChat (ChatGPT Next Web)

- -English / [简体中文](./README_CN.md) - -One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support. - -一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。 - -[![Web][Web-image]][web-url] -[![Windows][Windows-image]][download-url] -[![MacOS][MacOS-image]][download-url] -[![Linux][Linux-image]][download-url] - -[Web App](https://app.nextchat.dev/) / [Desktop App](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [Discord](https://discord.gg/YCkeafCafC) / [Twitter](https://twitter.com/NextChatDev) - -[网页版](https://app.nextchat.dev/) / [客户端](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) / [反馈](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) - -[web-url]: https://chatgpt.nextweb.fun -[download-url]: https://github.com/Yidadaa/ChatGPT-Next-Web/releases -[Web-image]: https://img.shields.io/badge/Web-PWA-orange?logo=microsoftedge -[Windows-image]: https://img.shields.io/badge/-Windows-blue?logo=windows -[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple -[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) - -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA) - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) - -![cover](./docs/images/cover.png) - -
- -## Features - -- **Deploy for free with one-click** on Vercel in under 1 minute -- Compact client (~5MB) on Linux/Windows/MacOS, [download it now](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) -- Fully compatible with self-deployed LLMs, recommended for use with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) or [LocalAI](https://github.com/go-skynet/LocalAI) -- Privacy first, all data is stored locally in the browser -- Markdown support: LaTex, mermaid, code highlight, etc. -- Responsive design, dark mode and PWA -- Fast first screen loading speed (~100kb), support streaming response -- New in v2: create, share and debug your chat tools with prompt templates (mask) -- Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts) -- Automatically compresses chat history to support long conversations while also saving your tokens -- I18n: English, 简体中文, 繁体中文, 日本語, Français, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia - -## Roadmap - -- [x] System Prompt: pin a user defined prompt as system prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) -- [x] User Prompt: user can edit and save custom prompts to prompt list -- [x] Prompt Template: create a new chat with pre-defined in-context prompts [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993) -- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) -- [x] Desktop App with tauri -- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc. -- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) - -## What's New - -- 🚀 v2.10.1 support Google Gemini Pro model. -- 🚀 v2.9.11 you can use azure endpoint now. -- 🚀 v2.8 now we have a client that runs across all platforms! -- 🚀 v2.7 let's share conversations as image, or share to ShareGPT! -- 🚀 v2.0 is released, now you can create prompt templates, turn your ideas into reality! Read this: [ChatGPT Prompt Engineering Tips: Zero, One and Few Shot Prompting](https://www.allabtai.com/prompt-engineering-tips-zero-one-and-few-shot-prompting/). - -## 主要功能 - -- 在 1 分钟内使用 Vercel **免费一键部署** -- 提供体积极小(~5MB)的跨平台客户端(Linux/Windows/MacOS), [下载地址](https://github.com/Yidadaa/ChatGPT-Next-Web/releases) -- 完整的 Markdown 支持:LaTex 公式、Mermaid 流程图、代码高亮等等 -- 精心设计的 UI,响应式设计,支持深色模式,支持 PWA -- 极快的首屏加载速度(~100kb),支持流式响应 -- 隐私安全,所有数据保存在用户浏览器本地 -- 预制角色功能(面具),方便地创建、分享和调试你的个性化对话 -- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts) -- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话 -- 多国语言支持:English, 简体中文, 繁体中文, 日本語, Español, Italiano, Türkçe, Deutsch, Tiếng Việt, Русский, Čeština, 한국어, Indonesia -- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问 - -## 开发计划 - -- [x] 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138) -- [x] 允许用户自行编辑内置 Prompt 列表 -- [x] 预制角色:使用预制角色快速定制新对话 [#993](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/993) -- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) -- [x] 使用 tauri 打包桌面应用 -- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm) -- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) - -## 最新动态 - -- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。 -- 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com -- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。 -- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。 -- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。 - -## Get Started - -> [简体中文 > 如何开始使用](./README_CN.md#开始使用) - -1. Get [OpenAI API Key](https://platform.openai.com/account/api-keys); -2. Click - [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web), remember that `CODE` is your page password; -3. Enjoy :) - -## FAQ - -[简体中文 > 常见问题](./docs/faq-cn.md) - -[English > FAQ](./docs/faq-en.md) - -## Keep Updated - -> [简体中文 > 如何保持代码更新](./README_CN.md#保持更新) - -If you have deployed your own project with just one click following the steps above, you may encounter the issue of "Updates Available" constantly showing up. This is because Vercel will create a new project for you by default instead of forking this project, resulting in the inability to detect updates correctly. - -We recommend that you follow the steps below to re-deploy: - -- Delete the original repository; -- Use the fork button in the upper right corner of the page to fork this project; -- Choose and deploy in Vercel again, [please see the detailed tutorial](./docs/vercel-cn.md). - -### Enable Automatic Updates - -> If you encounter a failure of Upstream Sync execution, please manually sync fork once. - -After forking the project, due to the limitations imposed by GitHub, you need to manually enable Workflows and Upstream Sync Action on the Actions page of the forked project. Once enabled, automatic updates will be scheduled every hour: - -![Automatic Updates](./docs/images/enable-actions.jpg) - -![Enable Automatic Updates](./docs/images/enable-actions-sync.jpg) - -### Manually Updating Code - -If you want to update instantly, you can check out the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. - -You can star or watch this project or follow author to get release notifications in time. - -## Access Password - -> [简体中文 > 如何增加访问密码](./README_CN.md#配置页面访问密码) - -This project provides limited access control. Please add an environment variable named `CODE` on the vercel environment variables page. The value should be passwords separated by comma like this: - -``` -code1,code2,code3 -``` - -After adding or modifying this environment variable, please redeploy the project for the changes to take effect. - -## Environment Variables - -> [简体中文 > 如何配置 api key、访问密码、接口代理](./README_CN.md#环境变量) - -### `CODE` (optional) - -Access password, separated by comma. - -### `OPENAI_API_KEY` (required) - -Your openai api key, join multiple api keys with comma. - -### `BASE_URL` (optional) - -> Default: `https://api.openai.com` - -> Examples: `http://your-openai-proxy.com` - -Override openai api request base url. - -### `OPENAI_ORG_ID` (optional) - -Specify OpenAI organization ID. - -### `AZURE_URL` (optional) - -> Example: https://{azure-resource-url}/openai/deployments/{deploy-name} - -Azure deploy url. - -### `AZURE_API_KEY` (optional) - -Azure Api Key. - -### `AZURE_API_VERSION` (optional) - -Azure Api Version, find it at [Azure Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions). - -### `GOOGLE_API_KEY` (optional) - -Google Gemini Pro Api Key. - -### `GOOGLE_URL` (optional) - -Google Gemini Pro Api Url. - -### `HIDE_USER_API_KEY` (optional) - -> Default: Empty - -If you do not want users to input their own API key, set this value to 1. - -### `DISABLE_GPT4` (optional) - -> Default: Empty - -If you do not want users to use GPT-4, set this value to 1. - -### `ENABLE_BALANCE_QUERY` (optional) - -> Default: Empty - -If you do want users to query balance, set this value to 1, or you should set it to 0. - -### `DISABLE_FAST_LINK` (optional) - -> Default: Empty - -If you want to disable parse settings from url, set this to 1. - -### `CUSTOM_MODELS` (optional) - -> Default: Empty -> Example: `+llama,+claude-2,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` means add `llama, claude-2` to model list, and remove `gpt-3.5-turbo` from list, and display `gpt-4-1106-preview` as `gpt-4-turbo`. - -To control custom models, use `+` to add a custom model, use `-` to hide a model, use `name=displayName` to customize model name, separated by comma. - -User `-all` to disable all default models, `+all` to enable all default models. - -## Requirements - -NodeJS >= 18, Docker >= 20 - -## Development - -> [简体中文 > 如何进行二次开发](./README_CN.md#开发) - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) - -Before starting development, you must create a new `.env.local` file at project root, and place your api key into it: - -``` -OPENAI_API_KEY= - -# if you are not able to access openai service, use this BASE_URL -BASE_URL=https://chatgpt1.nextweb.fun/api/proxy -``` - -### Local Development - -```shell -# 1. install nodejs and yarn first -# 2. config local env vars in `.env.local` -# 3. run -yarn install -yarn dev -``` - -## Deployment - -> [简体中文 > 如何部署到私人服务器](./README_CN.md#部署) - -### Docker (Recommended) - -```shell -docker pull yidadaa/chatgpt-next-web - -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=your-password \ - yidadaa/chatgpt-next-web -``` - -You can start service behind a proxy: - -```shell -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=your-password \ - -e PROXY_URL=http://localhost:7890 \ - yidadaa/chatgpt-next-web -``` - -If your proxy needs password, use: - -```shell --e PROXY_URL="http://127.0.0.1:7890 user pass" -``` - -### Shell - -```shell -bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) -``` - -## Synchronizing Chat Records (UpStash) - -| [简体中文](./docs/synchronise-chat-logs-cn.md) | [English](./docs/synchronise-chat-logs-en.md) | [Italiano](./docs/synchronise-chat-logs-es.md) | [日本語](./docs/synchronise-chat-logs-ja.md) | [한국어](./docs/synchronise-chat-logs-ko.md) - -## Documentation - -> Please go to the [docs][./docs] directory for more documentation instructions. - -- [Deploy with cloudflare (Deprecated)](./docs/cloudflare-pages-en.md) -- [Frequent Ask Questions](./docs/faq-en.md) -- [How to add a new translation](./docs/translation.md) -- [How to use Vercel (No English)](./docs/vercel-cn.md) -- [User Manual (Only Chinese, WIP)](./docs/user-manual-cn.md) - -## Screenshots - -![Settings](./docs/images/settings.png) - -![More](./docs/images/more.png) - -## Translation - -If you want to add a new translation, read this [document](./docs/translation.md). - -## Donation - -[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) - -## Special Thanks - -### Sponsor - -> 仅列出捐赠金额 >= 100RMB 的用户。 - -[@mushan0x0](https://github.com/mushan0x0) -[@ClarenceDan](https://github.com/ClarenceDan) -[@zhangjia](https://github.com/zhangjia) -[@hoochanlon](https://github.com/hoochanlon) -[@relativequantum](https://github.com/relativequantum) -[@desenmeng](https://github.com/desenmeng) -[@webees](https://github.com/webees) -[@chazzhou](https://github.com/chazzhou) -[@hauy](https://github.com/hauy) -[@Corwin006](https://github.com/Corwin006) -[@yankunsong](https://github.com/yankunsong) -[@ypwhs](https://github.com/ypwhs) -[@fxxxchao](https://github.com/fxxxchao) -[@hotic](https://github.com/hotic) -[@WingCH](https://github.com/WingCH) -[@jtung4](https://github.com/jtung4) -[@micozhu](https://github.com/micozhu) -[@jhansion](https://github.com/jhansion) -[@Sha1rholder](https://github.com/Sha1rholder) -[@AnsonHyq](https://github.com/AnsonHyq) -[@synwith](https://github.com/synwith) -[@piksonGit](https://github.com/piksonGit) -[@ouyangzhiping](https://github.com/ouyangzhiping) -[@wenjiavv](https://github.com/wenjiavv) -[@LeXwDeX](https://github.com/LeXwDeX) -[@Licoy](https://github.com/Licoy) -[@shangmin2009](https://github.com/shangmin2009) - -### Contributors - - - - - -## LICENSE - -[MIT](https://opensource.org/license/mit/) diff --git a/README_CN.md b/README_CN.md deleted file mode 100644 index 4acefefa518..00000000000 --- a/README_CN.md +++ /dev/null @@ -1,223 +0,0 @@ -
-预览 - -

NextChat

- -一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。 - -[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) - -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA) - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) - -![主界面](./docs/images/cover.png) - -
- -## 开始使用 - -1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); -2. 点击右侧按钮开始部署: - [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE; -3. 部署完毕后,即可开始使用; -4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 - -## 保持更新 - -如果你按照上述步骤一键部署了自己的项目,可能会发现总是提示“存在更新”的问题,这是由于 Vercel 会默认为你创建一个新项目而不是 fork 本项目,这会导致无法正确地检测更新。 -推荐你按照下列步骤重新部署: - -- 删除掉原先的仓库; -- 使用页面右上角的 fork 按钮,fork 本项目; -- 在 Vercel 重新选择并部署,[请查看详细教程](./docs/vercel-cn.md#如何新建项目)。 - -### 打开自动更新 - -> 如果你遇到了 Upstream Sync 执行错误,请手动 Sync Fork 一次! - -当你 fork 项目之后,由于 Github 的限制,需要手动去你 fork 后的项目的 Actions 页面启用 Workflows,并启用 Upstream Sync Action,启用之后即可开启每小时定时自动更新: - -![自动更新](./docs/images/enable-actions.jpg) - -![启用自动更新](./docs/images/enable-actions-sync.jpg) - -### 手动更新代码 - -如果你想让手动立即更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步。 - -你可以 star/watch 本项目或者 follow 作者来及时获得新功能更新通知。 - -## 配置页面访问密码 - -> 配置密码后,用户需要在设置页手动填写访问码才可以正常聊天,否则会通过消息提示未授权状态。 - -> **警告**:请务必将密码的位数设置得足够长,最好 7 位以上,否则[会被爆破](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。 - -本项目提供有限的权限控制功能,请在 Vercel 项目控制面板的环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义密码: - -``` -code1,code2,code3 -``` - -增加或修改该环境变量后,请**重新部署**项目使改动生效。 - -## 环境变量 - -> 本项目大多数配置项都通过环境变量来设置,教程:[如何修改 Vercel 环境变量](./docs/vercel-cn.md)。 - -### `OPENAI_API_KEY` (必填项) - -OpanAI 密钥,你在 openai 账户页面申请的 api key,使用英文逗号隔开多个 key,这样可以随机轮询这些 key。 - -### `CODE` (可选) - -访问密码,可选,可以使用逗号隔开多个密码。 - -**警告**:如果不填写此项,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。 - -### `BASE_URL` (可选) - -> Default: `https://api.openai.com` - -> Examples: `http://your-openai-proxy.com` - -OpenAI 接口代理 URL,如果你手动配置了 openai 接口代理,请填写此选项。 - -> 如果遇到 ssl 证书问题,请将 `BASE_URL` 的协议设置为 http。 - -### `OPENAI_ORG_ID` (可选) - -指定 OpenAI 中的组织 ID。 - -### `AZURE_URL` (可选) - -> 形如:https://{azure-resource-url}/openai/deployments/{deploy-name} - -Azure 部署地址。 - -### `AZURE_API_KEY` (可选) - -Azure 密钥。 - -### `AZURE_API_VERSION` (可选) - -Azure Api 版本,你可以在这里找到:[Azure 文档](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#chat-completions)。 - -### `GOOGLE_API_KEY` (optional) - -Google Gemini Pro 密钥. - -### `GOOGLE_URL` (optional) - -Google Gemini Pro Api Url. - -### `HIDE_USER_API_KEY` (可选) - -如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。 - -### `DISABLE_GPT4` (可选) - -如果你不想让用户使用 GPT-4,将此环境变量设置为 1 即可。 - -### `ENABLE_BALANCE_QUERY` (可选) - -如果你想启用余额查询功能,将此环境变量设置为 1 即可。 - -### `DISABLE_FAST_LINK` (可选) - -如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。 - -### `CUSTOM_MODELS` (可选) - -> 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。 -> 如果你想先禁用所有模型,再启用指定模型,可以使用 `-all,+gpt-3.5-turbo`,则表示仅启用 `gpt-3.5-turbo` - -用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。 - -## 开发 - -点击下方按钮,开始二次开发: - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) - -在开始写代码之前,需要在项目根目录新建一个 `.env.local` 文件,里面填入环境变量: - -``` -OPENAI_API_KEY= - -# 中国大陆用户,可以使用本项目自带的代理进行开发,你也可以自由选择其他代理地址 -BASE_URL=https://b.nextweb.fun/api/proxy -``` - -### 本地开发 - -1. 安装 nodejs 18 和 yarn,具体细节请询问 ChatGPT; -2. 执行 `yarn install && yarn dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署! -3. 如果你想本地部署,请使用 `yarn install && yarn build && yarn start` 命令,你可以配合 pm2 来守护进程,防止被杀死,详情询问 ChatGPT。 - -## 部署 - -### 容器部署 (推荐) - -> Docker 版本需要在 20 及其以上,否则会提示找不到镜像。 - -> ⚠️ 注意:docker 版本在大多数时间都会落后最新的版本 1 到 2 天,所以部署后会持续出现“存在更新”的提示,属于正常现象。 - -```shell -docker pull yidadaa/chatgpt-next-web - -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=页面访问密码 \ - yidadaa/chatgpt-next-web -``` - -你也可以指定 proxy: - -```shell -docker run -d -p 3000:3000 \ - -e OPENAI_API_KEY=sk-xxxx \ - -e CODE=页面访问密码 \ - --net=host \ - -e PROXY_URL=http://127.0.0.1:7890 \ - yidadaa/chatgpt-next-web -``` - -如果你的本地代理需要账号密码,可以使用: - -```shell --e PROXY_URL="http://127.0.0.1:7890 user password" -``` - -如果你需要指定其他环境变量,请自行在上述命令中增加 `-e 环境变量=环境变量值` 来指定。 - -### 本地部署 - -在控制台运行下方命令: - -```shell -bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/scripts/setup.sh) -``` - -⚠️ 注意:如果你安装过程中遇到了问题,请使用 docker 部署。 - -## 鸣谢 - -### 捐赠者 - -> 见英文版。 - -### 贡献者 - -[见项目贡献者列表](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors) - -### 相关项目 - -- [one-api](https://github.com/songquanpeng/one-api): 一站式大模型额度管理平台,支持市面上所有主流大语言模型 - -## 开源协议 - -[MIT](https://opensource.org/license/mit/) diff --git a/android-chrome-192x192.png b/android-chrome-192x192.png new file mode 100644 index 00000000000..1a2a76b4fff Binary files /dev/null and b/android-chrome-192x192.png differ diff --git a/android-chrome-512x512.png b/android-chrome-512x512.png new file mode 100644 index 00000000000..a5594de6145 Binary files /dev/null and b/android-chrome-512x512.png differ diff --git a/app/api/auth.ts b/app/api/auth.ts deleted file mode 100644 index 16c8034eb55..00000000000 --- a/app/api/auth.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NextRequest } from "next/server"; -import { getServerSideConfig } from "../config/server"; -import md5 from "spark-md5"; -import { ACCESS_CODE_PREFIX, ModelProvider } from "../constant"; - -function getIP(req: NextRequest) { - let ip = req.ip ?? req.headers.get("x-real-ip"); - const forwardedFor = req.headers.get("x-forwarded-for"); - - if (!ip && forwardedFor) { - ip = forwardedFor.split(",").at(0) ?? ""; - } - - return ip; -} - -function parseApiKey(bearToken: string) { - const token = bearToken.trim().replaceAll("Bearer ", "").trim(); - const isApiKey = !token.startsWith(ACCESS_CODE_PREFIX); - - return { - accessCode: isApiKey ? "" : token.slice(ACCESS_CODE_PREFIX.length), - apiKey: isApiKey ? token : "", - }; -} - -export function auth(req: NextRequest, modelProvider: ModelProvider) { - const authToken = req.headers.get("Authorization") ?? ""; - - // check if it is openai api key or user token - const { accessCode, apiKey } = parseApiKey(authToken); - - const hashedCode = md5.hash(accessCode ?? "").trim(); - - const serverConfig = getServerSideConfig(); - console.log("[Auth] allowed hashed codes: ", [...serverConfig.codes]); - console.log("[Auth] got access code:", accessCode); - console.log("[Auth] hashed access code:", hashedCode); - console.log("[User IP] ", getIP(req)); - console.log("[Time] ", new Date().toLocaleString()); - - if (serverConfig.needCode && !serverConfig.codes.has(hashedCode) && !apiKey) { - return { - error: true, - msg: !accessCode ? "empty access code" : "wrong access code", - }; - } - - if (serverConfig.hideUserApiKey && !!apiKey) { - return { - error: true, - msg: "you are not allowed to access with your own api key", - }; - } - - // if user does not provide an api key, inject system api key - if (!apiKey) { - const serverConfig = getServerSideConfig(); - - const systemApiKey = - modelProvider === ModelProvider.GeminiPro - ? serverConfig.googleApiKey - : serverConfig.isAzure - ? serverConfig.azureApiKey - : serverConfig.apiKey; - if (systemApiKey) { - console.log("[Auth] use system api key"); - req.headers.set("Authorization", `Bearer ${systemApiKey}`); - } else { - console.log("[Auth] admin did not provide an api key"); - } - } else { - console.log("[Auth] use user api key"); - } - - return { - error: false, - }; -} diff --git a/app/api/common.ts b/app/api/common.ts deleted file mode 100644 index ca8406bb361..00000000000 --- a/app/api/common.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getServerSideConfig } from "../config/server"; -import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant"; -import { collectModelTable } from "../utils/model"; -import { makeAzurePath } from "../azure"; - -const serverConfig = getServerSideConfig(); - -export async function requestOpenai(req: NextRequest) { - const controller = new AbortController(); - - var authValue, - authHeaderName = ""; - if (serverConfig.isAzure) { - authValue = - req.headers - .get("Authorization") - ?.trim() - .replaceAll("Bearer ", "") - .trim() ?? ""; - - authHeaderName = "api-key"; - } else { - authValue = req.headers.get("Authorization") ?? ""; - authHeaderName = "Authorization"; - } - - let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll( - "/api/openai/", - "", - ); - - let baseUrl = - serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL; - - if (!baseUrl.startsWith("http")) { - baseUrl = `https://${baseUrl}`; - } - - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.slice(0, -1); - } - - console.log("[Proxy] ", path); - console.log("[Base Url]", baseUrl); - // this fix [Org ID] undefined in server side if not using custom point - if (serverConfig.openaiOrgId !== undefined) { - console.log("[Org ID]", serverConfig.openaiOrgId); - } - - const timeoutId = setTimeout( - () => { - controller.abort(); - }, - 10 * 60 * 1000, - ); - - if (serverConfig.isAzure) { - if (!serverConfig.azureApiVersion) { - return NextResponse.json({ - error: true, - message: `missing AZURE_API_VERSION in server env vars`, - }); - } - path = makeAzurePath(path, serverConfig.azureApiVersion); - } - - const fetchUrl = `${baseUrl}/${path}`; - const fetchOptions: RequestInit = { - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - [authHeaderName]: authValue, - ...(serverConfig.openaiOrgId && { - "OpenAI-Organization": serverConfig.openaiOrgId, - }), - }, - method: req.method, - body: req.body, - // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body - redirect: "manual", - // @ts-ignore - duplex: "half", - signal: controller.signal, - }; - - // #1815 try to refuse gpt4 request - if (serverConfig.customModels && req.body) { - try { - const modelTable = collectModelTable( - DEFAULT_MODELS, - serverConfig.customModels, - ); - const clonedBody = await req.text(); - fetchOptions.body = clonedBody; - - const jsonBody = JSON.parse(clonedBody) as { model?: string }; - - // not undefined and is false - if (modelTable[jsonBody?.model ?? ""].available === false) { - return NextResponse.json( - { - error: true, - message: `you are not allowed to use ${jsonBody?.model} model`, - }, - { - status: 403, - }, - ); - } - } catch (e) { - console.error("[OpenAI] gpt4 filter", e); - } - } - - try { - const res = await fetch(fetchUrl, fetchOptions); - - // to prevent browser prompt for credentials - const newHeaders = new Headers(res.headers); - newHeaders.delete("www-authenticate"); - // to disable nginx buffering - newHeaders.set("X-Accel-Buffering", "no"); - - // The latest version of the OpenAI API forced the content-encoding to be "br" in json response - // So if the streaming is disabled, we need to remove the content-encoding header - // Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header - // The browser will try to decode the response with brotli and fail - newHeaders.delete("content-encoding"); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: newHeaders, - }); - } finally { - clearTimeout(timeoutId); - } -} diff --git a/app/api/config/route.ts b/app/api/config/route.ts deleted file mode 100644 index db84fba175a..00000000000 --- a/app/api/config/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; - -import { getServerSideConfig } from "../../config/server"; - -const serverConfig = getServerSideConfig(); - -// Danger! Do not hard code any secret value here! -// 警告!不要在这里写入任何敏感信息! -const DANGER_CONFIG = { - needCode: serverConfig.needCode, - hideUserApiKey: serverConfig.hideUserApiKey, - disableGPT4: serverConfig.disableGPT4, - hideBalanceQuery: serverConfig.hideBalanceQuery, - disableFastLink: serverConfig.disableFastLink, - customModels: serverConfig.customModels, -}; - -declare global { - type DangerConfig = typeof DANGER_CONFIG; -} - -async function handle() { - return NextResponse.json(DANGER_CONFIG); -} - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; diff --git a/app/api/cors/[...path]/route.ts b/app/api/cors/[...path]/route.ts deleted file mode 100644 index 1f70d663082..00000000000 --- a/app/api/cors/[...path]/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -async function handle( - req: NextRequest, - { params }: { params: { path: string[] } }, -) { - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - - const [protocol, ...subpath] = params.path; - const targetUrl = `${protocol}://${subpath.join("/")}`; - - const method = req.headers.get("method") ?? undefined; - const shouldNotHaveBody = ["get", "head"].includes( - method?.toLowerCase() ?? "", - ); - - const fetchOptions: RequestInit = { - headers: { - authorization: req.headers.get("authorization") ?? "", - }, - body: shouldNotHaveBody ? null : req.body, - method, - // @ts-ignore - duplex: "half", - }; - - const fetchResult = await fetch(targetUrl, fetchOptions); - - console.log("[Any Proxy]", targetUrl, { - status: fetchResult.status, - statusText: fetchResult.statusText, - }); - - return fetchResult; -} - -export const POST = handle; -export const GET = handle; -export const OPTIONS = handle; - -export const runtime = "edge"; diff --git a/app/api/google/[...path]/route.ts b/app/api/google/[...path]/route.ts deleted file mode 100644 index ebd19289129..00000000000 --- a/app/api/google/[...path]/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; -import { getServerSideConfig } from "@/app/config/server"; -import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant"; - -async function handle( - req: NextRequest, - { params }: { params: { path: string[] } }, -) { - console.log("[Google Route] params ", params); - - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - - const controller = new AbortController(); - - const serverConfig = getServerSideConfig(); - - let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL; - - if (!baseUrl.startsWith("http")) { - baseUrl = `https://${baseUrl}`; - } - - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.slice(0, -1); - } - - let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", ""); - - console.log("[Proxy] ", path); - console.log("[Base Url]", baseUrl); - - const timeoutId = setTimeout( - () => { - controller.abort(); - }, - 10 * 60 * 1000, - ); - - const authResult = auth(req, ModelProvider.GeminiPro); - if (authResult.error) { - return NextResponse.json(authResult, { - status: 401, - }); - } - - const bearToken = req.headers.get("Authorization") ?? ""; - const token = bearToken.trim().replaceAll("Bearer ", "").trim(); - - const key = token ? token : serverConfig.googleApiKey; - - if (!key) { - return NextResponse.json( - { - error: true, - message: `missing GOOGLE_API_KEY in server env vars`, - }, - { - status: 401, - }, - ); - } - - const fetchUrl = `${baseUrl}/${path}?key=${key}`; - const fetchOptions: RequestInit = { - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }, - method: req.method, - body: req.body, - // to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body - redirect: "manual", - // @ts-ignore - duplex: "half", - signal: controller.signal, - }; - - try { - const res = await fetch(fetchUrl, fetchOptions); - // to prevent browser prompt for credentials - const newHeaders = new Headers(res.headers); - newHeaders.delete("www-authenticate"); - // to disable nginx buffering - newHeaders.set("X-Accel-Buffering", "no"); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: newHeaders, - }); - } finally { - clearTimeout(timeoutId); - } -} - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "bom1", - "cle1", - "cpt1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; diff --git a/app/api/openai/[...path]/route.ts b/app/api/openai/[...path]/route.ts deleted file mode 100644 index 77059c151fc..00000000000 --- a/app/api/openai/[...path]/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { type OpenAIListModelResponse } from "@/app/client/platforms/openai"; -import { getServerSideConfig } from "@/app/config/server"; -import { ModelProvider, OpenaiPath } from "@/app/constant"; -import { prettyObject } from "@/app/utils/format"; -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "../../auth"; -import { requestOpenai } from "../../common"; - -const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); - -function getModels(remoteModelRes: OpenAIListModelResponse) { - const config = getServerSideConfig(); - - if (config.disableGPT4) { - remoteModelRes.data = remoteModelRes.data.filter( - (m) => !m.id.startsWith("gpt-4"), - ); - } - - return remoteModelRes; -} - -async function handle( - req: NextRequest, - { params }: { params: { path: string[] } }, -) { - console.log("[OpenAI Route] params ", params); - - if (req.method === "OPTIONS") { - return NextResponse.json({ body: "OK" }, { status: 200 }); - } - - const subpath = params.path.join("/"); - - if (!ALLOWD_PATH.has(subpath)) { - console.log("[OpenAI Route] forbidden path ", subpath); - return NextResponse.json( - { - error: true, - msg: "you are not allowed to request " + subpath, - }, - { - status: 403, - }, - ); - } - - const authResult = auth(req, ModelProvider.GPT); - if (authResult.error) { - return NextResponse.json(authResult, { - status: 401, - }); - } - - try { - const response = await requestOpenai(req); - - // list models - if (subpath === OpenaiPath.ListModelPath && response.status === 200) { - const resJson = (await response.json()) as OpenAIListModelResponse; - const availableModels = getModels(resJson); - return NextResponse.json(availableModels, { - status: response.status, - }); - } - - return response; - } catch (e) { - console.error("[OpenAI] ", e); - return NextResponse.json(prettyObject(e)); - } -} - -export const GET = handle; -export const POST = handle; - -export const runtime = "edge"; -export const preferredRegion = [ - "arn1", - "bom1", - "cdg1", - "cle1", - "cpt1", - "dub1", - "fra1", - "gru1", - "hnd1", - "iad1", - "icn1", - "kix1", - "lhr1", - "pdx1", - "sfo1", - "sin1", - "syd1", -]; diff --git a/app/azure.ts b/app/azure.ts deleted file mode 100644 index 48406c55ba5..00000000000 --- a/app/azure.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function makeAzurePath(path: string, apiVersion: string) { - // should omit /v1 prefix - path = path.replaceAll("v1/", ""); - - // should add api-key to query string - path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`; - - return path; -} diff --git a/app/client/api.ts b/app/client/api.ts deleted file mode 100644 index 9133e2a6431..00000000000 --- a/app/client/api.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { getClientConfig } from "../config/client"; -import { - ACCESS_CODE_PREFIX, - Azure, - ModelProvider, - ServiceProvider, -} from "../constant"; -import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; -import { ChatGPTApi } from "./platforms/openai"; -import { GeminiProApi } from "./platforms/google"; -export const ROLES = ["system", "user", "assistant"] as const; -export type MessageRole = (typeof ROLES)[number]; - -export const Models = ["gpt-3.5-turbo", "gpt-4"] as const; -export type ChatModel = ModelType; - -export interface MultimodalContent { - type: "text" | "image_url"; - text?: string; - image_url?: { - url: string; - }; -} - -export interface RequestMessage { - role: MessageRole; - content: string | MultimodalContent[]; -} - -export interface LLMConfig { - model: string; - temperature?: number; - top_p?: number; - stream?: boolean; - presence_penalty?: number; - frequency_penalty?: number; -} - -export interface ChatOptions { - messages: RequestMessage[]; - config: LLMConfig; - - onUpdate?: (message: string, chunk: string) => void; - onFinish: (message: string) => void; - onError?: (err: Error) => void; - onController?: (controller: AbortController) => void; -} - -export interface LLMUsage { - used: number; - total: number; -} - -export interface LLMModel { - name: string; - available: boolean; - provider: LLMModelProvider; -} - -export interface LLMModelProvider { - id: string; - providerName: string; - providerType: string; -} - -export abstract class LLMApi { - abstract chat(options: ChatOptions): Promise; - abstract usage(): Promise; - abstract models(): Promise; -} - -type ProviderName = "openai" | "azure" | "claude" | "palm"; - -interface Model { - name: string; - provider: ProviderName; - ctxlen: number; -} - -interface ChatProvider { - name: ProviderName; - apiConfig: { - baseUrl: string; - apiKey: string; - summaryModel: Model; - }; - models: Model[]; - - chat: () => void; - usage: () => void; -} - -export class ClientApi { - public llm: LLMApi; - - constructor(provider: ModelProvider = ModelProvider.GPT) { - if (provider === ModelProvider.GeminiPro) { - this.llm = new GeminiProApi(); - return; - } - this.llm = new ChatGPTApi(); - } - - config() {} - - prompts() {} - - masks() {} - - async share(messages: ChatMessage[], avatarUrl: string | null = null) { - const msgs = messages - .map((m) => ({ - from: m.role === "user" ? "human" : "gpt", - value: m.content, - })) - .concat([ - { - from: "human", - value: - "Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web", - }, - ]); - // 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用 - // Please do not modify this message - - console.log("[Share]", messages, msgs); - const clientConfig = getClientConfig(); - const proxyUrl = "/sharegpt"; - const rawUrl = "https://sharegpt.com/api/conversations"; - const shareUrl = clientConfig?.isApp ? rawUrl : proxyUrl; - const res = await fetch(shareUrl, { - body: JSON.stringify({ - avatarUrl, - items: msgs, - }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", - }); - - const resJson = await res.json(); - console.log("[Share]", resJson); - if (resJson.id) { - return `https://shareg.pt/${resJson.id}`; - } - } -} - -export function getHeaders() { - const accessStore = useAccessStore.getState(); - const headers: Record = { - "Content-Type": "application/json", - "x-requested-with": "XMLHttpRequest", - Accept: "application/json", - }; - const modelConfig = useChatStore.getState().currentSession().mask.modelConfig; - const isGoogle = modelConfig.model.startsWith("gemini"); - const isAzure = accessStore.provider === ServiceProvider.Azure; - const authHeader = isAzure ? "api-key" : "Authorization"; - const apiKey = isGoogle - ? accessStore.googleApiKey - : isAzure - ? accessStore.azureApiKey - : accessStore.openaiApiKey; - const clientConfig = getClientConfig(); - const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`; - const validString = (x: string) => x && x.length > 0; - - // when using google api in app, not set auth header - if (!(isGoogle && clientConfig?.isApp)) { - // use user's api key first - if (validString(apiKey)) { - headers[authHeader] = makeBearer(apiKey); - } else if ( - accessStore.enabledAccessControl() && - validString(accessStore.accessCode) - ) { - headers[authHeader] = makeBearer( - ACCESS_CODE_PREFIX + accessStore.accessCode, - ); - } - } - - return headers; -} diff --git a/app/client/controller.ts b/app/client/controller.ts deleted file mode 100644 index a2e00173dd0..00000000000 --- a/app/client/controller.ts +++ /dev/null @@ -1,37 +0,0 @@ -// To store message streaming controller -export const ChatControllerPool = { - controllers: {} as Record, - - addController( - sessionId: string, - messageId: string, - controller: AbortController, - ) { - const key = this.key(sessionId, messageId); - this.controllers[key] = controller; - return key; - }, - - stop(sessionId: string, messageId: string) { - const key = this.key(sessionId, messageId); - const controller = this.controllers[key]; - controller?.abort(); - }, - - stopAll() { - Object.values(this.controllers).forEach((v) => v.abort()); - }, - - hasPending() { - return Object.values(this.controllers).length > 0; - }, - - remove(sessionId: string, messageId: string) { - const key = this.key(sessionId, messageId); - delete this.controllers[key]; - }, - - key(sessionId: string, messageIndex: string) { - return `${sessionId},${messageIndex}`; - }, -}; diff --git a/app/client/platforms/google.ts b/app/client/platforms/google.ts deleted file mode 100644 index 848e5cd3f0d..00000000000 --- a/app/client/platforms/google.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant"; -import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api"; -import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; -import { getClientConfig } from "@/app/config/client"; -import { DEFAULT_API_HOST } from "@/app/constant"; -import { - getMessageTextContent, - getMessageImages, - isVisionModel, -} from "@/app/utils"; - -export class GeminiProApi implements LLMApi { - extractMessage(res: any) { - console.log("[Response] gemini-pro response: ", res); - - return ( - res?.candidates?.at(0)?.content?.parts.at(0)?.text || - res?.error?.message || - "" - ); - } - async chat(options: ChatOptions): Promise { - // const apiClient = this; - const visionModel = isVisionModel(options.config.model); - let multimodal = false; - const messages = options.messages.map((v) => { - let parts: any[] = [{ text: getMessageTextContent(v) }]; - if (visionModel) { - const images = getMessageImages(v); - if (images.length > 0) { - multimodal = true; - parts = parts.concat( - images.map((image) => { - const imageType = image.split(";")[0].split(":")[1]; - const imageData = image.split(",")[1]; - return { - inline_data: { - mime_type: imageType, - data: imageData, - }, - }; - }), - ); - } - } - return { - role: v.role.replace("assistant", "model").replace("system", "user"), - parts: parts, - }; - }); - - // google requires that role in neighboring messages must not be the same - for (let i = 0; i < messages.length - 1; ) { - // Check if current and next item both have the role "model" - if (messages[i].role === messages[i + 1].role) { - // Concatenate the 'parts' of the current and next item - messages[i].parts = messages[i].parts.concat(messages[i + 1].parts); - // Remove the next item - messages.splice(i + 1, 1); - } else { - // Move to the next item - i++; - } - } - // if (visionModel && messages.length > 1) { - // options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision")); - // } - const modelConfig = { - ...useAppConfig.getState().modelConfig, - ...useChatStore.getState().currentSession().mask.modelConfig, - ...{ - model: options.config.model, - }, - }; - const requestPayload = { - contents: messages, - generationConfig: { - // stopSequences: [ - // "Title" - // ], - temperature: modelConfig.temperature, - maxOutputTokens: modelConfig.max_tokens, - topP: modelConfig.top_p, - // "topK": modelConfig.top_k, - }, - safetySettings: [ - { - category: "HARM_CATEGORY_HARASSMENT", - threshold: "BLOCK_ONLY_HIGH", - }, - { - category: "HARM_CATEGORY_HATE_SPEECH", - threshold: "BLOCK_ONLY_HIGH", - }, - { - category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", - threshold: "BLOCK_ONLY_HIGH", - }, - { - category: "HARM_CATEGORY_DANGEROUS_CONTENT", - threshold: "BLOCK_ONLY_HIGH", - }, - ], - }; - - const accessStore = useAccessStore.getState(); - let baseUrl = accessStore.googleUrl; - const isApp = !!getClientConfig()?.isApp; - - let shouldStream = !!options.config.stream; - const controller = new AbortController(); - options.onController?.(controller); - try { - let googleChatPath = visionModel - ? Google.VisionChatPath - : Google.ChatPath; - let chatPath = this.path(googleChatPath); - - // let baseUrl = accessStore.googleUrl; - - if (!baseUrl) { - baseUrl = isApp - ? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath - : chatPath; - } - - if (isApp) { - baseUrl += `?key=${accessStore.googleApiKey}`; - } - const chatPayload = { - method: "POST", - body: JSON.stringify(requestPayload), - signal: controller.signal, - headers: getHeaders(), - }; - - // make a fetch request - const requestTimeoutId = setTimeout( - () => controller.abort(), - REQUEST_TIMEOUT_MS, - ); - if (shouldStream) { - let responseText = ""; - let remainText = ""; - let finished = false; - - let existingTexts: string[] = []; - const finish = () => { - finished = true; - options.onFinish(existingTexts.join("")); - }; - - // animate response to make it looks smooth - function animateResponseText() { - if (finished || controller.signal.aborted) { - responseText += remainText; - finish(); - return; - } - - if (remainText.length > 0) { - const fetchCount = Math.max(1, Math.round(remainText.length / 60)); - const fetchText = remainText.slice(0, fetchCount); - responseText += fetchText; - remainText = remainText.slice(fetchCount); - options.onUpdate?.(responseText, fetchText); - } - - requestAnimationFrame(animateResponseText); - } - - // start animaion - animateResponseText(); - - fetch( - baseUrl.replace("generateContent", "streamGenerateContent"), - chatPayload, - ) - .then((response) => { - const reader = response?.body?.getReader(); - const decoder = new TextDecoder(); - let partialData = ""; - - return reader?.read().then(function processText({ - done, - value, - }): Promise { - if (done) { - if (response.status !== 200) { - try { - let data = JSON.parse(ensureProperEnding(partialData)); - if (data && data[0].error) { - options.onError?.(new Error(data[0].error.message)); - } else { - options.onError?.(new Error("Request failed")); - } - } catch (_) { - options.onError?.(new Error("Request failed")); - } - } - - console.log("Stream complete"); - // options.onFinish(responseText + remainText); - finished = true; - return Promise.resolve(); - } - - partialData += decoder.decode(value, { stream: true }); - - try { - let data = JSON.parse(ensureProperEnding(partialData)); - - const textArray = data.reduce( - (acc: string[], item: { candidates: any[] }) => { - const texts = item.candidates.map((candidate) => - candidate.content.parts - .map((part: { text: any }) => part.text) - .join(""), - ); - return acc.concat(texts); - }, - [], - ); - - if (textArray.length > existingTexts.length) { - const deltaArray = textArray.slice(existingTexts.length); - existingTexts = textArray; - remainText += deltaArray.join(""); - } - } catch (error) { - // console.log("[Response Animation] error: ", error,partialData); - // skip error message when parsing json - } - - return reader.read().then(processText); - }); - }) - .catch((error) => { - console.error("Error:", error); - }); - } else { - const res = await fetch(baseUrl, chatPayload); - clearTimeout(requestTimeoutId); - const resJson = await res.json(); - if (resJson?.promptFeedback?.blockReason) { - // being blocked - options.onError?.( - new Error( - "Message is being blocked for reason: " + - resJson.promptFeedback.blockReason, - ), - ); - } - const message = this.extractMessage(resJson); - options.onFinish(message); - } - } catch (e) { - console.log("[Request] failed to make a chat request", e); - options.onError?.(e as Error); - } - } - usage(): Promise { - throw new Error("Method not implemented."); - } - async models(): Promise { - return []; - } - path(path: string): string { - return "/api/google/" + path; - } -} - -function ensureProperEnding(str: string) { - if (str.startsWith("[") && !str.endsWith("]")) { - return str + "]"; - } - return str; -} diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts deleted file mode 100644 index 919716bfb0a..00000000000 --- a/app/client/platforms/openai.ts +++ /dev/null @@ -1,352 +0,0 @@ -"use client"; -import { - ApiPath, - DEFAULT_API_HOST, - DEFAULT_MODELS, - OpenaiPath, - REQUEST_TIMEOUT_MS, - ServiceProvider, -} from "@/app/constant"; -import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; - -import { - ChatOptions, - getHeaders, - LLMApi, - LLMModel, - LLMUsage, - MultimodalContent, -} from "../api"; -import Locale from "../../locales"; -import { - EventStreamContentType, - fetchEventSource, -} from "@fortaine/fetch-event-source"; -import { prettyObject } from "@/app/utils/format"; -import { getClientConfig } from "@/app/config/client"; -import { makeAzurePath } from "@/app/azure"; -import { - getMessageTextContent, - getMessageImages, - isVisionModel, -} from "@/app/utils"; - -export interface OpenAIListModelResponse { - object: string; - data: Array<{ - id: string; - object: string; - root: string; - }>; -} - -export class ChatGPTApi implements LLMApi { - private disableListModels = true; - - path(path: string): string { - const accessStore = useAccessStore.getState(); - - const isAzure = accessStore.provider === ServiceProvider.Azure; - - if (isAzure && !accessStore.isValidAzure()) { - throw Error( - "incomplete azure config, please check it in your settings page", - ); - } - - let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl; - - if (baseUrl.length === 0) { - const isApp = !!getClientConfig()?.isApp; - baseUrl = isApp - ? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI - : ApiPath.OpenAI; - } - - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.slice(0, baseUrl.length - 1); - } - if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) { - baseUrl = "https://" + baseUrl; - } - - if (isAzure) { - path = makeAzurePath(path, accessStore.azureApiVersion); - } - - console.log("[Proxy Endpoint] ", baseUrl, path); - - return [baseUrl, path].join("/"); - } - - extractMessage(res: any) { - return res.choices?.at(0)?.message?.content ?? ""; - } - - async chat(options: ChatOptions) { - const visionModel = isVisionModel(options.config.model); - const messages = options.messages.map((v) => ({ - role: v.role, - content: visionModel ? v.content : getMessageTextContent(v), - })); - - const modelConfig = { - ...useAppConfig.getState().modelConfig, - ...useChatStore.getState().currentSession().mask.modelConfig, - ...{ - model: options.config.model, - }, - }; - - const requestPayload = { - messages, - stream: options.config.stream, - model: modelConfig.model, - temperature: modelConfig.temperature, - presence_penalty: modelConfig.presence_penalty, - frequency_penalty: modelConfig.frequency_penalty, - top_p: modelConfig.top_p, - // max_tokens: Math.max(modelConfig.max_tokens, 1024), - // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore. - }; - - console.log("[Request] openai payload: ", requestPayload); - - const shouldStream = !!options.config.stream; - const controller = new AbortController(); - options.onController?.(controller); - - try { - const chatPath = this.path(OpenaiPath.ChatPath); - const chatPayload = { - method: "POST", - body: JSON.stringify(requestPayload), - signal: controller.signal, - headers: getHeaders(), - }; - - // make a fetch request - const requestTimeoutId = setTimeout( - () => controller.abort(), - REQUEST_TIMEOUT_MS, - ); - - if (shouldStream) { - let responseText = ""; - let remainText = ""; - let finished = false; - - // animate response to make it looks smooth - function animateResponseText() { - if (finished || controller.signal.aborted) { - responseText += remainText; - console.log("[Response Animation] finished"); - return; - } - - if (remainText.length > 0) { - const fetchCount = Math.max(1, Math.round(remainText.length / 60)); - const fetchText = remainText.slice(0, fetchCount); - responseText += fetchText; - remainText = remainText.slice(fetchCount); - options.onUpdate?.(responseText, fetchText); - } - - requestAnimationFrame(animateResponseText); - } - - // start animaion - animateResponseText(); - - const finish = () => { - if (!finished) { - finished = true; - options.onFinish(responseText + remainText); - } - }; - - controller.signal.onabort = finish; - - fetchEventSource(chatPath, { - ...chatPayload, - async onopen(res) { - clearTimeout(requestTimeoutId); - const contentType = res.headers.get("content-type"); - console.log( - "[OpenAI] request response content type: ", - contentType, - ); - - if (contentType?.startsWith("text/plain")) { - responseText = await res.clone().text(); - return finish(); - } - - if ( - !res.ok || - !res.headers - .get("content-type") - ?.startsWith(EventStreamContentType) || - res.status !== 200 - ) { - const responseTexts = [responseText]; - let extraInfo = await res.clone().text(); - try { - const resJson = await res.clone().json(); - extraInfo = prettyObject(resJson); - } catch {} - - if (res.status === 401) { - responseTexts.push(Locale.Error.Unauthorized); - } - - if (extraInfo) { - responseTexts.push(extraInfo); - } - - responseText = responseTexts.join("\n\n"); - - return finish(); - } - }, - onmessage(msg) { - if (msg.data === "[DONE]" || finished) { - return finish(); - } - const text = msg.data; - try { - const json = JSON.parse(text) as { - choices: Array<{ - delta: { - content: string; - }; - }>; - }; - const delta = json.choices[0]?.delta?.content; - if (delta) { - remainText += delta; - } - } catch (e) { - console.error("[Request] parse error", text); - } - }, - onclose() { - finish(); - }, - onerror(e) { - options.onError?.(e); - throw e; - }, - openWhenHidden: true, - }); - } else { - const res = await fetch(chatPath, chatPayload); - clearTimeout(requestTimeoutId); - - const resJson = await res.json(); - const message = this.extractMessage(resJson); - options.onFinish(message); - } - } catch (e) { - console.log("[Request] failed to make a chat request", e); - options.onError?.(e as Error); - } - } - async usage() { - const formatDate = (d: Date) => - `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d - .getDate() - .toString() - .padStart(2, "0")}`; - const ONE_DAY = 1 * 24 * 60 * 60 * 1000; - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - const startDate = formatDate(startOfMonth); - const endDate = formatDate(new Date(Date.now() + ONE_DAY)); - - const [used, subs] = await Promise.all([ - fetch( - this.path( - `${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`, - ), - { - method: "GET", - headers: getHeaders(), - }, - ), - fetch(this.path(OpenaiPath.SubsPath), { - method: "GET", - headers: getHeaders(), - }), - ]); - - if (used.status === 401) { - throw new Error(Locale.Error.Unauthorized); - } - - if (!used.ok || !subs.ok) { - throw new Error("Failed to query usage from openai"); - } - - const response = (await used.json()) as { - total_usage?: number; - error?: { - type: string; - message: string; - }; - }; - - const total = (await subs.json()) as { - hard_limit_usd?: number; - }; - - if (response.error && response.error.type) { - throw Error(response.error.message); - } - - if (response.total_usage) { - response.total_usage = Math.round(response.total_usage) / 100; - } - - if (total.hard_limit_usd) { - total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100; - } - - return { - used: response.total_usage, - total: total.hard_limit_usd, - } as LLMUsage; - } - - async models(): Promise { - if (this.disableListModels) { - return DEFAULT_MODELS.slice(); - } - - const res = await fetch(this.path(OpenaiPath.ListModelPath), { - method: "GET", - headers: { - ...getHeaders(), - }, - }); - - const resJson = (await res.json()) as OpenAIListModelResponse; - const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-")); - console.log("[Models]", chatModels); - - if (!chatModels) { - return []; - } - - return chatModels.map((m) => ({ - name: m.id, - available: true, - provider: { - id: "openai", - providerName: "OpenAI", - providerType: "openai", - }, - })); - } -} -export { OpenaiPath }; diff --git a/app/command.ts b/app/command.ts deleted file mode 100644 index e515e5f0bb4..00000000000 --- a/app/command.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect } from "react"; -import { useSearchParams } from "react-router-dom"; -import Locale from "./locales"; - -type Command = (param: string) => void; -interface Commands { - fill?: Command; - submit?: Command; - mask?: Command; - code?: Command; - settings?: Command; -} - -export function useCommand(commands: Commands = {}) { - const [searchParams, setSearchParams] = useSearchParams(); - - useEffect(() => { - let shouldUpdate = false; - searchParams.forEach((param, name) => { - const commandName = name as keyof Commands; - if (typeof commands[commandName] === "function") { - commands[commandName]!(param); - searchParams.delete(name); - shouldUpdate = true; - } - }); - - if (shouldUpdate) { - setSearchParams(searchParams); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, commands]); -} - -interface ChatCommands { - new?: Command; - newm?: Command; - next?: Command; - prev?: Command; - clear?: Command; - del?: Command; -} - -export const ChatCommandPrefix = ":"; - -export function useChatCommand(commands: ChatCommands = {}) { - function extract(userInput: string) { - return ( - userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput - ) as keyof ChatCommands; - } - - function search(userInput: string) { - const input = extract(userInput); - const desc = Locale.Chat.Commands; - return Object.keys(commands) - .filter((c) => c.startsWith(input)) - .map((c) => ({ - title: desc[c as keyof ChatCommands], - content: ChatCommandPrefix + c, - })); - } - - function match(userInput: string) { - const command = extract(userInput); - const matched = typeof commands[command] === "function"; - - return { - matched, - invoke: () => matched && commands[command]!(userInput), - }; - } - - return { match, search }; -} diff --git a/app/components/auth.module.scss b/app/components/auth.module.scss deleted file mode 100644 index 6630c0613c7..00000000000 --- a/app/components/auth.module.scss +++ /dev/null @@ -1,36 +0,0 @@ -.auth-page { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - width: 100%; - flex-direction: column; - - .auth-logo { - transform: scale(1.4); - } - - .auth-title { - font-size: 24px; - font-weight: bold; - line-height: 2; - } - - .auth-tips { - font-size: 14px; - } - - .auth-input { - margin: 3vh 0; - } - - .auth-actions { - display: flex; - justify-content: center; - flex-direction: column; - - button:not(:last-child) { - margin-bottom: 10px; - } - } -} diff --git a/app/components/auth.tsx b/app/components/auth.tsx deleted file mode 100644 index 57118349bac..00000000000 --- a/app/components/auth.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import styles from "./auth.module.scss"; -import { IconButton } from "./button"; - -import { useNavigate } from "react-router-dom"; -import { Path } from "../constant"; -import { useAccessStore } from "../store"; -import Locale from "../locales"; - -import BotIcon from "../icons/bot.svg"; -import { useEffect } from "react"; -import { getClientConfig } from "../config/client"; - -export function AuthPage() { - const navigate = useNavigate(); - const accessStore = useAccessStore(); - - const goHome = () => navigate(Path.Home); - const goChat = () => navigate(Path.Chat); - const resetAccessCode = () => { - accessStore.update((access) => { - access.openaiApiKey = ""; - access.accessCode = ""; - }); - }; // Reset access code to empty string - - useEffect(() => { - if (getClientConfig()?.isApp) { - navigate(Path.Settings); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( -
-
- -
- -
{Locale.Auth.Title}
-
{Locale.Auth.Tips}
- - { - accessStore.update( - (access) => (access.accessCode = e.currentTarget.value), - ); - }} - /> - {!accessStore.hideUserApiKey ? ( - <> -
{Locale.Auth.SubTips}
- { - accessStore.update( - (access) => (access.openaiApiKey = e.currentTarget.value), - ); - }} - /> - { - accessStore.update( - (access) => (access.googleApiKey = e.currentTarget.value), - ); - }} - /> - - ) : null} - -
- - { - resetAccessCode(); - goHome(); - }} - /> -
-
- ); -} diff --git a/app/components/button.module.scss b/app/components/button.module.scss deleted file mode 100644 index e332df2d2c8..00000000000 --- a/app/components/button.module.scss +++ /dev/null @@ -1,83 +0,0 @@ -.icon-button { - background-color: var(--white); - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - padding: 10px; - - cursor: pointer; - transition: all 0.3s ease; - overflow: hidden; - user-select: none; - outline: none; - border: none; - color: var(--black); - - &[disabled] { - cursor: not-allowed; - opacity: 0.5; - } - - &.primary { - background-color: var(--primary); - color: white; - - path { - fill: white !important; - } - } - - &.danger { - color: rgba($color: red, $alpha: 0.8); - border-color: rgba($color: red, $alpha: 0.5); - background-color: rgba($color: red, $alpha: 0.05); - - &:hover { - border-color: red; - background-color: rgba($color: red, $alpha: 0.1); - } - - path { - fill: red !important; - } - } - - &:hover, - &:focus { - border-color: var(--primary); - } -} - -.shadow { - box-shadow: var(--card-shadow); -} - -.border { - border: var(--border-in-light); -} - -.icon-button-icon { - width: 16px; - height: 16px; - display: flex; - justify-content: center; - align-items: center; -} - -@media only screen and (max-width: 600px) { - .icon-button { - padding: 16px; - } -} - -.icon-button-text { - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &:not(:first-child) { - margin-left: 5px; - } -} diff --git a/app/components/button.tsx b/app/components/button.tsx deleted file mode 100644 index 7a5633924c5..00000000000 --- a/app/components/button.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; - -import styles from "./button.module.scss"; - -export type ButtonType = "primary" | "danger" | null; - -export function IconButton(props: { - onClick?: () => void; - icon?: JSX.Element; - type?: ButtonType; - text?: string; - bordered?: boolean; - shadow?: boolean; - className?: string; - title?: string; - disabled?: boolean; - tabIndex?: number; - autoFocus?: boolean; -}) { - return ( - - ); -} diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx deleted file mode 100644 index 33967717d53..00000000000 --- a/app/components/chat-list.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import DeleteIcon from "../icons/delete.svg"; -import BotIcon from "../icons/bot.svg"; - -import styles from "./home.module.scss"; -import { - DragDropContext, - Droppable, - Draggable, - OnDragEndResponder, -} from "@hello-pangea/dnd"; - -import { useChatStore } from "../store"; - -import Locale from "../locales"; -import { Link, useNavigate } from "react-router-dom"; -import { Path } from "../constant"; -import { MaskAvatar } from "./mask"; -import { Mask } from "../store/mask"; -import { useRef, useEffect } from "react"; -import { showConfirm } from "./ui-lib"; -import { useMobileScreen } from "../utils"; - -export function ChatItem(props: { - onClick?: () => void; - onDelete?: () => void; - title: string; - count: number; - time: string; - selected: boolean; - id: string; - index: number; - narrow?: boolean; - mask: Mask; -}) { - const draggableRef = useRef(null); - useEffect(() => { - if (props.selected && draggableRef.current) { - draggableRef.current?.scrollIntoView({ - block: "center", - }); - } - }, [props.selected]); - return ( - - {(provided) => ( -
{ - draggableRef.current = ele; - provided.innerRef(ele); - }} - {...provided.draggableProps} - {...provided.dragHandleProps} - title={`${props.title}\n${Locale.ChatItem.ChatItemCount( - props.count, - )}`} - > - {props.narrow ? ( -
-
- -
-
- {props.count} -
-
- ) : ( - <> -
{props.title}
-
-
- {Locale.ChatItem.ChatItemCount(props.count)} -
-
{props.time}
-
- - )} - -
{ - props.onDelete?.(); - e.preventDefault(); - e.stopPropagation(); - }} - > - -
-
- )} -
- ); -} - -export function ChatList(props: { narrow?: boolean }) { - const [sessions, selectedIndex, selectSession, moveSession] = useChatStore( - (state) => [ - state.sessions, - state.currentSessionIndex, - state.selectSession, - state.moveSession, - ], - ); - const chatStore = useChatStore(); - const navigate = useNavigate(); - const isMobileScreen = useMobileScreen(); - - const onDragEnd: OnDragEndResponder = (result) => { - const { destination, source } = result; - if (!destination) { - return; - } - - if ( - destination.droppableId === source.droppableId && - destination.index === source.index - ) { - return; - } - - moveSession(source.index, destination.index); - }; - - return ( - - - {(provided) => ( -
- {sessions.map((item, i) => ( - { - navigate(Path.Chat); - selectSession(i); - }} - onDelete={async () => { - if ( - (!props.narrow && !isMobileScreen) || - (await showConfirm(Locale.Home.DeleteChat)) - ) { - chatStore.deleteSession(i); - } - }} - narrow={props.narrow} - mask={item.mask} - /> - ))} - {provided.placeholder} -
- )} -
-
- ); -} diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss deleted file mode 100644 index e7619e92b89..00000000000 --- a/app/components/chat.module.scss +++ /dev/null @@ -1,627 +0,0 @@ -@import "../styles/animation.scss"; - -.attach-images { - position: absolute; - left: 30px; - bottom: 32px; - display: flex; -} - -.attach-image { - cursor: default; - width: 64px; - height: 64px; - border: rgba($color: #888, $alpha: 0.2) 1px solid; - border-radius: 5px; - margin-right: 10px; - background-size: cover; - background-position: center; - background-color: var(--white); - - .attach-image-mask { - width: 100%; - height: 100%; - opacity: 0; - transition: all ease 0.2s; - } - - .attach-image-mask:hover { - opacity: 1; - } - - .delete-image { - width: 24px; - height: 24px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: 5px; - float: right; - background-color: var(--white); - } -} - -.chat-input-actions { - display: flex; - flex-wrap: wrap; - - .chat-input-action { - display: inline-flex; - border-radius: 20px; - font-size: 12px; - background-color: var(--white); - color: var(--black); - border: var(--border-in-light); - padding: 4px 10px; - animation: slide-in ease 0.3s; - box-shadow: var(--card-shadow); - transition: width ease 0.3s; - align-items: center; - height: 16px; - width: var(--icon-width); - overflow: hidden; - - &:not(:last-child) { - margin-right: 5px; - } - - .text { - white-space: nowrap; - padding-left: 5px; - opacity: 0; - transform: translateX(-5px); - transition: all ease 0.3s; - pointer-events: none; - } - - &:hover { - --delay: 0.5s; - width: var(--full-width); - transition-delay: var(--delay); - - .text { - transition-delay: var(--delay); - opacity: 1; - transform: translate(0); - } - } - - .text, - .icon { - display: flex; - align-items: center; - justify-content: center; - } - } -} - -.prompt-toast { - position: absolute; - bottom: -50px; - z-index: 999; - display: flex; - justify-content: center; - width: calc(100% - 40px); - - .prompt-toast-inner { - display: flex; - justify-content: center; - align-items: center; - font-size: 12px; - background-color: var(--white); - color: var(--black); - - border: var(--border-in-light); - box-shadow: var(--card-shadow); - padding: 10px 20px; - border-radius: 100px; - - animation: slide-in-from-top ease 0.3s; - - .prompt-toast-content { - margin-left: 10px; - } - } -} - -.section-title { - font-size: 12px; - font-weight: bold; - margin-bottom: 10px; - display: flex; - justify-content: space-between; - align-items: center; - - .section-title-action { - display: flex; - align-items: center; - } -} - -.context-prompt { - .context-prompt-insert { - display: flex; - justify-content: center; - padding: 4px; - opacity: 0.2; - transition: all ease 0.3s; - background-color: rgba(0, 0, 0, 0); - cursor: pointer; - border-radius: 4px; - margin-top: 4px; - margin-bottom: 4px; - - &:hover { - opacity: 1; - background-color: rgba(0, 0, 0, 0.05); - } - } - - .context-prompt-row { - display: flex; - justify-content: center; - width: 100%; - - &:hover { - .context-drag { - opacity: 1; - } - } - - .context-drag { - display: flex; - align-items: center; - opacity: 0.5; - transition: all ease 0.3s; - } - - .context-role { - margin-right: 10px; - } - - .context-content { - flex: 1; - max-width: 100%; - text-align: left; - } - - .context-delete-button { - margin-left: 10px; - } - } - - .context-prompt-button { - flex: 1; - } -} - -.memory-prompt { - margin: 20px 0; - - .memory-prompt-content { - background-color: var(--white); - color: var(--black); - border: var(--border-in-light); - border-radius: 10px; - padding: 10px; - font-size: 12px; - user-select: text; - } -} - -.clear-context { - margin: 20px 0 0 0; - padding: 4px 0; - - border-top: var(--border-in-light); - border-bottom: var(--border-in-light); - box-shadow: var(--card-shadow) inset; - - display: flex; - justify-content: center; - align-items: center; - - color: var(--black); - transition: all ease 0.3s; - cursor: pointer; - overflow: hidden; - position: relative; - font-size: 12px; - - animation: slide-in ease 0.3s; - - $linear: linear-gradient(to right, - rgba(0, 0, 0, 0), - rgba(0, 0, 0, 1), - rgba(0, 0, 0, 0)); - mask-image: $linear; - - @mixin show { - transform: translateY(0); - position: relative; - transition: all ease 0.3s; - opacity: 1; - } - - @mixin hide { - transform: translateY(-50%); - position: absolute; - transition: all ease 0.1s; - opacity: 0; - } - - &-tips { - @include show; - opacity: 0.5; - } - - &-revert-btn { - color: var(--primary); - @include hide; - } - - &:hover { - opacity: 1; - border-color: var(--primary); - - .clear-context-tips { - @include hide; - } - - .clear-context-revert-btn { - @include show; - } - } -} - -.chat { - display: flex; - flex-direction: column; - position: relative; - height: 100%; -} - -.chat-body { - flex: 1; - overflow: auto; - overflow-x: hidden; - padding: 20px; - padding-bottom: 40px; - position: relative; - overscroll-behavior: none; -} - -.chat-body-main-title { - cursor: pointer; - - &:hover { - text-decoration: underline; - } -} - -@media only screen and (max-width: 600px) { - .chat-body-title { - text-align: center; - } -} - -.chat-message { - display: flex; - flex-direction: row; - - &:last-child { - animation: slide-in ease 0.3s; - } -} - -.chat-message-user { - display: flex; - flex-direction: row-reverse; - - .chat-message-header { - flex-direction: row-reverse; - } -} - -.chat-message-header { - margin-top: 20px; - display: flex; - align-items: center; - - .chat-message-actions { - display: flex; - box-sizing: border-box; - font-size: 12px; - align-items: flex-end; - justify-content: space-between; - transition: all ease 0.3s; - transform: scale(0.9) translateY(5px); - margin: 0 10px; - opacity: 0; - pointer-events: none; - - .chat-input-actions { - display: flex; - flex-wrap: nowrap; - } - } -} - -.chat-message-container { - max-width: var(--message-max-width); - display: flex; - flex-direction: column; - align-items: flex-start; - - &:hover { - .chat-message-edit { - opacity: 0.9; - } - - .chat-message-actions { - opacity: 1; - pointer-events: all; - transform: scale(1) translateY(0); - } - } -} - -.chat-message-user>.chat-message-container { - align-items: flex-end; -} - -.chat-message-avatar { - position: relative; - - .chat-message-edit { - position: absolute; - height: 100%; - width: 100%; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: all ease 0.3s; - - button { - padding: 7px; - } - } - - /* Specific styles for iOS devices */ - @media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) { - @supports (-webkit-touch-callout: none) { - .chat-message-edit { - top: -8%; - } - } - } -} - -.chat-message-status { - font-size: 12px; - color: #aaa; - line-height: 1.5; - margin-top: 5px; -} - -.chat-message-item { - box-sizing: border-box; - max-width: 100%; - margin-top: 10px; - border-radius: 10px; - background-color: rgba(0, 0, 0, 0.05); - padding: 10px; - font-size: 14px; - user-select: text; - word-break: break-word; - border: var(--border-in-light); - position: relative; - transition: all ease 0.3s; -} - -.chat-message-item-image { - width: 100%; - margin-top: 10px; -} - -.chat-message-item-images { - width: 100%; - display: grid; - justify-content: left; - grid-gap: 10px; - grid-template-columns: repeat(var(--image-count), auto); - margin-top: 10px; -} - -.chat-message-item-image-multi { - object-fit: cover; - background-size: cover; - background-position: center; - background-repeat: no-repeat; -} - -.chat-message-item-image, -.chat-message-item-image-multi { - box-sizing: border-box; - border-radius: 10px; - border: rgba($color: #888, $alpha: 0.2) 1px solid; -} - - -@media only screen and (max-width: 600px) { - $calc-image-width: calc(100vw/3*2/var(--image-count)); - - .chat-message-item-image-multi { - width: $calc-image-width; - height: $calc-image-width; - } - - .chat-message-item-image { - max-width: calc(100vw/3*2); - } -} - -@media screen and (min-width: 600px) { - $max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count)); - $image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count)); - - .chat-message-item-image-multi { - width: $image-width; - height: $image-width; - max-width: $max-image-width; - max-height: $max-image-width; - } - - .chat-message-item-image { - max-width: calc(calc(1200px - var(--sidebar-width))/3*2); - } -} - -.chat-message-action-date { - font-size: 12px; - opacity: 0.2; - white-space: nowrap; - transition: all ease 0.6s; - color: var(--black); - text-align: right; - width: 100%; - box-sizing: border-box; - padding-right: 10px; - pointer-events: none; - z-index: 1; -} - -.chat-message-user>.chat-message-container>.chat-message-item { - background-color: var(--second); - - &:hover { - min-width: 0; - } -} - -.chat-input-panel { - position: relative; - width: 100%; - padding: 20px; - padding-top: 10px; - box-sizing: border-box; - flex-direction: column; - border-top: var(--border-in-light); - box-shadow: var(--card-shadow); - - .chat-input-actions { - .chat-input-action { - margin-bottom: 10px; - } - } -} - -@mixin single-line { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.prompt-hints { - min-height: 20px; - width: 100%; - max-height: 50vh; - overflow: auto; - display: flex; - flex-direction: column-reverse; - - background-color: var(--white); - border: var(--border-in-light); - border-radius: 10px; - margin-bottom: 10px; - box-shadow: var(--shadow); - - .prompt-hint { - color: var(--black); - padding: 6px 10px; - animation: slide-in ease 0.3s; - cursor: pointer; - transition: all ease 0.3s; - border: transparent 1px solid; - margin: 4px; - border-radius: 8px; - - &:not(:last-child) { - margin-top: 0; - } - - .hint-title { - font-size: 12px; - font-weight: bolder; - - @include single-line(); - } - - .hint-content { - font-size: 12px; - - @include single-line(); - } - - &-selected, - &:hover { - border-color: var(--primary); - } - } -} - -.chat-input-panel-inner { - cursor: text; - display: flex; - flex: 1; - border-radius: 10px; - border: var(--border-in-light); -} - -.chat-input-panel-inner-attach { - padding-bottom: 80px; -} - -.chat-input-panel-inner:has(.chat-input:focus) { - border: 1px solid var(--primary); -} - -.chat-input { - height: 100%; - width: 100%; - border-radius: 10px; - border: none; - box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03); - background-color: var(--white); - color: var(--black); - font-family: inherit; - padding: 10px 90px 10px 14px; - resize: none; - outline: none; - box-sizing: border-box; - min-height: 68px; -} - -.chat-input:focus {} - -.chat-input-send { - background-color: var(--primary); - color: white; - - position: absolute; - right: 30px; - bottom: 32px; -} - -@media only screen and (max-width: 600px) { - .chat-input { - font-size: 16px; - } - - .chat-input-send { - bottom: 30px; - } -} \ No newline at end of file diff --git a/app/components/chat.tsx b/app/components/chat.tsx deleted file mode 100644 index 369d84c3326..00000000000 --- a/app/components/chat.tsx +++ /dev/null @@ -1,1498 +0,0 @@ -import { useDebouncedCallback } from "use-debounce"; -import React, { - useState, - useRef, - useEffect, - useMemo, - useCallback, - Fragment, -} from "react"; - -import SendWhiteIcon from "../icons/send-white.svg"; -import BrainIcon from "../icons/brain.svg"; -import RenameIcon from "../icons/rename.svg"; -import ExportIcon from "../icons/share.svg"; -import ReturnIcon from "../icons/return.svg"; -import CopyIcon from "../icons/copy.svg"; -import LoadingIcon from "../icons/three-dots.svg"; -import LoadingButtonIcon from "../icons/loading.svg"; -import PromptIcon from "../icons/prompt.svg"; -import MaskIcon from "../icons/mask.svg"; -import MaxIcon from "../icons/max.svg"; -import MinIcon from "../icons/min.svg"; -import ResetIcon from "../icons/reload.svg"; -import BreakIcon from "../icons/break.svg"; -import SettingsIcon from "../icons/chat-settings.svg"; -import DeleteIcon from "../icons/clear.svg"; -import PinIcon from "../icons/pin.svg"; -import EditIcon from "../icons/rename.svg"; -import ConfirmIcon from "../icons/confirm.svg"; -import CancelIcon from "../icons/cancel.svg"; -import ImageIcon from "../icons/image.svg"; - -import LightIcon from "../icons/light.svg"; -import DarkIcon from "../icons/dark.svg"; -import AutoIcon from "../icons/auto.svg"; -import BottomIcon from "../icons/bottom.svg"; -import StopIcon from "../icons/pause.svg"; -import RobotIcon from "../icons/robot.svg"; - -import { - ChatMessage, - SubmitKey, - useChatStore, - BOT_HELLO, - createMessage, - useAccessStore, - Theme, - useAppConfig, - DEFAULT_TOPIC, - ModelType, -} from "../store"; - -import { - copyToClipboard, - selectOrCopy, - autoGrowTextArea, - useMobileScreen, - getMessageTextContent, - getMessageImages, - isVisionModel, - compressImage, -} from "../utils"; - -import dynamic from "next/dynamic"; - -import { ChatControllerPool } from "../client/controller"; -import { Prompt, usePromptStore } from "../store/prompt"; -import Locale from "../locales"; - -import { IconButton } from "./button"; -import styles from "./chat.module.scss"; - -import { - List, - ListItem, - Modal, - Selector, - showConfirm, - showPrompt, - showToast, -} from "./ui-lib"; -import { useNavigate } from "react-router-dom"; -import { - CHAT_PAGE_SIZE, - LAST_INPUT_KEY, - Path, - REQUEST_TIMEOUT_MS, - UNFINISHED_INPUT, -} from "../constant"; -import { Avatar } from "./emoji"; -import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; -import { useMaskStore } from "../store/mask"; -import { ChatCommandPrefix, useChatCommand, useCommand } from "../command"; -import { prettyObject } from "../utils/format"; -import { ExportMessageModal } from "./exporter"; -import { getClientConfig } from "../config/client"; -import { useAllModels } from "../utils/hooks"; -import { MultimodalContent } from "../client/api"; - -const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { - loading: () => , -}); - -export function SessionConfigModel(props: { onClose: () => void }) { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const maskStore = useMaskStore(); - const navigate = useNavigate(); - - return ( -
- props.onClose()} - actions={[ - } - bordered - text={Locale.Chat.Config.Reset} - onClick={async () => { - if (await showConfirm(Locale.Memory.ResetConfirm)) { - chatStore.updateCurrentSession( - (session) => (session.memoryPrompt = ""), - ); - } - }} - />, - } - bordered - text={Locale.Chat.Config.SaveAs} - onClick={() => { - navigate(Path.Masks); - setTimeout(() => { - maskStore.create(session.mask); - }, 500); - }} - />, - ]} - > - { - const mask = { ...session.mask }; - updater(mask); - chatStore.updateCurrentSession((session) => (session.mask = mask)); - }} - shouldSyncFromGlobal - extraListItems={ - session.mask.modelConfig.sendMemory ? ( - - ) : ( - <> - ) - } - > - -
- ); -} - -function PromptToast(props: { - showToast?: boolean; - showModal?: boolean; - setShowModal: (_: boolean) => void; -}) { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const context = session.mask.context; - - return ( -
- {props.showToast && ( -
props.setShowModal(true)} - > - - - {Locale.Context.Toast(context.length)} - -
- )} - {props.showModal && ( - props.setShowModal(false)} /> - )} -
- ); -} - -function useSubmitHandler() { - const config = useAppConfig(); - const submitKey = config.submitKey; - const isComposing = useRef(false); - - useEffect(() => { - const onCompositionStart = () => { - isComposing.current = true; - }; - const onCompositionEnd = () => { - isComposing.current = false; - }; - - window.addEventListener("compositionstart", onCompositionStart); - window.addEventListener("compositionend", onCompositionEnd); - - return () => { - window.removeEventListener("compositionstart", onCompositionStart); - window.removeEventListener("compositionend", onCompositionEnd); - }; - }, []); - - const shouldSubmit = (e: React.KeyboardEvent) => { - if (e.key !== "Enter") return false; - if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) - return false; - return ( - (config.submitKey === SubmitKey.AltEnter && e.altKey) || - (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || - (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) || - (config.submitKey === SubmitKey.MetaEnter && e.metaKey) || - (config.submitKey === SubmitKey.Enter && - !e.altKey && - !e.ctrlKey && - !e.shiftKey && - !e.metaKey) - ); - }; - - return { - submitKey, - shouldSubmit, - }; -} - -export type RenderPompt = Pick; - -export function PromptHints(props: { - prompts: RenderPompt[]; - onPromptSelect: (prompt: RenderPompt) => void; -}) { - const noPrompts = props.prompts.length === 0; - const [selectIndex, setSelectIndex] = useState(0); - const selectedRef = useRef(null); - - useEffect(() => { - setSelectIndex(0); - }, [props.prompts.length]); - - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) { - return; - } - // arrow up / down to select prompt - const changeIndex = (delta: number) => { - e.stopPropagation(); - e.preventDefault(); - const nextIndex = Math.max( - 0, - Math.min(props.prompts.length - 1, selectIndex + delta), - ); - setSelectIndex(nextIndex); - selectedRef.current?.scrollIntoView({ - block: "center", - }); - }; - - if (e.key === "ArrowUp") { - changeIndex(1); - } else if (e.key === "ArrowDown") { - changeIndex(-1); - } else if (e.key === "Enter") { - const selectedPrompt = props.prompts.at(selectIndex); - if (selectedPrompt) { - props.onPromptSelect(selectedPrompt); - } - } - }; - - window.addEventListener("keydown", onKeyDown); - - return () => window.removeEventListener("keydown", onKeyDown); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.prompts.length, selectIndex]); - - if (noPrompts) return null; - return ( -
- {props.prompts.map((prompt, i) => ( -
props.onPromptSelect(prompt)} - onMouseEnter={() => setSelectIndex(i)} - > -
{prompt.title}
-
{prompt.content}
-
- ))} -
- ); -} - -function ClearContextDivider() { - const chatStore = useChatStore(); - - return ( -
- chatStore.updateCurrentSession( - (session) => (session.clearContextIndex = undefined), - ) - } - > -
{Locale.Context.Clear}
-
- {Locale.Context.Revert} -
-
- ); -} - -function ChatAction(props: { - text: string; - icon: JSX.Element; - onClick: () => void; -}) { - const iconRef = useRef(null); - const textRef = useRef(null); - const [width, setWidth] = useState({ - full: 16, - icon: 16, - }); - - function updateWidth() { - if (!iconRef.current || !textRef.current) return; - const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; - const textWidth = getWidth(textRef.current); - const iconWidth = getWidth(iconRef.current); - setWidth({ - full: textWidth + iconWidth, - icon: iconWidth, - }); - } - - return ( -
{ - props.onClick(); - setTimeout(updateWidth, 1); - }} - onMouseEnter={updateWidth} - onTouchStart={updateWidth} - style={ - { - "--icon-width": `${width.icon}px`, - "--full-width": `${width.full}px`, - } as React.CSSProperties - } - > -
- {props.icon} -
-
- {props.text} -
-
- ); -} - -function useScrollToBottom() { - // for auto-scroll - const scrollRef = useRef(null); - const [autoScroll, setAutoScroll] = useState(true); - - function scrollDomToBottom() { - const dom = scrollRef.current; - if (dom) { - requestAnimationFrame(() => { - setAutoScroll(true); - dom.scrollTo(0, dom.scrollHeight); - }); - } - } - - // auto scroll - useEffect(() => { - if (autoScroll) { - scrollDomToBottom(); - } - }); - - return { - scrollRef, - autoScroll, - setAutoScroll, - scrollDomToBottom, - }; -} - -export function ChatActions(props: { - uploadImage: () => void; - setAttachImages: (images: string[]) => void; - setUploading: (uploading: boolean) => void; - showPromptModal: () => void; - scrollToBottom: () => void; - showPromptHints: () => void; - hitBottom: boolean; - uploading: boolean; -}) { - const config = useAppConfig(); - const navigate = useNavigate(); - const chatStore = useChatStore(); - - // switch themes - const theme = config.theme; - function nextTheme() { - const themes = [Theme.Auto, Theme.Light, Theme.Dark]; - const themeIndex = themes.indexOf(theme); - const nextIndex = (themeIndex + 1) % themes.length; - const nextTheme = themes[nextIndex]; - config.update((config) => (config.theme = nextTheme)); - } - - // stop all responses - const couldStop = ChatControllerPool.hasPending(); - const stopAll = () => ChatControllerPool.stopAll(); - - // switch model - const currentModel = chatStore.currentSession().mask.modelConfig.model; - const allModels = useAllModels(); - const models = useMemo( - () => allModels.filter((m) => m.available), - [allModels], - ); - const [showModelSelector, setShowModelSelector] = useState(false); - const [showUploadImage, setShowUploadImage] = useState(false); - - useEffect(() => { - const show = isVisionModel(currentModel); - setShowUploadImage(show); - if (!show) { - props.setAttachImages([]); - props.setUploading(false); - } - - // if current model is not available - // switch to first available model - const isUnavaliableModel = !models.some((m) => m.name === currentModel); - if (isUnavaliableModel && models.length > 0) { - const nextModel = models[0].name as ModelType; - chatStore.updateCurrentSession( - (session) => (session.mask.modelConfig.model = nextModel), - ); - showToast(nextModel); - } - }, [chatStore, currentModel, models]); - - return ( -
- {couldStop && ( - } - /> - )} - {!props.hitBottom && ( - } - /> - )} - {props.hitBottom && ( - } - /> - )} - - {showUploadImage && ( - : } - /> - )} - - {theme === Theme.Auto ? ( - - ) : theme === Theme.Light ? ( - - ) : theme === Theme.Dark ? ( - - ) : null} - - } - /> - - } - /> - - { - navigate(Path.Masks); - }} - text={Locale.Chat.InputActions.Masks} - icon={} - /> - - } - onClick={() => { - chatStore.updateCurrentSession((session) => { - if (session.clearContextIndex === session.messages.length) { - session.clearContextIndex = undefined; - } else { - session.clearContextIndex = session.messages.length; - session.memoryPrompt = ""; // will clear memory - } - }); - }} - /> - - setShowModelSelector(true)} - text={currentModel} - icon={} - /> - - {showModelSelector && ( - ({ - title: m.displayName, - value: m.name, - }))} - onClose={() => setShowModelSelector(false)} - onSelection={(s) => { - if (s.length === 0) return; - chatStore.updateCurrentSession((session) => { - session.mask.modelConfig.model = s[0] as ModelType; - session.mask.syncGlobalConfig = false; - }); - showToast(s[0]); - }} - /> - )} -
- ); -} - -export function EditMessageModal(props: { onClose: () => void }) { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const [messages, setMessages] = useState(session.messages.slice()); - - return ( -
- } - key="cancel" - onClick={() => { - props.onClose(); - }} - />, - } - key="ok" - onClick={() => { - chatStore.updateCurrentSession( - (session) => (session.messages = messages), - ); - props.onClose(); - }} - />, - ]} - > - - - - chatStore.updateCurrentSession( - (session) => (session.topic = e.currentTarget.value), - ) - } - > - - - { - const newMessages = messages.slice(); - updater(newMessages); - setMessages(newMessages); - }} - /> - -
- ); -} - -export function DeleteImageButton(props: { deleteImage: () => void }) { - return ( -
- -
- ); -} - -function _Chat() { - type RenderMessage = ChatMessage & { preview?: boolean }; - - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const config = useAppConfig(); - const fontSize = config.fontSize; - - const [showExport, setShowExport] = useState(false); - - const inputRef = useRef(null); - const [userInput, setUserInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const { submitKey, shouldSubmit } = useSubmitHandler(); - const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom(); - const [hitBottom, setHitBottom] = useState(true); - const isMobileScreen = useMobileScreen(); - const navigate = useNavigate(); - const [attachImages, setAttachImages] = useState([]); - const [uploading, setUploading] = useState(false); - - // prompt hints - const promptStore = usePromptStore(); - const [promptHints, setPromptHints] = useState([]); - const onSearch = useDebouncedCallback( - (text: string) => { - const matchedPrompts = promptStore.search(text); - setPromptHints(matchedPrompts); - }, - 100, - { leading: true, trailing: true }, - ); - - // auto grow input - const [inputRows, setInputRows] = useState(2); - const measure = useDebouncedCallback( - () => { - const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; - const inputRows = Math.min( - 20, - Math.max(2 + Number(!isMobileScreen), rows), - ); - setInputRows(inputRows); - }, - 100, - { - leading: true, - trailing: true, - }, - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(measure, [userInput]); - - // chat commands shortcuts - const chatCommands = useChatCommand({ - new: () => chatStore.newSession(), - newm: () => navigate(Path.NewChat), - prev: () => chatStore.nextSession(-1), - next: () => chatStore.nextSession(1), - clear: () => - chatStore.updateCurrentSession( - (session) => (session.clearContextIndex = session.messages.length), - ), - del: () => chatStore.deleteSession(chatStore.currentSessionIndex), - }); - - // only search prompts when user input is short - const SEARCH_TEXT_LIMIT = 30; - const onInput = (text: string) => { - setUserInput(text); - const n = text.trim().length; - - // clear search results - if (n === 0) { - setPromptHints([]); - } else if (text.startsWith(ChatCommandPrefix)) { - setPromptHints(chatCommands.search(text)); - } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { - // check if need to trigger auto completion - if (text.startsWith("/")) { - let searchText = text.slice(1); - onSearch(searchText); - } - } - }; - - const doSubmit = (userInput: string) => { - if (userInput.trim() === "") return; - const matchCommand = chatCommands.match(userInput); - if (matchCommand.matched) { - setUserInput(""); - setPromptHints([]); - matchCommand.invoke(); - return; - } - setIsLoading(true); - chatStore - .onUserInput(userInput, attachImages) - .then(() => setIsLoading(false)); - setAttachImages([]); - localStorage.setItem(LAST_INPUT_KEY, userInput); - setUserInput(""); - setPromptHints([]); - if (!isMobileScreen) inputRef.current?.focus(); - setAutoScroll(true); - }; - - const onPromptSelect = (prompt: RenderPompt) => { - setTimeout(() => { - setPromptHints([]); - - const matchedChatCommand = chatCommands.match(prompt.content); - if (matchedChatCommand.matched) { - // if user is selecting a chat command, just trigger it - matchedChatCommand.invoke(); - setUserInput(""); - } else { - // or fill the prompt - setUserInput(prompt.content); - } - inputRef.current?.focus(); - }, 30); - }; - - // stop response - const onUserStop = (messageId: string) => { - ChatControllerPool.stop(session.id, messageId); - }; - - useEffect(() => { - chatStore.updateCurrentSession((session) => { - const stopTiming = Date.now() - REQUEST_TIMEOUT_MS; - session.messages.forEach((m) => { - // check if should stop all stale messages - if (m.isError || new Date(m.date).getTime() < stopTiming) { - if (m.streaming) { - m.streaming = false; - } - - if (m.content.length === 0) { - m.isError = true; - m.content = prettyObject({ - error: true, - message: "empty response", - }); - } - } - }); - - // auto sync mask config from global config - if (session.mask.syncGlobalConfig) { - console.log("[Mask] syncing from global, name = ", session.mask.name); - session.mask.modelConfig = { ...config.modelConfig }; - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // check if should send message - const onInputKeyDown = (e: React.KeyboardEvent) => { - // if ArrowUp and no userInput, fill with last input - if ( - e.key === "ArrowUp" && - userInput.length <= 0 && - !(e.metaKey || e.altKey || e.ctrlKey) - ) { - setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); - e.preventDefault(); - return; - } - if (shouldSubmit(e) && promptHints.length === 0) { - doSubmit(userInput); - e.preventDefault(); - } - }; - const onRightClick = (e: any, message: ChatMessage) => { - // copy to clipboard - if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) { - if (userInput.length === 0) { - setUserInput(getMessageTextContent(message)); - } - - e.preventDefault(); - } - }; - - const deleteMessage = (msgId?: string) => { - chatStore.updateCurrentSession( - (session) => - (session.messages = session.messages.filter((m) => m.id !== msgId)), - ); - }; - - const onDelete = (msgId: string) => { - deleteMessage(msgId); - }; - - const onResend = (message: ChatMessage) => { - // when it is resending a message - // 1. for a user's message, find the next bot response - // 2. for a bot's message, find the last user's input - // 3. delete original user input and bot's message - // 4. resend the user's input - - const resendingIndex = session.messages.findIndex( - (m) => m.id === message.id, - ); - - if (resendingIndex < 0 || resendingIndex >= session.messages.length) { - console.error("[Chat] failed to find resending message", message); - return; - } - - let userMessage: ChatMessage | undefined; - let botMessage: ChatMessage | undefined; - - if (message.role === "assistant") { - // if it is resending a bot's message, find the user input for it - botMessage = message; - for (let i = resendingIndex; i >= 0; i -= 1) { - if (session.messages[i].role === "user") { - userMessage = session.messages[i]; - break; - } - } - } else if (message.role === "user") { - // if it is resending a user's input, find the bot's response - userMessage = message; - for (let i = resendingIndex; i < session.messages.length; i += 1) { - if (session.messages[i].role === "assistant") { - botMessage = session.messages[i]; - break; - } - } - } - - if (userMessage === undefined) { - console.error("[Chat] failed to resend", message); - return; - } - - // delete the original messages - deleteMessage(userMessage.id); - deleteMessage(botMessage?.id); - - // resend the message - setIsLoading(true); - const textContent = getMessageTextContent(userMessage); - const images = getMessageImages(userMessage); - chatStore.onUserInput(textContent, images).then(() => setIsLoading(false)); - inputRef.current?.focus(); - }; - - const onPinMessage = (message: ChatMessage) => { - chatStore.updateCurrentSession((session) => - session.mask.context.push(message), - ); - - showToast(Locale.Chat.Actions.PinToastContent, { - text: Locale.Chat.Actions.PinToastAction, - onClick: () => { - setShowPromptModal(true); - }, - }); - }; - - const context: RenderMessage[] = useMemo(() => { - return session.mask.hideContext ? [] : session.mask.context.slice(); - }, [session.mask.context, session.mask.hideContext]); - const accessStore = useAccessStore(); - - if ( - context.length === 0 && - session.messages.at(0)?.content !== BOT_HELLO.content - ) { - const copiedHello = Object.assign({}, BOT_HELLO); - if (!accessStore.isAuthorized()) { - copiedHello.content = Locale.Error.Unauthorized; - } - context.push(copiedHello); - } - - // preview messages - const renderMessages = useMemo(() => { - return context - .concat(session.messages as RenderMessage[]) - .concat( - isLoading - ? [ - { - ...createMessage({ - role: "assistant", - content: "……", - }), - preview: true, - }, - ] - : [], - ) - .concat( - userInput.length > 0 && config.sendPreviewBubble - ? [ - { - ...createMessage({ - role: "user", - content: userInput, - }), - preview: true, - }, - ] - : [], - ); - }, [ - config.sendPreviewBubble, - context, - isLoading, - session.messages, - userInput, - ]); - - const [msgRenderIndex, _setMsgRenderIndex] = useState( - Math.max(0, renderMessages.length - CHAT_PAGE_SIZE), - ); - function setMsgRenderIndex(newIndex: number) { - newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex); - newIndex = Math.max(0, newIndex); - _setMsgRenderIndex(newIndex); - } - - const messages = useMemo(() => { - const endRenderIndex = Math.min( - msgRenderIndex + 3 * CHAT_PAGE_SIZE, - renderMessages.length, - ); - return renderMessages.slice(msgRenderIndex, endRenderIndex); - }, [msgRenderIndex, renderMessages]); - - const onChatBodyScroll = (e: HTMLElement) => { - const bottomHeight = e.scrollTop + e.clientHeight; - const edgeThreshold = e.clientHeight; - - const isTouchTopEdge = e.scrollTop <= edgeThreshold; - const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold; - const isHitBottom = - bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10); - - const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE; - const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE; - - if (isTouchTopEdge && !isTouchBottomEdge) { - setMsgRenderIndex(prevPageMsgIndex); - } else if (isTouchBottomEdge) { - setMsgRenderIndex(nextPageMsgIndex); - } - - setHitBottom(isHitBottom); - setAutoScroll(isHitBottom); - }; - - function scrollToBottom() { - setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); - scrollDomToBottom(); - } - - // clear context index = context length + index in messages - const clearContextIndex = - (session.clearContextIndex ?? -1) >= 0 - ? session.clearContextIndex! + context.length - msgRenderIndex - : -1; - - const [showPromptModal, setShowPromptModal] = useState(false); - - const clientConfig = useMemo(() => getClientConfig(), []); - - const autoFocus = !isMobileScreen; // wont auto focus on mobile screen - const showMaxIcon = !isMobileScreen && !clientConfig?.isApp; - - useCommand({ - fill: setUserInput, - submit: (text) => { - doSubmit(text); - }, - code: (text) => { - if (accessStore.disableFastLink) return; - console.log("[Command] got code from url: ", text); - showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => { - if (res) { - accessStore.update((access) => (access.accessCode = text)); - } - }); - }, - settings: (text) => { - if (accessStore.disableFastLink) return; - - try { - const payload = JSON.parse(text) as { - key?: string; - url?: string; - }; - - console.log("[Command] got settings from url: ", payload); - - if (payload.key || payload.url) { - showConfirm( - Locale.URLCommand.Settings + - `\n${JSON.stringify(payload, null, 4)}`, - ).then((res) => { - if (!res) return; - if (payload.key) { - accessStore.update( - (access) => (access.openaiApiKey = payload.key!), - ); - } - if (payload.url) { - accessStore.update((access) => (access.openaiUrl = payload.url!)); - } - }); - } - } catch { - console.error("[Command] failed to get settings from url: ", text); - } - }, - }); - - // edit / insert message modal - const [isEditingMessage, setIsEditingMessage] = useState(false); - - // remember unfinished input - useEffect(() => { - // try to load from local storage - const key = UNFINISHED_INPUT(session.id); - const mayBeUnfinishedInput = localStorage.getItem(key); - if (mayBeUnfinishedInput && userInput.length === 0) { - setUserInput(mayBeUnfinishedInput); - localStorage.removeItem(key); - } - - const dom = inputRef.current; - return () => { - localStorage.setItem(key, dom?.value ?? ""); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - async function uploadImage() { - const images: string[] = []; - images.push(...attachImages); - - images.push( - ...(await new Promise((res, rej) => { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = - "image/png, image/jpeg, image/webp, image/heic, image/heif"; - fileInput.multiple = true; - fileInput.onchange = (event: any) => { - setUploading(true); - const files = event.target.files; - const imagesData: string[] = []; - for (let i = 0; i < files.length; i++) { - const file = event.target.files[i]; - compressImage(file, 256 * 1024) - .then((dataUrl) => { - imagesData.push(dataUrl); - if ( - imagesData.length === 3 || - imagesData.length === files.length - ) { - setUploading(false); - res(imagesData); - } - }) - .catch((e) => { - setUploading(false); - rej(e); - }); - } - }; - fileInput.click(); - })), - ); - - const imagesLength = images.length; - if (imagesLength > 3) { - images.splice(3, imagesLength - 3); - } - setAttachImages(images); - } - - return ( -
-
- {isMobileScreen && ( -
-
- } - bordered - title={Locale.Chat.Actions.ChatList} - onClick={() => navigate(Path.Home)} - /> -
-
- )} - -
-
setIsEditingMessage(true)} - > - {!session.topic ? DEFAULT_TOPIC : session.topic} -
-
- {Locale.Chat.SubTitle(session.messages.length)} -
-
-
- {!isMobileScreen && ( -
- } - bordered - onClick={() => setIsEditingMessage(true)} - /> -
- )} -
- } - bordered - title={Locale.Chat.Actions.Export} - onClick={() => { - setShowExport(true); - }} - /> -
- {showMaxIcon && ( -
- : } - bordered - onClick={() => { - config.update( - (config) => (config.tightBorder = !config.tightBorder), - ); - }} - /> -
- )} -
- - -
- -
onChatBodyScroll(e.currentTarget)} - onMouseDown={() => inputRef.current?.blur()} - onTouchStart={() => { - inputRef.current?.blur(); - setAutoScroll(false); - }} - > - {messages.map((message, i) => { - const isUser = message.role === "user"; - const isContext = i < context.length; - const showActions = - i > 0 && - !(message.preview || message.content.length === 0) && - !isContext; - const showTyping = message.preview || message.streaming; - - const shouldShowClearContextDivider = i === clearContextIndex - 1; - - return ( - -
-
-
-
-
- } - onClick={async () => { - const newMessage = await showPrompt( - Locale.Chat.Actions.Edit, - getMessageTextContent(message), - 10, - ); - let newContent: string | MultimodalContent[] = - newMessage; - const images = getMessageImages(message); - if (images.length > 0) { - newContent = [{ type: "text", text: newMessage }]; - for (let i = 0; i < images.length; i++) { - newContent.push({ - type: "image_url", - image_url: { - url: images[i], - }, - }); - } - } - chatStore.updateCurrentSession((session) => { - const m = session.mask.context - .concat(session.messages) - .find((m) => m.id === message.id); - if (m) { - m.content = newContent; - } - }); - }} - > -
- {isUser ? ( - - ) : ( - <> - {["system"].includes(message.role) ? ( - - ) : ( - - )} - - )} -
- - {showActions && ( -
-
- {message.streaming ? ( - } - onClick={() => onUserStop(message.id ?? i)} - /> - ) : ( - <> - } - onClick={() => onResend(message)} - /> - - } - onClick={() => onDelete(message.id ?? i)} - /> - - } - onClick={() => onPinMessage(message)} - /> - } - onClick={() => - copyToClipboard( - getMessageTextContent(message), - ) - } - /> - - )} -
-
- )} -
- {showTyping && ( -
- {Locale.Chat.Typing} -
- )} -
- onRightClick(e, message)} - onDoubleClickCapture={() => { - if (!isMobileScreen) return; - setUserInput(getMessageTextContent(message)); - }} - fontSize={fontSize} - parentRef={scrollRef} - defaultShow={i >= messages.length - 6} - /> - {getMessageImages(message).length == 1 && ( - - )} - {getMessageImages(message).length > 1 && ( -
- {getMessageImages(message).map((image, index) => { - return ( - - ); - })} -
- )} -
- -
- {isContext - ? Locale.Chat.IsContext - : message.date.toLocaleString()} -
-
-
- {shouldShowClearContextDivider && } -
- ); - })} -
- -
- - - setShowPromptModal(true)} - scrollToBottom={scrollToBottom} - hitBottom={hitBottom} - uploading={uploading} - showPromptHints={() => { - // Click again to close - if (promptHints.length > 0) { - setPromptHints([]); - return; - } - - inputRef.current?.focus(); - setUserInput("/"); - onSearch(""); - }} - /> -