[
  {
    "path": ".github/FUNDING.yml",
    "content": "custom: ['https://iamazing.cn/page/reward']"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: 报告问题\nabout: 使用简练详细的语言描述你遇到的问题\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**例行检查**\n\n[//]: # (方框内删除已有的空格，填 x 号)\n+ [ ] 我已确认目前没有类似 issue\n+ [ ] 我已确认我已升级到最新版本\n+ [ ] 我已完整查看过项目 README，尤其是常见问题部分\n+ [ ] 我理解并愿意跟进此 issue，协助测试和提供反馈 \n+ [ ] 我理解并认可上述内容，并理解项目维护者精力有限，**不遵循规则的 issue 可能会被无视或直接关闭**\n\n**问题描述**\n\n**复现步骤**\n\n**预期结果**\n\n**相关截图**\n如果没有的话，请删除此节。"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 项目群聊\n    url: https://openai.justsong.cn/\n    about: QQ 群：828520184，自动审核，备注 One API\n  - name: 赞赏支持\n    url: https://iamazing.cn/page/reward\n    about: 请作者喝杯咖啡，以激励作者持续开发\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: 功能请求\nabout: 使用简练详细的语言描述希望加入的新功能\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**例行检查**\n\n[//]: # (方框内删除已有的空格，填 x 号)\n+ [ ] 我已确认目前没有类似 issue\n+ [ ] 我已确认我已升级到最新版本\n+ [ ] 我已完整查看过项目 README，已确定现有版本无法满足需求\n+ [ ] 我理解并愿意跟进此 issue，协助测试和提供反馈\n+ [ ] 我理解并认可上述内容，并理解项目维护者精力有限，**不遵循规则的 issue 可能会被无视或直接关闭**\n\n**功能描述**\n\n**应用场景**\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\n# This setup assumes that you run the unit tests with code coverage in the same\n# workflow that will also print the coverage report as comment to the pull request.\n# Therefore, you need to trigger this workflow when a pull request is (re)opened or\n# when new code is pushed to the branch of the pull request. In addition, you also\n# need to trigger this workflow when new code is pushed to the main branch because\n# we need to upload the code coverage results as artifact for the main branch as\n# well since it will be the baseline code coverage.\n#\n# We do not want to trigger the workflow for pushes to *any* branch because this\n# would trigger our jobs twice on pull requests (once from \"push\" event and once\n# from \"pull_request->synchronize\")\non:\n  push:\n    branches:\n      - 'main'\n\njobs:\n  unit_tests:\n    name: \"Unit tests\"\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Go\n        uses: actions/setup-go@v4\n        with:\n          go-version: ^1.22\n\n      # When you execute your unit tests, make sure to use the \"-coverprofile\" flag to write a\n      # coverage profile to a file. You will need the name of the file (e.g. \"coverage.txt\")\n      # in the next step as well as the next job.\n      - name: Test\n        run: go test -cover -coverprofile=coverage.txt ./...\n      - uses: codecov/codecov-action@v4\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n\n  commit_lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: wagoid/commitlint-github-action@v6\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: Publish Docker image\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n  workflow_dispatch:\n    inputs:\n      name:\n        description: 'reason'\n        required: false\njobs:\n  push_to_registries:\n    name: Push Docker image to multiple registries\n    runs-on: ubuntu-latest\n    permissions:\n      packages: write\n      contents: read\n    steps:\n      - name: Check out the repo\n        uses: actions/checkout@v3\n\n      - name: Check repository URL\n        run: |\n          REPO_URL=$(git config --get remote.origin.url)\n          if [[ $REPO_URL == *\"pro\" ]]; then\n            exit 1\n          fi\n\n      - name: Save version info\n        run: |\n          git describe --tags > VERSION \n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: |\n            ${{ contains(github.ref, 'alpha') && 'justsong/one-api-alpha' || 'justsong/one-api' }}\n            ${{ contains(github.ref, 'alpha') && format('ghcr.io/{0}-alpha', github.repository) || format('ghcr.io/{0}', github.repository) }}\n\n      - name: Build and push Docker images\n        uses: docker/build-push-action@v3\n        with:\n          context: .\n          platforms: ${{ contains(github.ref, 'alpha') && 'linux/amd64' || 'linux/amd64' }}\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}"
  },
  {
    "path": ".github/workflows/linux-release.yml",
    "content": "name: Linux Release\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n      - '!*-alpha*'\n      - '!*-preview*'\n  workflow_dispatch:\n    inputs:\n      name:\n        description: 'reason'\n        required: false\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - name: Check repository URL\n        run: |\n          REPO_URL=$(git config --get remote.origin.url)\n          if [[ $REPO_URL == *\"pro\" ]]; then\n            exit 1\n          fi\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 16\n      - name: Build Frontend\n        env:\n          CI: \"\"\n        run: |\n          cd web\n          git describe --tags > VERSION\n          REACT_APP_VERSION=$(git describe --tags) chmod u+x ./build.sh && ./build.sh\n          cd ..\n      - name: Set up Go\n        uses: actions/setup-go@v3\n        with:\n          go-version: '>=1.18.0'\n      - name: Build Backend (amd64)\n        run: |\n          go mod download\n          go build -ldflags \"-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(git describe --tags)' -extldflags '-static'\" -o one-api\n\n      - name: Build Backend (arm64)\n        run: |\n          sudo apt-get update\n          sudo apt-get install gcc-aarch64-linux-gnu\n          CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags \"-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'\" -o one-api-arm64\n\n      - name: Release\n        uses: softprops/action-gh-release@v1\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: |\n            one-api\n            one-api-arm64\n          draft: true\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".github/workflows/macos-release.yml",
    "content": "name: macOS Release\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n      - '!*-alpha*'\n      - '!*-preview*'\n  workflow_dispatch:\n    inputs:\n      name:\n        description: 'reason'\n        required: false\njobs:\n  release:\n    runs-on: macos-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - name: Check repository URL\n        run: |\n          REPO_URL=$(git config --get remote.origin.url)\n          if [[ $REPO_URL == *\"pro\" ]]; then\n            exit 1\n          fi\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 16\n      - name: Build Frontend\n        env:\n          CI: \"\"\n        run: |\n          cd web\n          git describe --tags > VERSION\n          REACT_APP_VERSION=$(git describe --tags) chmod u+x ./build.sh && ./build.sh\n          cd ..\n      - name: Set up Go\n        uses: actions/setup-go@v3\n        with:\n          go-version: '>=1.18.0'\n      - name: Build Backend\n        run: |\n          go mod download\n          go build -ldflags \"-X 'github.com/songquanpeng/one-api/common.Version=$(git describe --tags)'\" -o one-api-macos\n      - name: Release\n        uses: softprops/action-gh-release@v1\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: one-api-macos\n          draft: true\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/windows-release.yml",
    "content": "name: Windows Release\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n      - '!*-alpha*'\n      - '!*-preview*'\n  workflow_dispatch:\n    inputs:\n      name:\n        description: 'reason'\n        required: false\njobs:\n  release:\n    runs-on: windows-latest\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - name: Check repository URL\n        run: |\n          REPO_URL=$(git config --get remote.origin.url)\n          if [[ $REPO_URL == *\"pro\" ]]; then\n            exit 1\n          fi\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 16\n      - name: Build Frontend\n        env:\n          CI: \"\"\n        run: |\n          cd web/default\n          npm install\n          REACT_APP_VERSION=$(git describe --tags) npm run build\n          cd ../..\n      - name: Set up Go\n        uses: actions/setup-go@v3\n        with:\n          go-version: '>=1.18.0'\n      - name: Build Backend\n        run: |\n          go mod download\n          go build -ldflags \"-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(git describe --tags)'\" -o one-api.exe\n      - name: Release\n        uses: softprops/action-gh-release@v1\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: one-api.exe\n          draft: true\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".gitignore",
    "content": ".idea\n.vscode\nupload\n*.exe\n*.db\nbuild\n*.db-journal\nlogs\ndata\n/web/node_modules\ncmd.md\n.env\n/one-api\ntemp\n.DS_Store"
  },
  {
    "path": "Dockerfile",
    "content": "FROM --platform=$BUILDPLATFORM node:16 AS builder\n\nWORKDIR /web\nCOPY ./VERSION .\nCOPY ./web .\n\nRUN npm install --prefix /web/default & \\\n    npm install --prefix /web/berry & \\\n    npm install --prefix /web/air & \\\n    wait\n\nRUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ./VERSION) npm run build --prefix /web/default & \\\n    DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ./VERSION) npm run build --prefix /web/berry & \\\n    DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ./VERSION) npm run build --prefix /web/air & \\\n    wait\n\nFROM golang:alpine AS builder2\n\nRUN apk add --no-cache \\\n    gcc \\\n    musl-dev \\\n    sqlite-dev \\\n    build-base\n\nENV GO111MODULE=on \\\n    CGO_ENABLED=1 \\\n    GOOS=linux\n\nWORKDIR /build\n\nADD go.mod go.sum ./\nRUN go mod download\n\nCOPY . .\nCOPY --from=builder /web/build ./web/build\n\nRUN go build -trimpath -ldflags \"-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(cat VERSION)' -linkmode external -extldflags '-static'\" -o one-api\n\nFROM alpine:latest\n\nRUN apk add --no-cache ca-certificates tzdata\n\nCOPY --from=builder2 /build/one-api /\n\nEXPOSE 3000\nWORKDIR /data\nENTRYPOINT [\"/one-api\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 JustSong\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.en.md",
    "content": "<p align=\"right\">\n    <a href=\"./README.md\">中文</a> | <strong>English</strong> | <a href=\"./README.ja.md\">日本語</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/songquanpeng/one-api\"><img src=\"https://raw.githubusercontent.com/songquanpeng/one-api/main/web/default/public/logo.png\" width=\"150\" height=\"150\" alt=\"one-api logo\"></a>\n</p>\n\n<div align=\"center\">\n\n# One API\n\n_✨ Access all LLM through the standard OpenAI API format, easy to deploy & use ✨_\n\n</div>\n\n<p align=\"center\">\n  <a href=\"https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE\">\n    <img src=\"https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen\" alt=\"license\">\n  </a>\n  <a href=\"https://github.com/songquanpeng/one-api/releases/latest\">\n    <img src=\"https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases\" alt=\"release\">\n  </a>\n  <a href=\"https://hub.docker.com/repository/docker/justsong/one-api\">\n    <img src=\"https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen\" alt=\"docker pull\">\n  </a>\n  <a href=\"https://github.com/songquanpeng/one-api/releases/latest\">\n    <img src=\"https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases\" alt=\"release\">\n  </a>\n  <a href=\"https://goreportcard.com/report/github.com/songquanpeng/one-api\">\n    <img src=\"https://goreportcard.com/badge/github.com/songquanpeng/one-api\" alt=\"GoReportCard\">\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"#deployment\">Deployment Tutorial</a>\n  ·\n  <a href=\"#usage\">Usage</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api/issues\">Feedback</a>\n  ·\n  <a href=\"#screenshots\">Screenshots</a>\n  ·\n  <a href=\"https://openai.justsong.cn/\">Live Demo</a>\n  ·\n  <a href=\"#faq\">FAQ</a>\n  ·\n  <a href=\"#related-projects\">Related Projects</a>\n  ·\n  <a href=\"https://iamazing.cn/page/reward\">Donate</a>\n</p>\n\n> **Warning**: This README is translated by ChatGPT. Please feel free to submit a PR if you find any translation errors.\n\n> **Note**: The latest image pulled from Docker may be an `alpha` release. Specify the version manually if you require stability.\n\n## Features\n1. Support for multiple large models:\n   + [x] [OpenAI ChatGPT Series Models](https://platform.openai.com/docs/guides/gpt/chat-completions-api) (Supports [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference))\n   + [x] [Anthropic Claude Series Models](https://anthropic.com)\n   + [x] [Google PaLM2 and Gemini Series Models](https://developers.generativeai.google)\n   + [x] [Baidu Wenxin Yiyuan Series Models](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)\n   + [x] [Alibaba Tongyi Qianwen Series Models](https://help.aliyun.com/document_detail/2400395.html)\n   + [x] [Zhipu ChatGLM Series Models](https://bigmodel.cn)\n2. Supports access to multiple channels through **load balancing**.\n3. Supports **stream mode** that enables typewriter-like effect through stream transmission.\n4. Supports **multi-machine deployment**. [See here](#multi-machine-deployment) for more details.\n5. Supports **token management** that allows setting token expiration time and usage count.\n6. Supports **voucher management** that enables batch generation and export of vouchers. Vouchers can be used for account balance replenishment.\n7. Supports **channel management** that allows bulk creation of channels.\n8. Supports **user grouping** and **channel grouping** for setting different rates for different groups.\n9. Supports channel **model list configuration**.\n10. Supports **quota details checking**.\n11. Supports **user invite rewards**.\n12. Allows display of balance in USD.\n13. Supports announcement publishing, recharge link setting, and initial balance setting for new users.\n14. Offers rich **customization** options:\n    1. Supports customization of system name, logo, and footer.\n    2. Supports customization of homepage and about page using HTML & Markdown code, or embedding a standalone webpage through iframe.\n15. Supports management API access through system access tokens.\n16. Supports Cloudflare Turnstile user verification.\n17. Supports user management and multiple user login/registration methods:\n    + Email login/registration and password reset via email.\n    + [GitHub OAuth](https://github.com/settings/applications/new).\n    + WeChat Official Account authorization (requires additional deployment of [WeChat Server](https://github.com/songquanpeng/wechat-server)).\n18. Immediate support and encapsulation of other major model APIs as they become available.\n\n## Deployment\n### Docker Deployment\n\nDeployment command:\n`docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api`\n\nUpdate command: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`\n\nThe first `3000` in `-p 3000:3000` is the port of the host, which can be modified as needed.\n\nData will be saved in the `/home/ubuntu/data/one-api` directory on the host. Ensure that the directory exists and has write permissions, or change it to a suitable directory.\n\nNginx reference configuration:\n```\nserver{\n   server_name openai.justsong.cn;  # Modify your domain name accordingly\n\n   location / {\n          client_max_body_size  64m;\n          proxy_http_version 1.1;\n          proxy_pass http://localhost:3000;  # Modify your port accordingly\n          proxy_set_header Host $host;\n          proxy_set_header X-Forwarded-For $remote_addr;\n          proxy_cache_bypass $http_upgrade;\n          proxy_set_header Accept-Encoding gzip;\n   }\n}\n```\n\nNext, configure HTTPS with Let's Encrypt certbot:\n```bash\n# Install certbot on Ubuntu:\nsudo snap install --classic certbot\nsudo ln -s /snap/bin/certbot /usr/bin/certbot\n# Generate certificates & modify Nginx configuration\nsudo certbot --nginx\n# Follow the prompts\n# Restart Nginx\nsudo service nginx restart\n```\n\nThe initial account username is `root` and password is `123456`.\n\n### Manual Deployment\n1. Download the executable file from [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) or compile from source:\n   ```shell\n   git clone https://github.com/songquanpeng/one-api.git\n\n   # Build the frontend\n   cd one-api/web/default\n   npm install\n   npm run build\n\n   # Build the backend\n   cd ../..\n   go mod download\n   go build -ldflags \"-s -w\" -o one-api\n   ```\n2. Run:\n   ```shell\n   chmod u+x one-api\n   ./one-api --port 3000 --log-dir ./logs\n   ```\n3. Access [http://localhost:3000/](http://localhost:3000/) and log in. The initial account username is `root` and password is `123456`.\n\nFor more detailed deployment tutorials, please refer to [this page](https://iamazing.cn/page/how-to-deploy-a-website).\n\n### Multi-machine Deployment\n1. Set the same `SESSION_SECRET` for all servers.\n2. Set `SQL_DSN` and use MySQL instead of SQLite. All servers should connect to the same database.\n3. Set the `NODE_TYPE` for all non-master nodes to `slave`.\n4. Set `SYNC_FREQUENCY` for servers to periodically sync configurations from the database.\n5. Non-master nodes can optionally set `FRONTEND_BASE_URL` to redirect page requests to the master server.\n6. Install Redis separately on non-master nodes, and configure `REDIS_CONN_STRING` so that the database can be accessed with zero latency when the cache has not expired.\n7. If the main server also has high latency accessing the database, Redis must be enabled and `SYNC_FREQUENCY` must be set to periodically sync configurations from the database.\n\nPlease refer to the [environment variables](#environment-variables) section for details on using environment variables.\n\n### Deployment on Control Panels (e.g., Baota)\nRefer to [#175](https://github.com/songquanpeng/one-api/issues/175) for detailed instructions.\n\nIf you encounter a blank page after deployment, refer to [#97](https://github.com/songquanpeng/one-api/issues/97) for possible solutions.\n\n### Deployment on Third-Party Platforms\n<details>\n<summary><strong>Deploy on Sealos</strong></summary>\n<div>\n\n> Sealos supports high concurrency, dynamic scaling, and stable operations for millions of users.\n\n> Click the button below to deploy with one click.👇\n\n[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)\n\n\n</div>\n</details>\n\n<details>\n<summary><strong>Deployment on Zeabur</strong></summary>\n<div>\n\n> Zeabur's servers are located overseas, automatically solving network issues, and the free quota is sufficient for personal usage.\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/7Q0KO3)\n\n1. First, fork the code.\n2. Go to [Zeabur](https://zeabur.com?referralCode=songquanpeng), log in, and enter the console.\n3. Create a new project. In Service -> Add Service, select Marketplace, and choose MySQL. Note down the connection parameters (username, password, address, and port).\n4. Copy the connection parameters and run ```create database `one-api` ``` to create the database.\n5. Then, in Service -> Add Service, select Git (authorization is required for the first use) and choose your forked repository.\n6. Automatic deployment will start, but please cancel it for now. Go to the Variable tab, add a `PORT` with a value of `3000`, and then add a `SQL_DSN` with a value of `<username>:<password>@tcp(<addr>:<port>)/one-api`. Save the changes. Please note that if `SQL_DSN` is not set, data will not be persisted, and the data will be lost after redeployment.\n7. Select Redeploy.\n8. In the Domains tab, select a suitable domain name prefix, such as \"my-one-api\". The final domain name will be \"my-one-api.zeabur.app\". You can also CNAME your own domain name.\n9. Wait for the deployment to complete, and click on the generated domain name to access One API.\n\n</div>\n</details>\n\n## Configuration\nThe system is ready to use out of the box.\n\nYou can configure it by setting environment variables or command line parameters.\n\nAfter the system starts, log in as the `root` user to further configure the system.\n\n## Usage\nAdd your API Key on the `Channels` page, and then add an access token on the `Tokens` page.\n\nYou can then use your access token to access One API. The usage is consistent with the [OpenAI API](https://platform.openai.com/docs/api-reference/introduction).\n\nIn places where the OpenAI API is used, remember to set the API Base to your One API deployment address, for example: `https://openai.justsong.cn`. The API Key should be the token generated in One API.\n\nNote that the specific API Base format depends on the client you are using.\n\n```mermaid\ngraph LR\n    A(User)\n    A --->|Request| B(One API)\n    B -->|Relay Request| C(OpenAI)\n    B -->|Relay Request| D(Azure)\n    B -->|Relay Request| E(Other downstream channels)\n```\n\nTo specify which channel to use for the current request, you can add the channel ID after the token, for example: `Authorization: Bearer ONE_API_KEY-CHANNEL_ID`.\nNote that the token needs to be created by an administrator to specify the channel ID.\n\nIf the channel ID is not provided, load balancing will be used to distribute the requests to multiple channels.\n\n### Environment Variables\n1. `REDIS_CONN_STRING`: When set, Redis will be used as the storage for request rate limiting instead of memory.\n    + Example: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153`\n2. `SESSION_SECRET`: When set, a fixed session key will be used to ensure that cookies of logged-in users are still valid after the system restarts.\n    + Example: `SESSION_SECRET=random_string`\n3. `SQL_DSN`: When set, the specified database will be used instead of SQLite. Please use MySQL version 8.0.\n    + Example: `SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`\n4. `LOG_SQL_DSN`: When set, a separate database will be used for the `logs` table; please use MySQL or PostgreSQL.\n    + Example: `LOG_SQL_DSN=root:123456@tcp(localhost:3306)/oneapi-logs`\n5. `FRONTEND_BASE_URL`: When set, the specified frontend address will be used instead of the backend address.\n    + Example: `FRONTEND_BASE_URL=https://openai.justsong.cn`\n6. 'MEMORY_CACHE_ENABLED': Enabling memory caching can cause a certain delay in updating user quotas, with optional values of 'true' and 'false'. If not set, it defaults to 'false'.\n7. `SYNC_FREQUENCY`: When set, the system will periodically sync configurations from the database, with the unit in seconds. If not set, no sync will happen.\n    + Example: `SYNC_FREQUENCY=60`\n8. `NODE_TYPE`: When set, specifies the node type. Valid values are `master` and `slave`. If not set, it defaults to `master`.\n    + Example: `NODE_TYPE=slave`\n9. `CHANNEL_UPDATE_FREQUENCY`: When set, it periodically updates the channel balances, with the unit in minutes. If not set, no update will happen.\n    + Example: `CHANNEL_UPDATE_FREQUENCY=1440`\n10. `CHANNEL_TEST_FREQUENCY`: When set, it periodically tests the channels, with the unit in minutes. If not set, no test will happen.\n    + Example: `CHANNEL_TEST_FREQUENCY=1440`\n11. `POLLING_INTERVAL`: The time interval (in seconds) between requests when updating channel balances and testing channel availability. Default is no interval.\n    + Example: `POLLING_INTERVAL=5`\n12. `BATCH_UPDATE_ENABLED`: Enabling batch database update aggregation can cause a certain delay in updating user quotas. The optional values are 'true' and 'false', but if not set, it defaults to 'false'.\n    +Example: ` BATCH_UPDATE_ENABLED=true`\n    +If you encounter an issue with too many database connections, you can try enabling this option.\n13. `BATCH_UPDATE_INTERVAL=5`: The time interval for batch updating aggregates, measured in seconds, defaults to '5'.\n    +Example: ` BATCH_UPDATE_INTERVAL=5`\n14. Request frequency limit:\n    + `GLOBAL_API_RATE_LIMIT`: Global API rate limit (excluding relay requests), the maximum number of requests within three minutes per IP, default to 180.\n    + `GLOBAL_WEL_RATE_LIMIT`: Global web speed limit, the maximum number of requests within three minutes per IP, default to 60.\n15. Encoder cache settings:\n    +`TIKTOKEN_CACHE_DIR`: By default, when the program starts, it will download the encoding of some common word elements online, such as' gpt-3.5 turbo '. In some unstable network environments or offline situations, it may cause startup problems. This directory can be configured to cache data and can be migrated to an offline environment.\n    +`DATA_GYM_CACHE_DIR`: Currently, this configuration has the same function as' TIKTOKEN-CACHE-DIR ', but its priority is not as high as it.\n16. `RELAY_TIMEOUT`: Relay timeout setting, measured in seconds, with no default timeout time set.\n17. `RELAY_PROXY`: After setting up, use this proxy to request APIs.\n18. `USER_CONTENT_REQUEST_TIMEOUT`: The timeout period for users to upload and download content, measured in seconds.\n19. `USER_CONTENT_REQUEST_PROXY`: After setting up, use this agent to request content uploaded by users, such as images.\n20. `SQLITE_BUSY_TIMEOUT`: SQLite lock wait timeout setting, measured in milliseconds, default to '3000'.\n21. `GEMINI_SAFETY_SETTING`: Gemini's security settings are set to 'BLOCK-NONE' by default.\n22. `GEMINI_VERSION`: The Gemini version used by the One API, which defaults to 'v1'.\n23. `THE`: The system's theme setting, default to 'default', specific optional values refer to [here] (./web/README. md).\n24. `ENABLE_METRIC`: Whether to disable channels based on request success rate, default not enabled, optional values are 'true' and 'false'.\n25. `METRIC_QUEUE_SIZE`: Request success rate statistics queue size, default to '10'.\n26. `METRIC_SUCCESS_RATE_THRESHOLD`: Request success rate threshold, default to '0.8'.\n27. `INITIAL_ROOT_TOKEN`: If this value is set, a root user token with the value of the environment variable will be automatically created when the system starts for the first time.\n28. `INITIAL_ROOT_ACCESS_TOKEN`: If this value is set, a system management token will be automatically created for the root user with a value of the environment variable when the system starts for the first time.\n\n### Command Line Parameters\n1. `--port <port_number>`: Specifies the port number on which the server listens. Defaults to `3000`.\n    + Example: `--port 3000`\n2. `--log-dir <log_dir>`: Specifies the log directory. If not set, the logs will not be saved.\n    + Example: `--log-dir ./logs`\n3. `--version`: Prints the system version number and exits.\n4. `--help`: Displays the command usage help and parameter descriptions.\n\n## Screenshots\n![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)\n![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)\n\n## FAQ\n1. What is quota? How is it calculated? Does One API have quota calculation issues?\n    + Quota = Group multiplier * Model multiplier * (number of prompt tokens + number of completion tokens * completion multiplier)\n    + The completion multiplier is fixed at 1.33 for GPT3.5 and 2 for GPT4, consistent with the official definition.\n    + If it is not a stream mode, the official API will return the total number of tokens consumed. However, please note that the consumption multipliers for prompts and completions are different.\n2. Why does it prompt \"insufficient quota\" even though my account balance is sufficient?\n    + Please check if your token quota is sufficient. It is separate from the account balance.\n    + The token quota is used to set the maximum usage and can be freely set by the user.\n3. It says \"No available channels\" when trying to use a channel. What should I do?\n    + Please check the user and channel group settings.\n    + Also check the channel model settings.\n4. Channel testing reports an error: \"invalid character '<' looking for beginning of value\"\n    + This error occurs when the returned value is not valid JSON but an HTML page.\n    + Most likely, the IP of your deployment site or the node of the proxy has been blocked by CloudFlare.\n5. ChatGPT Next Web reports an error: \"Failed to fetch\"\n    + Do not set `BASE_URL` during deployment.\n    + Double-check that your interface address and API Key are correct.\n\n## Related Projects\n* [FastGPT](https://github.com/labring/FastGPT): Knowledge question answering system based on the LLM\n* [VChart](https://github.com/VisActor/VChart):  More than just a cross-platform charting library, but also an expressive data storyteller.\n* [VMind](https://github.com/VisActor/VMind):  Not just automatic, but also fantastic. Open-source solution for intelligent visualization.\n* * [CherryStudio](https://github.com/CherryHQ/cherry-studio):  A cross-platform AI client that integrates multiple service providers and supports local knowledge base management.\n\n## Note\nThis project is an open-source project. Please use it in compliance with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**. It must not be used for illegal purposes.\n\nThis project is released under the MIT license. Based on this, attribution and a link to this project must be included at the bottom of the page.\n\nThe same applies to derivative projects based on this project.\n\nIf you do not wish to include attribution, prior authorization must be obtained.\n\nAccording to the MIT license, users should bear the risk and responsibility of using this project, and the developer of this open-source project is not responsible for this.\n"
  },
  {
    "path": "README.ja.md",
    "content": "<p align=\"right\">\n    <a href=\"./README.md\">中文</a> | <a href=\"./README.en.md\">English</a> | <strong>日本語</strong>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/songquanpeng/one-api\"><img src=\"https://raw.githubusercontent.com/songquanpeng/one-api/main/web/default/public/logo.png\" width=\"150\" height=\"150\" alt=\"one-api logo\"></a>\n</p>\n\n<div align=\"center\">\n\n# One API\n\n_✨ 標準的な OpenAI API フォーマットを通じてすべての LLM にアクセスでき、導入と利用が容易です ✨_\n\n</div>\n\n<p align=\"center\">\n  <a href=\"https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE\">\n    <img src=\"https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen\" alt=\"license\">\n  </a>\n  <a href=\"https://github.com/songquanpeng/one-api/releases/latest\">\n    <img src=\"https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases\" alt=\"release\">\n  </a>\n  <a href=\"https://hub.docker.com/repository/docker/justsong/one-api\">\n    <img src=\"https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen\" alt=\"docker pull\">\n  </a>\n  <a href=\"https://github.com/songquanpeng/one-api/releases/latest\">\n    <img src=\"https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases\" alt=\"release\">\n  </a>\n  <a href=\"https://goreportcard.com/report/github.com/songquanpeng/one-api\">\n    <img src=\"https://goreportcard.com/badge/github.com/songquanpeng/one-api\" alt=\"GoReportCard\">\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"#deployment\">デプロイチュートリアル</a>\n  ·\n  <a href=\"#usage\">使用方法</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api/issues\">フィードバック</a>\n  ·\n  <a href=\"#screenshots\">スクリーンショット</a>\n  ·\n  <a href=\"https://openai.justsong.cn/\">ライブデモ</a>\n  ·\n  <a href=\"#faq\">FAQ</a>\n  ·\n  <a href=\"#related-projects\">関連プロジェクト</a>\n  ·\n  <a href=\"https://iamazing.cn/page/reward\">寄付</a>\n</p>\n\n> **警告**: この README は ChatGPT によって翻訳されています。翻訳ミスを発見した場合は遠慮なく PR を投稿してください。\n\n> **注**: Docker からプルされた最新のイメージは、`alpha` リリースかもしれません。安定性が必要な場合は、手動でバージョンを指定してください。\n\n## 特徴\n1. 複数の大型モデルをサポート:\n   + [x] [OpenAI ChatGPT シリーズモデル](https://platform.openai.com/docs/guides/gpt/chat-completions-api) ([Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) をサポート)\n   + [x] [Anthropic Claude シリーズモデル](https://anthropic.com)\n   + [x] [Google PaLM2/Gemini シリーズモデル](https://developers.generativeai.google)\n   + [x] [Baidu Wenxin Yiyuan シリーズモデル](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)\n   + [x] [Alibaba Tongyi Qianwen シリーズモデル](https://help.aliyun.com/document_detail/2400395.html)\n   + [x] [Zhipu ChatGLM シリーズモデル](https://bigmodel.cn)\n2. **ロードバランシング**による複数チャンネルへのアクセスをサポート。\n3. ストリーム伝送によるタイプライター的効果を可能にする**ストリームモード**に対応。\n4. **マルチマシンデプロイ**に対応。[詳細はこちら](#multi-machine-deployment)を参照。\n5. トークンの有効期限や使用回数を設定できる**トークン管理**に対応しています。\n6. **バウチャー管理**に対応しており、バウチャーの一括生成やエクスポートが可能です。バウチャーは口座残高の補充に利用できます。\n7. **チャンネル管理**に対応し、チャンネルの一括作成が可能。\n8. グループごとに異なるレートを設定するための**ユーザーグループ**と**チャンネルグループ**をサポートしています。\n9. チャンネル**モデルリスト設定**に対応。\n10. **クォータ詳細チェック**をサポート。\n11. **ユーザー招待報酬**をサポートします。\n12. 米ドルでの残高表示が可能。\n13. 新規ユーザー向けのお知らせ公開、リチャージリンク設定、初期残高設定に対応。\n14. 豊富な**カスタマイズ**オプションを提供します:\n    1. システム名、ロゴ、フッターのカスタマイズが可能。\n    2. HTML と Markdown コードを使用したホームページとアバウトページのカスタマイズ、または iframe を介したスタンドアロンウェブページの埋め込みをサポートしています。\n15. システム・アクセストークンによる管理 API アクセスをサポートする。\n16. Cloudflare Turnstile によるユーザー認証に対応。\n17. ユーザー管理と複数のユーザーログイン/登録方法をサポート:\n    + 電子メールによるログイン/登録とパスワードリセット。\n    + [GitHub OAuth](https://github.com/settings/applications/new)。\n    + WeChat 公式アカウントの認証（[WeChat Server](https://github.com/songquanpeng/wechat-server)の追加導入が必要）。\n18. 他の主要なモデル API が利用可能になった場合、即座にサポートし、カプセル化する。\n\n## デプロイメント\n### Docker デプロイメント\n\nデプロイコマンド:\n`docker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api`。\n\nコマンドを更新する: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrr/watchtower -cR`。\n\n`-p 3000:3000` の最初の `3000` はホストのポートで、必要に応じて変更できます。\n\nデータはホストの `/home/ubuntu/data/one-api` ディレクトリに保存される。このディレクトリが存在し、書き込み権限があることを確認する、もしくは適切なディレクトリに変更してください。\n\nNginxリファレンス設定:\n```\nserver{\n   server_name openai.justsong.cn;  # ドメイン名は適宜変更\n\n   location / {\n          client_max_body_size  64m;\n          proxy_http_version 1.1;\n          proxy_pass http://localhost:3000;  # それに応じてポートを変更\n          proxy_set_header Host $host;\n          proxy_set_header X-Forwarded-For $remote_addr;\n          proxy_cache_bypass $http_upgrade;\n          proxy_set_header Accept-Encoding gzip;\n          proxy_read_timeout 300s;  # GPT-4 はより長いタイムアウトが必要\n   }\n}\n```\n\n次に、Let's Encrypt certbot を使って HTTPS を設定します:\n```bash\n# Ubuntu に certbot をインストール:\nsudo snap install --classic certbot\nsudo ln -s /snap/bin/certbot /usr/bin/certbot\n# 証明書の生成と Nginx 設定の変更\nsudo certbot --nginx\n# プロンプトに従う\n# Nginx を再起動\nsudo service nginx restart\n```\n\n初期アカウントのユーザー名は `root` で、パスワードは `123456` です。\n\n### マニュアルデプロイ\n1. [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) から実行ファイルをダウンロードする、もしくはソースからコンパイルする:\n   ```shell\n   git clone https://github.com/songquanpeng/one-api.git\n\n   # フロントエンドのビルド\n   cd one-api/web/default\n   npm install\n   npm run build\n\n   # バックエンドのビルド\n   cd ../..\n   go mod download\n   go build -ldflags \"-s -w\" -o one-api\n   ```\n2. 実行:\n   ```shell\n   chmod u+x one-api\n   ./one-api --port 3000 --log-dir ./logs\n   ```\n3. [http://localhost:3000/](http://localhost:3000/) にアクセスし、ログインする。初期アカウントのユーザー名は `root`、パスワードは `123456` である。\n\nより詳細なデプロイのチュートリアルについては、[このページ](https://iamazing.cn/page/how-to-deploy-a-website) を参照してください。\n\n### マルチマシンデプロイ\n1. すべてのサーバに同じ `SESSION_SECRET` を設定する。\n2. `SQL_DSN` を設定し、SQLite の代わりに MySQL を使用する。すべてのサーバは同じデータベースに接続する。\n3. マスターノード以外のノードの `NODE_TYPE` を `slave` に設定する。\n4. データベースから定期的に設定を同期するサーバーには `SYNC_FREQUENCY` を設定する。\n5. マスター以外のノードでは、オプションで `FRONTEND_BASE_URL` を設定して、ページ要求をマスターサーバーにリダイレクトすることができます。\n6. マスター以外のノードには Redis を個別にインストールし、`REDIS_CONN_STRING` を設定して、キャッシュの有効期限が切れていないときにデータベースにゼロレイテンシーでアクセスできるようにする。\n7. メインサーバーでもデータベースへのアクセスが高レイテンシになる場合は、Redis を有効にし、`SYNC_FREQUENCY` を設定してデータベースから定期的に設定を同期する必要がある。\n\nPlease refer to the [environment variables](#environment-variables) section for details on using environment variables.\n\n### コントロールパネル（例: Baota）への展開\n詳しい手順は [#175](https://github.com/songquanpeng/one-api/issues/175) を参照してください。\n\n配置後に空白のページが表示される場合は、[#97](https://github.com/songquanpeng/one-api/issues/97) を参照してください。\n\n### サードパーティプラットフォームへのデプロイ\n<details>\n<summary><strong>Sealos へのデプロイ</strong></summary>\n<div>\n\n> Sealos は、高い同時実行性、ダイナミックなスケーリング、数百万人のユーザーに対する安定した運用をサポートしています。\n\n> 下のボタンをクリックすると、ワンクリックで展開できます。👇\n\n[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)\n\n\n</div>\n</details>\n\n<details>\n<summary><strong>Zeabur へのデプロイ</strong></summary>\n<div>\n\n> Zeabur のサーバーは海外にあるため、ネットワークの問題は自動的に解決されます。\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/7Q0KO3)\n\n1. まず、コードをフォークする。\n2. [Zeabur](https://zeabur.com?referralCode=songquanpeng) にアクセスしてログインし、コンソールに入る。\n3. 新しいプロジェクトを作成します。Service -> Add ServiceでMarketplace を選択し、MySQL を選択する。接続パラメータ（ユーザー名、パスワード、アドレス、ポート）をメモします。\n4. 接続パラメータをコピーし、```create database `one-api` ``` を実行してデータベースを作成する。\n5. その後、Service -> Add Service で Git を選択し（最初の使用には認証が必要です）、フォークしたリポジトリを選択します。\n6. 自動デプロイが開始されますが、一旦キャンセルしてください。Variable タブで `PORT` に `3000` を追加し、`SQL_DSN` に `<username>:<password>@tcp(<addr>:<port>)/one-api` を追加します。変更を保存する。SQL_DSN` が設定されていないと、データが永続化されず、再デプロイ後にデータが失われるので注意すること。\n7. 再デプロイを選択します。\n8. Domains タブで、\"my-one-api\" のような適切なドメイン名の接頭辞を選択する。最終的なドメイン名は \"my-one-api.zeabur.app\" となります。独自のドメイン名を CNAME することもできます。\n9. デプロイが完了するのを待ち、生成されたドメイン名をクリックして One API にアクセスします。\n\n</div>\n</details>\n\n## コンフィグ\nシステムは箱から出してすぐに使えます。\n\n環境変数やコマンドラインパラメータを設定することで、システムを構成することができます。\n\nシステム起動後、`root` ユーザーとしてログインし、さらにシステムを設定します。\n\n## 使用方法\n`Channels` ページで API Key を追加し、`Tokens` ページでアクセストークンを追加する。\n\nアクセストークンを使って One API にアクセスすることができる。使い方は [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) と同じです。\n\nOpenAI API が使用されている場所では、API Base に One API のデプロイアドレスを設定することを忘れないでください（例: `https://openai.justsong.cn`）。API Key は One API で生成されたトークンでなければなりません。\n\n具体的な API Base のフォーマットは、使用しているクライアントに依存することに注意してください。\n\n```mermaid\ngraph LR\n    A(ユーザ)\n    A --->|リクエスト| B(One API)\n    B -->|中継リクエスト| C(OpenAI)\n    B -->|中継リクエスト| D(Azure)\n    B -->|中継リクエスト| E(その他のダウンストリームチャンネル)\n```\n\n現在のリクエストにどのチャネルを使うかを指定するには、トークンの後に チャネル ID を追加します： 例えば、`Authorization: Bearer ONE_API_KEY-CHANNEL_ID` のようにします。\nチャンネル ID を指定するためには、トークンは管理者によって作成される必要があることに注意してください。\n\nもしチャネル ID が指定されない場合、ロードバランシングによってリクエストが複数のチャネルに振り分けられます。\n\n### 環境変数\n1. `REDIS_CONN_STRING`: 設定すると、リクエストレート制限のためのストレージとして、メモリの代わりに Redis が使われる。\n    + 例: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153`\n2. `SESSION_SECRET`: 設定すると、固定セッションキーが使用され、システムの再起動後もログインユーザーのクッキーが有効であることが保証されます。\n    + 例: `SESSION_SECRET=random_string`\n3. `SQL_DSN`: 設定すると、SQLite の代わりに指定したデータベースが使用されます。MySQL バージョン 8.0 を使用してください。\n    + 例: `SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`\n4. `LOG_SQL_DSN`: を設定すると、`logs`テーブルには独立したデータベースが使用されます。MySQLまたはPostgreSQLを使用してください。\n5. `FRONTEND_BASE_URL`: 設定されると、バックエンドアドレスではなく、指定されたフロントエンドアドレスが使われる。\n    + 例: `FRONTEND_BASE_URL=https://openai.justsong.cn`\n6. `SYNC_FREQUENCY`: 設定された場合、システムは定期的にデータベースからコンフィグを秒単位で同期する。設定されていない場合、同期は行われません。\n    + 例: `SYNC_FREQUENCY=60`\n7. `NODE_TYPE`: 設定すると、ノードのタイプを指定する。有効な値は `master` と `slave` である。設定されていない場合、デフォルトは `master`。\n    + 例: `NODE_TYPE=slave`\n8. `CHANNEL_UPDATE_FREQUENCY`: 設定すると、チャンネル残高を分単位で定期的に更新する。設定されていない場合、更新は行われません。\n    + 例: `CHANNEL_UPDATE_FREQUENCY=1440`\n9. `CHANNEL_TEST_FREQUENCY`: 設定すると、チャンネルを定期的にテストする。設定されていない場合、テストは行われません。\n    + 例: `CHANNEL_TEST_FREQUENCY=1440`\n10. `POLLING_INTERVAL`: チャネル残高の更新とチャネルの可用性をテストするときのリクエスト間の時間間隔 (秒)。デフォルトは間隔なし。\n    + 例: `POLLING_INTERVAL=5`\n\n### コマンドラインパラメータ\n1. `--port <port_number>`: サーバがリッスンするポート番号を指定。デフォルトは `3000` です。\n    + 例: `--port 3000`\n2. `--log-dir <log_dir>`: ログディレクトリを指定。設定しない場合、ログは保存されません。\n    + 例: `--log-dir ./logs`\n3. `--version`: システムのバージョン番号を表示して終了する。\n4. `--help`: コマンドの使用法ヘルプとパラメータの説明を表示。\n\n## スクリーンショット\n![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)\n![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)\n\n## FAQ\n1. ノルマとは何か？どのように計算されますか？One API にはノルマ計算の問題はありますか？\n    + ノルマ = グループ倍率 * モデル倍率 * (プロンプトトークンの数 + 完了トークンの数 * 完了倍率)\n    + 完了倍率は、公式の定義と一致するように、GPT3.5 では 1.33、GPT4 では 2 に固定されています。\n    + ストリームモードでない場合、公式 API は消費したトークンの総数を返す。ただし、プロンプトとコンプリートの消費倍率は異なるので注意してください。\n2. アカウント残高は十分なのに、\"insufficient quota\" と表示されるのはなぜですか？\n    + トークンのクォータが十分かどうかご確認ください。トークンクォータはアカウント残高とは別のものです。\n    + トークンクォータは最大使用量を設定するためのもので、ユーザーが自由に設定できます。\n3. チャンネルを使おうとすると \"No available channels\" と表示されます。どうすればいいですか？\n    + ユーザーとチャンネルグループの設定を確認してください。\n    + チャンネルモデルの設定も確認してください。\n4. チャンネルテストがエラーを報告する: \"invalid character '<' looking for beginning of value\"\n    + このエラーは、返された値が有効な JSON ではなく、HTML ページである場合に発生する。\n    + ほとんどの場合、デプロイサイトのIPかプロキシのノードが CloudFlare によってブロックされています。\n5. ChatGPT Next Web でエラーが発生しました: \"Failed to fetch\"\n    + デプロイ時に `BASE_URL` を設定しないでください。\n    + インターフェイスアドレスと API Key が正しいか再確認してください。\n\n## 関連プロジェクト\n* [FastGPT](https://github.com/labring/FastGPT): LLM に基づく知識質問応答システム\n* [CherryStudio](https://github.com/CherryHQ/cherry-studio):  マルチプラットフォーム対応のAIクライアント。複数のサービスプロバイダーを統合管理し、ローカル知識ベースをサポートします。\n## 注\n本プロジェクトはオープンソースプロジェクトです。OpenAI の[利用規約](https://openai.com/policies/terms-of-use)および**適用される法令**を遵守してご利用ください。違法な目的での利用はご遠慮ください。\n\nこのプロジェクトは MIT ライセンスで公開されています。これに基づき、ページの最下部に帰属表示と本プロジェクトへのリンクを含める必要があります。\n\nこのプロジェクトを基にした派生プロジェクトについても同様です。\n\n帰属表示を含めたくない場合は、事前に許可を得なければなりません。\n\nMIT ライセンスによると、このプロジェクトを利用するリスクと責任は利用者が負うべきであり、このオープンソースプロジェクトの開発者は責任を負いません。\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"right\">\n   <strong>中文</strong> | <a href=\"./README.en.md\">English</a> | <a href=\"./README.ja.md\">日本語</a>\n</p>\n\n\n<p align=\"center\">\n  <a href=\"https://github.com/songquanpeng/one-api\"><img src=\"https://raw.githubusercontent.com/songquanpeng/one-api/main/web/default/public/logo.png\" width=\"150\" height=\"150\" alt=\"one-api logo\"></a>\n</p>\n\n<div align=\"center\">\n\n# One API\n\n_✨ 通过标准的 OpenAI API 格式访问所有的大模型，开箱即用 ✨_\n\n</div>\n\n<p align=\"center\">\n  <a href=\"https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE\">\n    <img src=\"https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen\" alt=\"license\">\n  </a>\n  <a href=\"https://github.com/songquanpeng/one-api/releases/latest\">\n    <img src=\"https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases\" alt=\"release\">\n  </a>\n  <a href=\"https://hub.docker.com/repository/docker/justsong/one-api\">\n    <img src=\"https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen\" alt=\"docker pull\">\n  </a>\n  <a href=\"https://github.com/songquanpeng/one-api/releases/latest\">\n    <img src=\"https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases\" alt=\"release\">\n  </a>\n  <a href=\"https://goreportcard.com/report/github.com/songquanpeng/one-api\">\n    <img src=\"https://goreportcard.com/badge/github.com/songquanpeng/one-api\" alt=\"GoReportCard\">\n  </a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/songquanpeng/one-api#部署\">部署教程</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api#使用方法\">使用方法</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api/issues\">意见反馈</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api#截图展示\">截图展示</a>\n  ·\n  <a href=\"https://openai.justsong.cn/\">在线演示</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api#常见问题\">常见问题</a>\n  ·\n  <a href=\"https://github.com/songquanpeng/one-api#相关项目\">相关项目</a>\n  ·\n  <a href=\"https://iamazing.cn/page/reward\">赞赏支持</a>\n</p>\n\n> [!NOTE]\n> 本项目为开源项目，使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用，不得用于非法用途。\n>\n> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求，请勿对中国地区公众提供一切未经备案的生成式人工智能服务。\n\n> [!NOTE]\n> 稳定版 / 预览版镜像地址：[justsong/one-api](https://hub.docker.com/repository/docker/justsong/one-api)\n> 或者 [ghcr.io/songquanpeng/one-api](https://github.com/songquanpeng/one-api/pkgs/container/one-api)\n>\n> alpha 版镜像地址：[justsong/one-api-alpha](https://hub.docker.com/repository/docker/justsong/one-api-alpha)\n> 或者 [ghcr.io/songquanpeng/one-api-alpha](https://github.com/songquanpeng/one-api/pkgs/container/one-api-alpha)\n\n> [!WARNING]\n> 使用 root 用户初次登录系统后，务必修改默认密码 `123456`！\n\n## 功能\n1. 支持多种大模型：\n   + [x] [OpenAI ChatGPT 系列模型](https://platform.openai.com/docs/guides/gpt/chat-completions-api)（支持 [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)）\n   + [x] [Anthropic Claude 系列模型](https://anthropic.com) (支持 AWS Claude)\n   + [x] [Google PaLM2/Gemini 系列模型](https://developers.generativeai.google)\n   + [x] [Mistral 系列模型](https://mistral.ai/)\n   + [x] [字节跳动豆包大模型（火山引擎）](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=2QXCA1VI)\n   + [x] [百度文心一言系列模型](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)\n   + [x] [阿里通义千问系列模型](https://help.aliyun.com/document_detail/2400395.html)\n   + [x] [讯飞星火认知大模型](https://www.xfyun.cn/doc/spark/Web.html)\n   + [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn)\n   + [x] [360 智脑](https://ai.360.cn)\n   + [x] [腾讯混元大模型](https://cloud.tencent.com/document/product/1729)\n   + [x] [Moonshot AI](https://platform.moonshot.cn/)\n   + [x] [百川大模型](https://platform.baichuan-ai.com)\n   + [x] [MINIMAX](https://api.minimax.chat/)\n   + [x] [Groq](https://wow.groq.com/)\n   + [x] [Ollama](https://github.com/ollama/ollama)\n   + [x] [零一万物](https://platform.lingyiwanwu.com/)\n   + [x] [阶跃星辰](https://platform.stepfun.com/)\n   + [x] [Coze](https://www.coze.com/)\n   + [x] [Cohere](https://cohere.com/)\n   + [x] [DeepSeek](https://www.deepseek.com/)\n   + [x] [Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/)\n   + [x] [DeepL](https://www.deepl.com/)\n   + [x] [together.ai](https://www.together.ai/)\n   + [x] [novita.ai](https://www.novita.ai/)\n   + [x] [硅基流动 SiliconCloud](https://cloud.siliconflow.cn/i/rKXmRobW)\n   + [x] [xAI](https://x.ai/)\n2. 支持配置镜像以及众多[第三方代理服务](https://iamazing.cn/page/openai-api-third-party-services)。\n3. 支持通过**负载均衡**的方式访问多个渠道。\n4. 支持 **stream 模式**，可以通过流式传输实现打字机效果。\n5. 支持**多机部署**，[详见此处](#多机部署)。\n6. 支持**令牌管理**，设置令牌的过期时间、额度、允许的 IP 范围以及允许的模型访问。\n7. 支持**兑换码管理**，支持批量生成和导出兑换码，可使用兑换码为账户进行充值。\n8. 支持**渠道管理**，批量创建渠道。\n9. 支持**用户分组**以及**渠道分组**，支持为不同分组设置不同的倍率。\n10. 支持渠道**设置模型列表**。\n11. 支持**查看额度明细**。\n12. 支持**用户邀请奖励**。\n13. 支持以美元为单位显示额度。\n14. 支持发布公告，设置充值链接，设置新用户初始额度。\n15. 支持模型映射，重定向用户的请求模型，如无必要请不要设置，设置之后会导致请求体被重新构造而非直接透传，会导致部分还未正式支持的字段无法传递成功。\n16. 支持失败自动重试。\n17. 支持绘图接口。\n18. 支持 [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/providers/openai/)，渠道设置的代理部分填写 `https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/openai` 即可。\n19. 支持丰富的**自定义**设置，\n    1. 支持自定义系统名称，logo 以及页脚。\n    2. 支持自定义首页和关于页面，可以选择使用 HTML & Markdown 代码进行自定义，或者使用一个单独的网页通过 iframe 嵌入。\n20. 支持通过系统访问令牌调用管理 API，进而**在无需二开的情况下扩展和自定义** One API 的功能，详情请参考此处 [API 文档](./docs/API.md)。\n21. 支持 Cloudflare Turnstile 用户校验。\n22. 支持用户管理，支持**多种用户登录注册方式**：\n    + 邮箱登录注册（支持注册邮箱白名单）以及通过邮箱进行密码重置。\n    + 支持[飞书授权登录](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/authen-v1/authorize/get)（[这里有 One API 的实现细节阐述供参考](https://iamazing.cn/page/feishu-oauth-login)）。\n    + 支持 [GitHub 授权登录](https://github.com/settings/applications/new)。\n    + 微信公众号授权（需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server)）。\n23. 支持主题切换，设置环境变量 `THEME` 即可，默认为 `default`，欢迎 PR 更多主题，具体参考[此处](./web/README.md)。\n24. 配合 [Message Pusher](https://github.com/songquanpeng/message-pusher) 可将报警信息推送到多种 App 上。\n\n## 部署\n### 基于 Docker 进行部署\n```shell\n# 使用 SQLite 的部署命令：\ndocker run --name one-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api\n# 使用 MySQL 的部署命令，在上面的基础上添加 `-e SQL_DSN=\"root:123456@tcp(localhost:3306)/oneapi\"`，请自行修改数据库连接参数，不清楚如何修改请参见下面环境变量一节。\n# 例如：\ndocker run --name one-api -d --restart always -p 3000:3000 -e SQL_DSN=\"root:123456@tcp(localhost:3306)/oneapi\" -e TZ=Asia/Shanghai -v /home/ubuntu/data/one-api:/data justsong/one-api\n```\n\n其中，`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口，可以根据需要进行修改。\n\n数据和日志将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录，请确保该目录存在且具有写入权限，或者更改为合适的目录。\n\n如果启动失败，请添加 `--privileged=true`，具体参考 https://github.com/songquanpeng/one-api/issues/482 。\n\n如果上面的镜像无法拉取，可以尝试使用 GitHub 的 Docker 镜像，将上面的 `justsong/one-api` 替换为 `ghcr.io/songquanpeng/one-api` 即可。\n\n如果你的并发量较大，**务必**设置 `SQL_DSN`，详见下面[环境变量](#环境变量)一节。\n\n更新命令：`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`\n\nNginx 的参考配置：\n```\nserver{\n   server_name openai.justsong.cn;  # 请根据实际情况修改你的域名\n\n   location / {\n          client_max_body_size  64m;\n          proxy_http_version 1.1;\n          proxy_pass http://localhost:3000;  # 请根据实际情况修改你的端口\n          proxy_set_header Host $host;\n          proxy_set_header X-Forwarded-For $remote_addr;\n          proxy_cache_bypass $http_upgrade;\n          proxy_set_header Accept-Encoding gzip;\n          proxy_read_timeout 300s;  # GPT-4 需要较长的超时时间，请自行调整\n   }\n}\n```\n\n之后使用 Let's Encrypt 的 certbot 配置 HTTPS：\n```bash\n# Ubuntu 安装 certbot：\nsudo snap install --classic certbot\nsudo ln -s /snap/bin/certbot /usr/bin/certbot\n# 生成证书 & 修改 Nginx 配置\nsudo certbot --nginx\n# 根据指示进行操作\n# 重启 Nginx\nsudo service nginx restart\n```\n\n初始账号用户名为 `root`，密码为 `123456`。\n\n### 通过宝塔面板进行一键部署\n1. 安装宝塔面板9.2.0及以上版本，前往 [宝塔面板](https://www.bt.cn/new/download.html?r=dk_oneapi) 官网，选择正式版的脚本下载安装；\n2. 安装后登录宝塔面板，在左侧菜单栏中点击 `Docker`，首次进入会提示安装 `Docker` 服务，点击立即安装，按提示完成安装；\n3. 安装完成后在应用商店中搜索 `One-API`，点击安装，配置域名等基本信息即可完成安装；\n\n### 基于 Docker Compose 进行部署\n\n> 仅启动方式不同，参数设置不变，请参考基于 Docker 部署部分\n\n```shell\n# 目前支持 MySQL 启动，数据存储在 ./data/mysql 文件夹内\ndocker-compose up -d\n\n# 查看部署状态\ndocker-compose ps\n```\n\n### 手动部署\n1. 从 [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) 下载可执行文件或者从源码编译：\n   ```shell\n   git clone https://github.com/songquanpeng/one-api.git\n\n   # 构建前端\n   cd one-api/web/default\n   npm install\n   npm run build\n\n   # 构建后端\n   cd ../..\n   go mod download\n   go build -ldflags \"-s -w\" -o one-api\n   ````\n2. 运行：\n   ```shell\n   chmod u+x one-api\n   ./one-api --port 3000 --log-dir ./logs\n   ```\n3. 访问 [http://localhost:3000/](http://localhost:3000/) 并登录。初始账号用户名为 `root`，密码为 `123456`。\n\n更加详细的部署教程[参见此处](https://iamazing.cn/page/how-to-deploy-a-website)。\n\n### 多机部署\n1. 所有服务器 `SESSION_SECRET` 设置一样的值。\n2. 必须设置 `SQL_DSN`，使用 MySQL 数据库而非 SQLite，所有服务器连接同一个数据库。\n3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`，不设置则默认为主服务器。\n4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置，在使用远程数据库的情况下，推荐设置该项并启用 Redis，无论主从。\n5. 从服务器可以选择设置 `FRONTEND_BASE_URL`，以重定向页面请求到主服务器。\n6. 从服务器上**分别**装好 Redis，设置好 `REDIS_CONN_STRING`，这样可以做到在缓存未过期的情况下数据库零访问，可以减少延迟（Redis 集群或者哨兵模式的支持请参考环境变量说明）。\n7. 如果主服务器访问数据库延迟也比较高，则也需要启用 Redis，并设置 `SYNC_FREQUENCY`，以定期从数据库同步配置。\n\n环境变量的具体使用方法详见[此处](#环境变量)。\n\n### 宝塔部署教程\n\n详见 [#175](https://github.com/songquanpeng/one-api/issues/175)。\n\n如果部署后访问出现空白页面，详见 [#97](https://github.com/songquanpeng/one-api/issues/97)。\n\n### 部署第三方服务配合 One API 使用\n> 欢迎 PR 添加更多示例。\n\n#### ChatGPT Next Web\n项目主页：https://github.com/Yidadaa/ChatGPT-Next-Web\n\n```bash\ndocker run --name chat-next-web -d -p 3001:3000 yidadaa/chatgpt-next-web\n```\n\n注意修改端口号，之后在页面上设置接口地址（例如：https://openai.justsong.cn/ ）和 API Key 即可。\n\n#### ChatGPT Web\n项目主页：https://github.com/Chanzhaoyu/chatgpt-web\n\n```bash\ndocker run --name chatgpt-web -d -p 3002:3002 -e OPENAI_API_BASE_URL=https://openai.justsong.cn -e OPENAI_API_KEY=sk-xxx chenzhaoyu94/chatgpt-web\n```\n\n注意修改端口号、`OPENAI_API_BASE_URL` 和 `OPENAI_API_KEY`。\n\n#### QChatGPT - QQ机器人\n项目主页：https://github.com/RockChinQ/QChatGPT\n\n根据[文档](https://qchatgpt.rockchin.top)完成部署后，在 `data/provider.json`设置`requester.openai-chat-completions.base-url`为 One API 实例地址，并填写 API Key 到 `keys.openai` 组中，设置 `model` 为要使用的模型名称。\n\n运行期间可以通过`!model`命令查看、切换可用模型。\n\n### 部署到第三方平台\n<details>\n<summary><strong>部署到 Sealos </strong></summary>\n<div>\n\n> Sealos 的服务器在国外，不需要额外处理网络问题，支持高并发 & 动态伸缩。\n\n点击以下按钮一键部署（部署后访问出现 404 请等待 3~5 分钟）：\n\n[![Deploy-on-Sealos.svg](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)\n\n</div>\n</details>\n\n<details>\n<summary><strong>部署到 Zeabur</strong></summary>\n<div>\n\n> Zeabur 的服务器在国外，自动解决了网络的问题，同时免费的额度也足够个人使用\n\n[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/7Q0KO3)\n\n1. 首先 fork 一份代码。\n2. 进入 [Zeabur](https://zeabur.com?referralCode=songquanpeng)，登录，进入控制台。\n3. 新建一个 Project，在 Service -> Add Service 选择 Marketplace，选择 MySQL，并记下连接参数（用户名、密码、地址、端口）。\n4. 复制链接参数，运行 ```create database `one-api` ``` 创建数据库。\n5. 然后在 Service -> Add Service，选择 Git（第一次使用需要先授权），选择你 fork 的仓库。\n6. Deploy 会自动开始，先取消。进入下方 Variable，添加一个 `PORT`，值为 `3000`，再添加一个 `SQL_DSN`，值为 `<username>:<password>@tcp(<addr>:<port>)/one-api` ，然后保存。 注意如果不填写 `SQL_DSN`，数据将无法持久化，重新部署后数据会丢失。\n7. 选择 Redeploy。\n8. 进入下方 Domains，选择一个合适的域名前缀，如 \"my-one-api\"，最终域名为 \"my-one-api.zeabur.app\"，也可以 CNAME 自己的域名。\n9. 等待部署完成，点击生成的域名进入 One API。\n\n</div>\n</details>\n\n<details>\n<summary><strong>部署到 Render</strong></summary>\n<div>\n\n> Render 提供免费额度，绑卡后可以进一步提升额度\n\nRender 可以直接部署 docker 镜像，不需要 fork 仓库：https://dashboard.render.com\n\n</div>\n</details>\n\n## 配置\n系统本身开箱即用。\n\n你可以通过设置环境变量或者命令行参数进行配置。\n\n等到系统启动后，使用 `root` 用户登录系统并做进一步的配置。\n\n**Note**：如果你不知道某个配置项的含义，可以临时删掉值以看到进一步的提示文字。\n\n## 使用方法\n在`渠道`页面中添加你的 API Key，之后在`令牌`页面中新增访问令牌。\n\n之后就可以使用你的令牌访问 One API 了，使用方式与 [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) 一致。\n\n你需要在各种用到 OpenAI API 的地方设置 API Base 为你的 One API 的部署地址，例如：`https://openai.justsong.cn`，API Key 则为你在 One API 中生成的令牌。\n\n注意，具体的 API Base 的格式取决于你所使用的客户端。\n\n例如对于 OpenAI 的官方库：\n```bash\nOPENAI_API_KEY=\"sk-xxxxxx\"\nOPENAI_API_BASE=\"https://<HOST>:<PORT>/v1\"\n```\n\n```mermaid\ngraph LR\n    A(用户)\n    A --->|使用 One API 分发的 key 进行请求| B(One API)\n    B -->|中继请求| C(OpenAI)\n    B -->|中继请求| D(Azure)\n    B -->|中继请求| E(其他 OpenAI API 格式下游渠道)\n    B -->|中继并修改请求体和返回体| F(非 OpenAI API 格式下游渠道)\n```\n\n可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求，例如：`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。\n注意，需要是管理员用户创建的令牌才能指定渠道 ID。\n\n不加的话将会使用负载均衡的方式使用多个渠道。\n\n### 环境变量\n> One API 支持从 `.env` 文件中读取环境变量，请参照 `.env.example` 文件，使用时请将其重命名为 `.env`。\n1. `REDIS_CONN_STRING`：设置之后将使用 Redis 作为缓存使用。\n   + 例子：`REDIS_CONN_STRING=redis://default:redispw@localhost:49153`\n   + 如果数据库访问延迟很低，没有必要启用 Redis，启用后反而会出现数据滞后的问题。\n   + 如果需要使用哨兵或者集群模式：\n     + 则需要把该环境变量设置为节点列表，例如：`localhost:49153,localhost:49154,localhost:49155`。\n     + 除此之外还需要设置以下环境变量：\n       + `REDIS_PASSWORD`：Redis 集群或者哨兵模式下的密码设置。\n       + `REDIS_MASTER_NAME`：Redis 哨兵模式下主节点的名称。\n2. `SESSION_SECRET`：设置之后将使用固定的会话密钥，这样系统重新启动后已登录用户的 cookie 将依旧有效。\n   + 例子：`SESSION_SECRET=random_string`\n3. `SQL_DSN`：设置之后将使用指定数据库而非 SQLite，请使用 MySQL 或 PostgreSQL。\n   + 例子：\n     + MySQL：`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`\n     + PostgreSQL：`SQL_DSN=postgres://postgres:123456@localhost:5432/oneapi`（适配中，欢迎反馈）\n   + 注意需要提前建立数据库 `oneapi`，无需手动建表，程序将自动建表。\n   + 如果使用本地数据库：部署命令可添加 `--network=\"host\"` 以使得容器内的程序可以访问到宿主机上的 MySQL。\n   + 如果使用云数据库：如果云服务器需要验证身份，需要在连接参数中添加 `?tls=skip-verify`。\n   + 请根据你的数据库配置修改下列参数（或者保持默认值）：\n     + `SQL_MAX_IDLE_CONNS`：最大空闲连接数，默认为 `100`。\n     + `SQL_MAX_OPEN_CONNS`：最大打开连接数，默认为 `1000`。\n       + 如果报错 `Error 1040: Too many connections`，请适当减小该值。\n     + `SQL_CONN_MAX_LIFETIME`：连接的最大生命周期，默认为 `60`，单位分钟。\n4. `LOG_SQL_DSN`：设置之后将为 `logs` 表使用独立的数据库，请使用 MySQL 或 PostgreSQL。\n5. `FRONTEND_BASE_URL`：设置之后将重定向页面请求到指定的地址，仅限从服务器设置。\n   + 例子：`FRONTEND_BASE_URL=https://openai.justsong.cn`\n6. `MEMORY_CACHE_ENABLED`：启用内存缓存，会导致用户额度的更新存在一定的延迟，可选值为 `true` 和 `false`，未设置则默认为 `false`。\n   + 例子：`MEMORY_CACHE_ENABLED=true`\n7. `SYNC_FREQUENCY`：在启用缓存的情况下与数据库同步配置的频率，单位为秒，默认为 `600` 秒。\n   + 例子：`SYNC_FREQUENCY=60`\n8. `NODE_TYPE`：设置之后将指定节点类型，可选值为 `master` 和 `slave`，未设置则默认为 `master`。\n   + 例子：`NODE_TYPE=slave`\n9. `CHANNEL_UPDATE_FREQUENCY`：设置之后将定期更新渠道余额，单位为分钟，未设置则不进行更新。\n   + 例子：`CHANNEL_UPDATE_FREQUENCY=1440`\n10. `CHANNEL_TEST_FREQUENCY`：设置之后将定期检查渠道，单位为分钟，未设置则不进行检查。 \n   +例子：`CHANNEL_TEST_FREQUENCY=1440`\n11. `POLLING_INTERVAL`：批量更新渠道余额以及测试可用性时的请求间隔，单位为秒，默认无间隔。\n    + 例子：`POLLING_INTERVAL=5`\n12. `BATCH_UPDATE_ENABLED`：启用数据库批量更新聚合，会导致用户额度的更新存在一定的延迟可选值为 `true` 和 `false`，未设置则默认为 `false`。\n    + 例子：`BATCH_UPDATE_ENABLED=true`\n    + 如果你遇到了数据库连接数过多的问题，可以尝试启用该选项。\n13. `BATCH_UPDATE_INTERVAL=5`：批量更新聚合的时间间隔，单位为秒，默认为 `5`。\n    + 例子：`BATCH_UPDATE_INTERVAL=5`\n14. 请求频率限制：\n    + `GLOBAL_API_RATE_LIMIT`：全局 API 速率限制（除中继请求外），单 ip 三分钟内的最大请求数，默认为 `180`。\n    + `GLOBAL_WEB_RATE_LIMIT`：全局 Web 速率限制，单 ip 三分钟内的最大请求数，默认为 `60`。\n15. 编码器缓存设置：\n    + `TIKTOKEN_CACHE_DIR`：默认程序启动时会联网下载一些通用的词元的编码，如：`gpt-3.5-turbo`，在一些网络环境不稳定，或者离线情况，可能会导致启动有问题，可以配置此目录缓存数据，可迁移到离线环境。\n    + `DATA_GYM_CACHE_DIR`：目前该配置作用与 `TIKTOKEN_CACHE_DIR` 一致，但是优先级没有它高。\n16. `RELAY_TIMEOUT`：中继超时设置，单位为秒，默认不设置超时时间。\n17. `RELAY_PROXY`：设置后使用该代理来请求 API。\n18. `USER_CONTENT_REQUEST_TIMEOUT`：用户上传内容下载超时时间，单位为秒。\n19. `USER_CONTENT_REQUEST_PROXY`：设置后使用该代理来请求用户上传的内容，例如图片。\n20. `SQLITE_BUSY_TIMEOUT`：SQLite 锁等待超时设置，单位为毫秒，默认 `3000`。\n21. `GEMINI_SAFETY_SETTING`：Gemini 的安全设置，默认 `BLOCK_NONE`。\n22. `GEMINI_VERSION`：One API 所使用的 Gemini 版本，默认为 `v1`。\n23. `THEME`：系统的主题设置，默认为 `default`，具体可选值参考[此处](./web/README.md)。\n24. `ENABLE_METRIC`：是否根据请求成功率禁用渠道，默认不开启，可选值为 `true` 和 `false`。\n25. `METRIC_QUEUE_SIZE`：请求成功率统计队列大小，默认为 `10`。\n26. `METRIC_SUCCESS_RATE_THRESHOLD`：请求成功率阈值，默认为 `0.8`。\n27. `INITIAL_ROOT_TOKEN`：如果设置了该值，则在系统首次启动时会自动创建一个值为该环境变量值的 root 用户令牌。\n28. `INITIAL_ROOT_ACCESS_TOKEN`：如果设置了该值，则在系统首次启动时会自动创建一个值为该环境变量的 root 用户创建系统管理令牌。\n29. `ENFORCE_INCLUDE_USAGE`：是否强制在 stream 模型下返回 usage，默认不开启，可选值为 `true` 和 `false`。\n30. `TEST_PROMPT`：测试模型时的用户 prompt，默认为 `Print your model name exactly and do not output without any other text.`。\n\n### 命令行参数\n1. `--port <port_number>`: 指定服务器监听的端口号，默认为 `3000`。\n   + 例子：`--port 3000`\n2. `--log-dir <log_dir>`: 指定日志文件夹，如果没有设置，默认保存至工作目录的 `logs` 文件夹下。\n   + 例子：`--log-dir ./logs`\n3. `--version`: 打印系统版本号并退出。\n4. `--help`: 查看命令的使用帮助和参数说明。\n\n## 演示\n### 在线演示\n注意，该演示站不提供对外服务：\nhttps://openai.justsong.cn\n\n### 截图展示\n![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)\n![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)\n\n## 常见问题\n1. 额度是什么？怎么计算的？One API 的额度计算有问题？\n   + 额度 = 分组倍率 * 模型倍率 * （提示 token 数 + 补全 token 数 * 补全倍率）\n   + 其中补全倍率对于 GPT3.5 固定为 1.33，GPT4 为 2，与官方保持一致。\n   + 如果是非流模式，官方接口会返回消耗的总 token，但是你要注意提示和补全的消耗倍率不一样。\n   + 注意，One API 的默认倍率就是官方倍率，是已经调整过的。\n2. 账户额度足够为什么提示额度不足？\n   + 请检查你的令牌额度是否足够，这个和账户额度是分开的。\n   + 令牌额度仅供用户设置最大使用量，用户可自由设置。\n3. 提示无可用渠道？\n   + 请检查的用户分组和渠道分组设置。\n   + 以及渠道的模型设置。\n4. 渠道测试报错：`invalid character '<' looking for beginning of value`\n   + 这是因为返回值不是合法的 JSON，而是一个 HTML 页面。\n   + 大概率是你的部署站的 IP 或代理的节点被 CloudFlare 封禁了。\n5. ChatGPT Next Web 报错：`Failed to fetch`\n   + 部署的时候不要设置 `BASE_URL`。\n   + 检查你的接口地址和 API Key 有没有填对。\n   + 检查是否启用了 HTTPS，浏览器会拦截 HTTPS 域名下的 HTTP 请求。\n6. 报错：`当前分组负载已饱和，请稍后再试`\n   + 上游渠道 429 了。\n7. 升级之后我的数据会丢失吗？\n   + 如果使用 MySQL，不会。\n   + 如果使用 SQLite，需要按照我所给的部署命令挂载 volume 持久化 one-api.db 数据库文件，否则容器重启后数据会丢失。\n8. 升级之前数据库需要做变更吗？\n   + 一般情况下不需要，系统将在初始化的时候自动调整。\n   + 如果需要的话，我会在更新日志中说明，并给出脚本。\n9. 手动修改数据库后报错：`数据库一致性已被破坏，请联系管理员`？\n   + 这是检测到 ability 表里有些记录的渠道 id 是不存在的，这大概率是因为你删了 channel 表里的记录但是没有同步在 ability 表里清理无效的渠道。\n   + 对于每一个渠道，其所支持的模型都需要有一个专门的 ability 表的记录，表示该渠道支持该模型。\n\n## 相关项目\n* [FastGPT](https://github.com/labring/FastGPT): 基于 LLM 大语言模型的知识库问答系统\n* [ChatGPT Next Web](https://github.com/Yidadaa/ChatGPT-Next-Web):  一键拥有你自己的跨平台 ChatGPT 应用\n* [VChart](https://github.com/VisActor/VChart):  不只是开箱即用的多端图表库，更是生动灵活的数据故事讲述者。\n* [VMind](https://github.com/VisActor/VMind):  不仅自动，还很智能。开源智能可视化解决方案。\n* [CherryStudio](https://github.com/CherryHQ/cherry-studio):  全平台支持的AI客户端, 多服务商集成管理、本地知识库支持。\n\n## 注意\n\n本项目使用 MIT 协议进行开源，**在此基础上**，必须在页面底部保留署名以及指向本项目的链接。如果不想保留署名，必须首先获得授权。\n\n同样适用于基于本项目的二开项目。\n\n依据 MIT 协议，使用者需自行承担使用本项目的风险与责任，本开源项目开发者与此无关。\n"
  },
  {
    "path": "VERSION",
    "content": ""
  },
  {
    "path": "bin/migration_v0.2-v0.3.sql",
    "content": "UPDATE users\nSET quota = quota + (\n    SELECT SUM(remain_quota)\n    FROM tokens\n    WHERE tokens.user_id = users.id\n)\n"
  },
  {
    "path": "bin/migration_v0.3-v0.4.sql",
    "content": "INSERT INTO abilities (`group`, model, channel_id, enabled)\nSELECT c.`group`, m.model, c.id, 1\nFROM channels c\nCROSS JOIN (\n    SELECT 'gpt-3.5-turbo' AS model UNION ALL\n    SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL\n    SELECT 'gpt-4' AS model UNION ALL\n    SELECT 'gpt-4-0314' AS model\n) AS m\nWHERE c.status = 1\n  AND NOT EXISTS (\n    SELECT 1\n    FROM abilities a\n    WHERE a.`group` = c.`group`\n      AND a.model = m.model\n      AND a.channel_id = c.id\n);\n"
  },
  {
    "path": "bin/time_test.sh",
    "content": "#!/bin/bash\n\nif [ $# -lt 3 ]; then\n  echo \"Usage: time_test.sh <domain> <key> <count> [<model>]\"\n  exit 1\nfi\n\ndomain=$1\nkey=$2\ncount=$3\nmodel=${4:-\"gpt-3.5-turbo\"} # 设置默认模型为 gpt-3.5-turbo\n\ntotal_time=0\ntimes=()\n\nfor ((i=1; i<=count; i++)); do\n  result=$(curl -o /dev/null -s -w \"%{http_code} %{time_total}\\\\n\" \\\n           https://\"$domain\"/v1/chat/completions \\\n           -H \"Content-Type: application/json\" \\\n           -H \"Authorization: Bearer $key\" \\\n           -d '{\"messages\": [{\"content\": \"echo hi\", \"role\": \"user\"}], \"model\": \"'\"$model\"'\", \"stream\": false, \"max_tokens\": 1}')\n  http_code=$(echo \"$result\" | awk '{print $1}')\n  time=$(echo \"$result\" | awk '{print $2}')\n  echo \"HTTP status code: $http_code, Time taken: $time\"\n  total_time=$(bc <<< \"$total_time + $time\")\n  times+=(\"$time\")\ndone\n\naverage_time=$(echo \"scale=4; $total_time / $count\" | bc)\n\nsum_of_squares=0\nfor time in \"${times[@]}\"; do\n  difference=$(echo \"scale=4; $time - $average_time\" | bc)\n  square=$(echo \"scale=4; $difference * $difference\" | bc)\n  sum_of_squares=$(echo \"scale=4; $sum_of_squares + $square\" | bc)\ndone\n\nstandard_deviation=$(echo \"scale=4; sqrt($sum_of_squares / $count)\" | bc)\n\necho \"Average time: $average_time±$standard_deviation\"\n"
  },
  {
    "path": "common/blacklist/main.go",
    "content": "package blacklist\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n)\n\nvar blackList sync.Map\n\nfunc init() {\n\tblackList = sync.Map{}\n}\n\nfunc userId2Key(id int) string {\n\treturn fmt.Sprintf(\"userid_%d\", id)\n}\n\nfunc BanUser(id int) {\n\tblackList.Store(userId2Key(id), true)\n}\n\nfunc UnbanUser(id int) {\n\tblackList.Delete(userId2Key(id))\n}\n\nfunc IsUserBanned(id int) bool {\n\t_, ok := blackList.Load(userId2Key(id))\n\treturn ok\n}\n"
  },
  {
    "path": "common/client/init.go",
    "content": "package client\n\nimport (\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n)\n\nvar HTTPClient *http.Client\nvar ImpatientHTTPClient *http.Client\nvar UserContentRequestHTTPClient *http.Client\n\nfunc Init() {\n\tif config.UserContentRequestProxy != \"\" {\n\t\tlogger.SysLog(fmt.Sprintf(\"using %s as proxy to fetch user content\", config.UserContentRequestProxy))\n\t\tproxyURL, err := url.Parse(config.UserContentRequestProxy)\n\t\tif err != nil {\n\t\t\tlogger.FatalLog(fmt.Sprintf(\"USER_CONTENT_REQUEST_PROXY set but invalid: %s\", config.UserContentRequestProxy))\n\t\t}\n\t\ttransport := &http.Transport{\n\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t}\n\t\tUserContentRequestHTTPClient = &http.Client{\n\t\t\tTransport: transport,\n\t\t\tTimeout:   time.Second * time.Duration(config.UserContentRequestTimeout),\n\t\t}\n\t} else {\n\t\tUserContentRequestHTTPClient = &http.Client{}\n\t}\n\tvar transport http.RoundTripper\n\tif config.RelayProxy != \"\" {\n\t\tlogger.SysLog(fmt.Sprintf(\"using %s as api relay proxy\", config.RelayProxy))\n\t\tproxyURL, err := url.Parse(config.RelayProxy)\n\t\tif err != nil {\n\t\t\tlogger.FatalLog(fmt.Sprintf(\"USER_CONTENT_REQUEST_PROXY set but invalid: %s\", config.UserContentRequestProxy))\n\t\t}\n\t\ttransport = &http.Transport{\n\t\t\tProxy: http.ProxyURL(proxyURL),\n\t\t}\n\t}\n\n\tif config.RelayTimeout == 0 {\n\t\tHTTPClient = &http.Client{\n\t\t\tTransport: transport,\n\t\t}\n\t} else {\n\t\tHTTPClient = &http.Client{\n\t\t\tTimeout:   time.Duration(config.RelayTimeout) * time.Second,\n\t\t\tTransport: transport,\n\t\t}\n\t}\n\n\tImpatientHTTPClient = &http.Client{\n\t\tTimeout:   5 * time.Second,\n\t\tTransport: transport,\n\t}\n}\n"
  },
  {
    "path": "common/config/config.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/songquanpeng/one-api/common/env\"\n\n\t\"github.com/google/uuid\"\n)\n\nvar SystemName = \"One API\"\nvar ServerAddress = \"http://localhost:3000\"\nvar Footer = \"\"\nvar Logo = \"\"\nvar TopUpLink = \"\"\nvar ChatLink = \"\"\nvar QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens\nvar DisplayInCurrencyEnabled = true\nvar DisplayTokenStatEnabled = true\n\n// Any options with \"Secret\", \"Token\" in its key won't be return by GetOptions\n\nvar SessionSecret = uuid.New().String()\n\nvar OptionMap map[string]string\nvar OptionMapRWMutex sync.RWMutex\n\nvar ItemsPerPage = 10\nvar MaxRecentItems = 100\n\nvar PasswordLoginEnabled = true\nvar PasswordRegisterEnabled = true\nvar EmailVerificationEnabled = false\nvar GitHubOAuthEnabled = false\nvar OidcEnabled = false\nvar WeChatAuthEnabled = false\nvar TurnstileCheckEnabled = false\nvar RegisterEnabled = true\n\nvar EmailDomainRestrictionEnabled = false\nvar EmailDomainWhitelist = []string{\n\t\"gmail.com\",\n\t\"163.com\",\n\t\"126.com\",\n\t\"qq.com\",\n\t\"outlook.com\",\n\t\"hotmail.com\",\n\t\"icloud.com\",\n\t\"yahoo.com\",\n\t\"foxmail.com\",\n}\n\nvar DebugEnabled = strings.ToLower(os.Getenv(\"DEBUG\")) == \"true\"\nvar DebugSQLEnabled = strings.ToLower(os.Getenv(\"DEBUG_SQL\")) == \"true\"\nvar MemoryCacheEnabled = strings.ToLower(os.Getenv(\"MEMORY_CACHE_ENABLED\")) == \"true\"\n\nvar LogConsumeEnabled = true\n\nvar SMTPServer = \"\"\nvar SMTPPort = 587\nvar SMTPAccount = \"\"\nvar SMTPFrom = \"\"\nvar SMTPToken = \"\"\n\nvar GitHubClientId = \"\"\nvar GitHubClientSecret = \"\"\n\nvar LarkClientId = \"\"\nvar LarkClientSecret = \"\"\n\nvar OidcClientId = \"\"\nvar OidcClientSecret = \"\"\nvar OidcWellKnown = \"\"\nvar OidcAuthorizationEndpoint = \"\"\nvar OidcTokenEndpoint = \"\"\nvar OidcUserinfoEndpoint = \"\"\n\nvar WeChatServerAddress = \"\"\nvar WeChatServerToken = \"\"\nvar WeChatAccountQRCodeImageURL = \"\"\n\nvar MessagePusherAddress = \"\"\nvar MessagePusherToken = \"\"\n\nvar TurnstileSiteKey = \"\"\nvar TurnstileSecretKey = \"\"\n\nvar QuotaForNewUser int64 = 0\nvar QuotaForInviter int64 = 0\nvar QuotaForInvitee int64 = 0\nvar ChannelDisableThreshold = 5.0\nvar AutomaticDisableChannelEnabled = false\nvar AutomaticEnableChannelEnabled = false\nvar QuotaRemindThreshold int64 = 1000\nvar PreConsumedQuota int64 = 500\nvar ApproximateTokenEnabled = false\nvar RetryTimes = 0\n\nvar RootUserEmail = \"\"\n\nvar IsMasterNode = os.Getenv(\"NODE_TYPE\") != \"slave\"\n\nvar requestInterval, _ = strconv.Atoi(os.Getenv(\"POLLING_INTERVAL\"))\nvar RequestInterval = time.Duration(requestInterval) * time.Second\n\nvar SyncFrequency = env.Int(\"SYNC_FREQUENCY\", 10*60) // unit is second\n\nvar BatchUpdateEnabled = false\nvar BatchUpdateInterval = env.Int(\"BATCH_UPDATE_INTERVAL\", 5)\n\nvar RelayTimeout = env.Int(\"RELAY_TIMEOUT\", 0) // unit is second\n\nvar GeminiSafetySetting = env.String(\"GEMINI_SAFETY_SETTING\", \"BLOCK_NONE\")\n\nvar Theme = env.String(\"THEME\", \"default\")\nvar ValidThemes = map[string]bool{\n\t\"default\": true,\n\t\"berry\":   true,\n\t\"air\":     true,\n}\n\n// All duration's unit is seconds\n// Shouldn't larger then RateLimitKeyExpirationDuration\nvar (\n\tGlobalApiRateLimitNum            = env.Int(\"GLOBAL_API_RATE_LIMIT\", 480)\n\tGlobalApiRateLimitDuration int64 = 3 * 60\n\n\tGlobalWebRateLimitNum            = env.Int(\"GLOBAL_WEB_RATE_LIMIT\", 240)\n\tGlobalWebRateLimitDuration int64 = 3 * 60\n\n\tUploadRateLimitNum            = 10\n\tUploadRateLimitDuration int64 = 60\n\n\tDownloadRateLimitNum            = 10\n\tDownloadRateLimitDuration int64 = 60\n\n\tCriticalRateLimitNum            = 20\n\tCriticalRateLimitDuration int64 = 20 * 60\n)\n\nvar RateLimitKeyExpirationDuration = 20 * time.Minute\n\nvar EnableMetric = env.Bool(\"ENABLE_METRIC\", false)\nvar MetricQueueSize = env.Int(\"METRIC_QUEUE_SIZE\", 10)\nvar MetricSuccessRateThreshold = env.Float64(\"METRIC_SUCCESS_RATE_THRESHOLD\", 0.8)\nvar MetricSuccessChanSize = env.Int(\"METRIC_SUCCESS_CHAN_SIZE\", 1024)\nvar MetricFailChanSize = env.Int(\"METRIC_FAIL_CHAN_SIZE\", 128)\n\nvar InitialRootToken = os.Getenv(\"INITIAL_ROOT_TOKEN\")\n\nvar InitialRootAccessToken = os.Getenv(\"INITIAL_ROOT_ACCESS_TOKEN\")\n\nvar GeminiVersion = env.String(\"GEMINI_VERSION\", \"v1\")\n\nvar OnlyOneLogFile = env.Bool(\"ONLY_ONE_LOG_FILE\", false)\n\nvar RelayProxy = env.String(\"RELAY_PROXY\", \"\")\nvar UserContentRequestProxy = env.String(\"USER_CONTENT_REQUEST_PROXY\", \"\")\nvar UserContentRequestTimeout = env.Int(\"USER_CONTENT_REQUEST_TIMEOUT\", 30)\n\nvar EnforceIncludeUsage = env.Bool(\"ENFORCE_INCLUDE_USAGE\", false)\nvar TestPrompt = env.String(\"TEST_PROMPT\", \"Output only your specific model name with no additional text.\")\n"
  },
  {
    "path": "common/constants.go",
    "content": "package common\n\nimport \"time\"\n\nvar StartTime = time.Now().Unix() // unit: second\nvar Version = \"v0.0.0\"            // this hard coding will be replaced automatically when building, no need to manually change\n"
  },
  {
    "path": "common/conv/any.go",
    "content": "package conv\n\nfunc AsString(v any) string {\n\tstr, _ := v.(string)\n\treturn str\n}\n"
  },
  {
    "path": "common/crypto.go",
    "content": "package common\n\nimport \"golang.org/x/crypto/bcrypt\"\n\nfunc Password2Hash(password string) (string, error) {\n\tpasswordBytes := []byte(password)\n\thashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)\n\treturn string(hashedPassword), err\n}\n\nfunc ValidatePasswordAndHash(password string, hash string) bool {\n\terr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))\n\treturn err == nil\n}\n"
  },
  {
    "path": "common/ctxkey/key.go",
    "content": "package ctxkey\n\nconst (\n\tConfig            = \"config\"\n\tId                = \"id\"\n\tUsername          = \"username\"\n\tRole              = \"role\"\n\tStatus            = \"status\"\n\tChannel           = \"channel\"\n\tChannelId         = \"channel_id\"\n\tSpecificChannelId = \"specific_channel_id\"\n\tRequestModel      = \"request_model\"\n\tConvertedRequest  = \"converted_request\"\n\tOriginalModel     = \"original_model\"\n\tGroup             = \"group\"\n\tModelMapping      = \"model_mapping\"\n\tChannelName       = \"channel_name\"\n\tTokenId           = \"token_id\"\n\tTokenName         = \"token_name\"\n\tBaseURL           = \"base_url\"\n\tAvailableModels   = \"available_models\"\n\tKeyRequestBody    = \"key_request_body\"\n\tSystemPrompt      = \"system_prompt\"\n)\n"
  },
  {
    "path": "common/custom-event.go",
    "content": "// Copyright 2014 Manu Martinez-Almeida.  All rights reserved.\n// Use of this source code is governed by a MIT style\n// license that can be found in the LICENSE file.\n\npackage common\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\ntype stringWriter interface {\n\tio.Writer\n\twriteString(string) (int, error)\n}\n\ntype stringWrapper struct {\n\tio.Writer\n}\n\nfunc (w stringWrapper) writeString(str string) (int, error) {\n\treturn w.Writer.Write([]byte(str))\n}\n\nfunc checkWriter(writer io.Writer) stringWriter {\n\tif w, ok := writer.(stringWriter); ok {\n\t\treturn w\n\t} else {\n\t\treturn stringWrapper{writer}\n\t}\n}\n\n// Server-Sent Events\n// W3C Working Draft 29 October 2009\n// http://www.w3.org/TR/2009/WD-eventsource-20091029/\n\nvar contentType = []string{\"text/event-stream\"}\nvar noCache = []string{\"no-cache\"}\n\nvar fieldReplacer = strings.NewReplacer(\n\t\"\\n\", \"\\\\n\",\n\t\"\\r\", \"\\\\r\")\n\nvar dataReplacer = strings.NewReplacer(\n\t\"\\n\", \"\\ndata:\",\n\t\"\\r\", \"\\\\r\")\n\ntype CustomEvent struct {\n\tEvent string\n\tId    string\n\tRetry uint\n\tData  interface{}\n}\n\nfunc encode(writer io.Writer, event CustomEvent) error {\n\tw := checkWriter(writer)\n\treturn writeData(w, event.Data)\n}\n\nfunc writeData(w stringWriter, data interface{}) error {\n\tdataReplacer.WriteString(w, fmt.Sprint(data))\n\tif strings.HasPrefix(data.(string), \"data\") {\n\t\tw.writeString(\"\\n\\n\")\n\t}\n\treturn nil\n}\n\nfunc (r CustomEvent) Render(w http.ResponseWriter) error {\n\tr.WriteContentType(w)\n\treturn encode(w, r)\n}\n\nfunc (r CustomEvent) WriteContentType(w http.ResponseWriter) {\n\theader := w.Header()\n\theader[\"Content-Type\"] = contentType\n\n\tif _, exist := header[\"Cache-Control\"]; !exist {\n\t\theader[\"Cache-Control\"] = noCache\n\t}\n}\n"
  },
  {
    "path": "common/database.go",
    "content": "package common\n\nimport (\n\t\"github.com/songquanpeng/one-api/common/env\"\n)\n\nvar UsingSQLite = false\nvar UsingPostgreSQL = false\nvar UsingMySQL = false\n\nvar SQLitePath = \"one-api.db\"\nvar SQLiteBusyTimeout = env.Int(\"SQLITE_BUSY_TIMEOUT\", 3000)\n"
  },
  {
    "path": "common/embed-file-system.go",
    "content": "package common\n\nimport (\n\t\"embed\"\n\t\"github.com/gin-contrib/static\"\n\t\"io/fs\"\n\t\"net/http\"\n)\n\n// Credit: https://github.com/gin-contrib/static/issues/19\n\ntype embedFileSystem struct {\n\thttp.FileSystem\n}\n\nfunc (e embedFileSystem) Exists(prefix string, path string) bool {\n\t_, err := e.Open(path)\n\treturn err == nil\n}\n\nfunc EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {\n\tefs, err := fs.Sub(fsEmbed, targetPath)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn embedFileSystem{\n\t\tFileSystem: http.FS(efs),\n\t}\n}\n"
  },
  {
    "path": "common/env/helper.go",
    "content": "package env\n\nimport (\n\t\"os\"\n\t\"strconv\"\n)\n\nfunc Bool(env string, defaultValue bool) bool {\n\tif env == \"\" || os.Getenv(env) == \"\" {\n\t\treturn defaultValue\n\t}\n\treturn os.Getenv(env) == \"true\"\n}\n\nfunc Int(env string, defaultValue int) int {\n\tif env == \"\" || os.Getenv(env) == \"\" {\n\t\treturn defaultValue\n\t}\n\tnum, err := strconv.Atoi(os.Getenv(env))\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\treturn num\n}\n\nfunc Float64(env string, defaultValue float64) float64 {\n\tif env == \"\" || os.Getenv(env) == \"\" {\n\t\treturn defaultValue\n\t}\n\tnum, err := strconv.ParseFloat(os.Getenv(env), 64)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\treturn num\n}\n\nfunc String(env string, defaultValue string) string {\n\tif env == \"\" || os.Getenv(env) == \"\" {\n\t\treturn defaultValue\n\t}\n\treturn os.Getenv(env)\n}\n"
  },
  {
    "path": "common/gin.go",
    "content": "package common\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n)\n\nfunc GetRequestBody(c *gin.Context) ([]byte, error) {\n\trequestBody, _ := c.Get(ctxkey.KeyRequestBody)\n\tif requestBody != nil {\n\t\treturn requestBody.([]byte), nil\n\t}\n\trequestBody, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t_ = c.Request.Body.Close()\n\tc.Set(ctxkey.KeyRequestBody, requestBody)\n\treturn requestBody.([]byte), nil\n}\n\nfunc UnmarshalBodyReusable(c *gin.Context, v any) error {\n\trequestBody, err := GetRequestBody(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcontentType := c.Request.Header.Get(\"Content-Type\")\n\tif strings.HasPrefix(contentType, \"application/json\") {\n\t\terr = json.Unmarshal(requestBody, &v)\n\t} else {\n\t\tc.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))\n\t\terr = c.ShouldBind(&v)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Reset request body\n\tc.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))\n\treturn nil\n}\n\nfunc SetEventStreamHeaders(c *gin.Context) {\n\tc.Writer.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tc.Writer.Header().Set(\"Cache-Control\", \"no-cache\")\n\tc.Writer.Header().Set(\"Connection\", \"keep-alive\")\n\tc.Writer.Header().Set(\"Transfer-Encoding\", \"chunked\")\n\tc.Writer.Header().Set(\"X-Accel-Buffering\", \"no\")\n}\n"
  },
  {
    "path": "common/helper/helper.go",
    "content": "package helper\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"log\"\n\t\"net\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/random\"\n)\n\nfunc OpenBrowser(url string) {\n\tvar err error\n\n\tswitch runtime.GOOS {\n\tcase \"linux\":\n\t\terr = exec.Command(\"xdg-open\", url).Start()\n\tcase \"windows\":\n\t\terr = exec.Command(\"rundll32\", \"url.dll,FileProtocolHandler\", url).Start()\n\tcase \"darwin\":\n\t\terr = exec.Command(\"open\", url).Start()\n\t}\n\tif err != nil {\n\t\tlog.Println(err)\n\t}\n}\n\nfunc GetIp() (ip string) {\n\tips, err := net.InterfaceAddrs()\n\tif err != nil {\n\t\tlog.Println(err)\n\t\treturn ip\n\t}\n\n\tfor _, a := range ips {\n\t\tif ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {\n\t\t\tif ipNet.IP.To4() != nil {\n\t\t\t\tip = ipNet.IP.String()\n\t\t\t\tif strings.HasPrefix(ip, \"10\") {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif strings.HasPrefix(ip, \"172\") {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif strings.HasPrefix(ip, \"192.168\") {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tip = \"\"\n\t\t\t}\n\t\t}\n\t}\n\treturn\n}\n\nvar sizeKB = 1024\nvar sizeMB = sizeKB * 1024\nvar sizeGB = sizeMB * 1024\n\nfunc Bytes2Size(num int64) string {\n\tnumStr := \"\"\n\tunit := \"B\"\n\tif num/int64(sizeGB) > 1 {\n\t\tnumStr = fmt.Sprintf(\"%.2f\", float64(num)/float64(sizeGB))\n\t\tunit = \"GB\"\n\t} else if num/int64(sizeMB) > 1 {\n\t\tnumStr = fmt.Sprintf(\"%d\", int(float64(num)/float64(sizeMB)))\n\t\tunit = \"MB\"\n\t} else if num/int64(sizeKB) > 1 {\n\t\tnumStr = fmt.Sprintf(\"%d\", int(float64(num)/float64(sizeKB)))\n\t\tunit = \"KB\"\n\t} else {\n\t\tnumStr = fmt.Sprintf(\"%d\", num)\n\t}\n\treturn numStr + \" \" + unit\n}\n\nfunc Interface2String(inter interface{}) string {\n\tswitch inter := inter.(type) {\n\tcase string:\n\t\treturn inter\n\tcase int:\n\t\treturn fmt.Sprintf(\"%d\", inter)\n\tcase float64:\n\t\treturn fmt.Sprintf(\"%f\", inter)\n\t}\n\treturn \"Not Implemented\"\n}\n\nfunc UnescapeHTML(x string) interface{} {\n\treturn template.HTML(x)\n}\n\nfunc IntMax(a int, b int) int {\n\tif a >= b {\n\t\treturn a\n\t} else {\n\t\treturn b\n\t}\n}\n\nfunc GenRequestID() string {\n\treturn GetTimeString() + random.GetRandomNumberString(8)\n}\n\nfunc SetRequestID(ctx context.Context, id string) context.Context {\n\treturn context.WithValue(ctx, RequestIdKey, id)\n}\n\nfunc GetRequestID(ctx context.Context) string {\n\trawRequestId := ctx.Value(RequestIdKey)\n\tif rawRequestId == nil {\n\t\treturn \"\"\n\t}\n\treturn rawRequestId.(string)\n}\n\nfunc GetResponseID(c *gin.Context) string {\n\tlogID := c.GetString(RequestIdKey)\n\treturn fmt.Sprintf(\"chatcmpl-%s\", logID)\n}\n\nfunc Max(a int, b int) int {\n\tif a >= b {\n\t\treturn a\n\t} else {\n\t\treturn b\n\t}\n}\n\nfunc AssignOrDefault(value string, defaultValue string) string {\n\tif len(value) != 0 {\n\t\treturn value\n\t}\n\treturn defaultValue\n}\n\nfunc MessageWithRequestId(message string, id string) string {\n\treturn fmt.Sprintf(\"%s (request id: %s)\", message, id)\n}\n\nfunc String2Int(str string) int {\n\tnum, err := strconv.Atoi(str)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn num\n}\n\nfunc Float64PtrMax(p *float64, maxValue float64) *float64 {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tif *p > maxValue {\n\t\treturn &maxValue\n\t}\n\treturn p\n}\n\nfunc Float64PtrMin(p *float64, minValue float64) *float64 {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tif *p < minValue {\n\t\treturn &minValue\n\t}\n\treturn p\n}\n"
  },
  {
    "path": "common/helper/key.go",
    "content": "package helper\n\nconst (\n\tRequestIdKey = \"X-Oneapi-Request-Id\"\n)\n"
  },
  {
    "path": "common/helper/time.go",
    "content": "package helper\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\nfunc GetTimestamp() int64 {\n\treturn time.Now().Unix()\n}\n\nfunc GetTimeString() string {\n\tnow := time.Now()\n\treturn fmt.Sprintf(\"%s%d\", now.Format(\"20060102150405\"), now.UnixNano()%1e9)\n}\n\n// CalcElapsedTime return the elapsed time in milliseconds (ms)\nfunc CalcElapsedTime(start time.Time) int64 {\n\treturn time.Now().Sub(start).Milliseconds()\n}\n"
  },
  {
    "path": "common/i18n/i18n.go",
    "content": "package i18n\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n//go:embed locales/*.json\nvar localesFS embed.FS\n\nvar (\n\ttranslations = make(map[string]map[string]string)\n\tdefaultLang  = \"en\"\n\tContextKey   = \"i18n\"\n)\n\n// Init loads all translation files from embedded filesystem\nfunc Init() error {\n\tentries, err := localesFS.ReadDir(\"locales\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() || !strings.HasSuffix(entry.Name(), \".json\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tlangCode := strings.TrimSuffix(entry.Name(), \".json\")\n\t\tcontent, err := localesFS.ReadFile(\"locales/\" + entry.Name())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar translation map[string]string\n\t\tif err := json.Unmarshal(content, &translation); err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttranslations[langCode] = translation\n\t}\n\n\treturn nil\n}\n\nfunc GetLang(c *gin.Context) string {\n\trawLang, ok := c.Get(ContextKey)\n\tif !ok {\n\t\treturn defaultLang\n\t}\n\tlang, _ := rawLang.(string)\n\tif lang != \"\" {\n\t\treturn lang\n\t}\n\treturn defaultLang\n}\n\nfunc Translate(c *gin.Context, message string) string {\n\tlang := GetLang(c)\n\treturn translateHelper(lang, message)\n}\n\nfunc translateHelper(lang, message string) string {\n\tif trans, ok := translations[lang]; ok {\n\t\tif translated, exists := trans[message]; exists {\n\t\t\treturn translated\n\t\t}\n\t}\n\treturn message\n}\n"
  },
  {
    "path": "common/i18n/locales/en.json",
    "content": "{\n  \"invalid_input\": \"Invalid input, please check your input\",\n  \"send_email_failed\": \"failed to send email: \",\n  \"invalid_parameter\": \"invalid parameter\"\n}\n"
  },
  {
    "path": "common/i18n/locales/zh-CN.json",
    "content": "{\n  \"invalid_input\": \"无效的输入，请检查您的输入\",\n  \"send_email_failed\": \"发送邮件失败：\",\n  \"invalid_parameter\": \"无效的参数\"\n}\n"
  },
  {
    "path": "common/image/image.go",
    "content": "package image\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"image\"\n\t_ \"image/gif\"\n\t_ \"image/jpeg\"\n\t_ \"image/png\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\n\t_ \"golang.org/x/image/webp\"\n)\n\n// Regex to match data URL pattern\nvar dataURLPattern = regexp.MustCompile(`data:image/([^;]+);base64,(.*)`)\n\nfunc IsImageUrl(url string) (bool, error) {\n\tresp, err := client.UserContentRequestHTTPClient.Head(url)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif !strings.HasPrefix(resp.Header.Get(\"Content-Type\"), \"image/\") {\n\t\treturn false, nil\n\t}\n\treturn true, nil\n}\n\nfunc GetImageSizeFromUrl(url string) (width int, height int, err error) {\n\tisImage, err := IsImageUrl(url)\n\tif !isImage {\n\t\treturn\n\t}\n\tresp, err := client.UserContentRequestHTTPClient.Get(url)\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\timg, _, err := image.DecodeConfig(resp.Body)\n\tif err != nil {\n\t\treturn\n\t}\n\treturn img.Width, img.Height, nil\n}\n\nfunc GetImageFromUrl(url string) (mimeType string, data string, err error) {\n\t// Check if the URL is a data URL\n\tmatches := dataURLPattern.FindStringSubmatch(url)\n\tif len(matches) == 3 {\n\t\t// URL is a data URL\n\t\tmimeType = \"image/\" + matches[1]\n\t\tdata = matches[2]\n\t\treturn\n\t}\n\n\tisImage, err := IsImageUrl(url)\n\tif !isImage {\n\t\treturn\n\t}\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tbuffer := bytes.NewBuffer(nil)\n\t_, err = buffer.ReadFrom(resp.Body)\n\tif err != nil {\n\t\treturn\n\t}\n\tmimeType = resp.Header.Get(\"Content-Type\")\n\tdata = base64.StdEncoding.EncodeToString(buffer.Bytes())\n\treturn\n}\n\nvar (\n\treg = regexp.MustCompile(`data:image/([^;]+);base64,`)\n)\n\nvar readerPool = sync.Pool{\n\tNew: func() interface{} {\n\t\treturn &bytes.Reader{}\n\t},\n}\n\nfunc GetImageSizeFromBase64(encoded string) (width int, height int, err error) {\n\tdecoded, err := base64.StdEncoding.DecodeString(reg.ReplaceAllString(encoded, \"\"))\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\n\treader := readerPool.Get().(*bytes.Reader)\n\tdefer readerPool.Put(reader)\n\treader.Reset(decoded)\n\n\timg, _, err := image.DecodeConfig(reader)\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\n\treturn img.Width, img.Height, nil\n}\n\nfunc GetImageSize(image string) (width int, height int, err error) {\n\tif strings.HasPrefix(image, \"data:image/\") {\n\t\treturn GetImageSizeFromBase64(image)\n\t}\n\treturn GetImageSizeFromUrl(image)\n}\n"
  },
  {
    "path": "common/image/image_test.go",
    "content": "package image_test\n\nimport (\n\t\"encoding/base64\"\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"image\"\n\t_ \"image/gif\"\n\t_ \"image/jpeg\"\n\t_ \"image/png\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\timg \"github.com/songquanpeng/one-api/common/image\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t_ \"golang.org/x/image/webp\"\n)\n\ntype CountingReader struct {\n\treader    io.Reader\n\tBytesRead int\n}\n\nfunc (r *CountingReader) Read(p []byte) (n int, err error) {\n\tn, err = r.reader.Read(p)\n\tr.BytesRead += n\n\treturn n, err\n}\n\nvar (\n\tcases = []struct {\n\t\turl    string\n\t\tformat string\n\t\twidth  int\n\t\theight int\n\t}{\n\t\t{\"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg\", \"jpeg\", 2560, 1669},\n\t\t{\"https://upload.wikimedia.org/wikipedia/commons/9/97/Basshunter_live_performances.png\", \"png\", 4500, 2592},\n\t\t{\"https://upload.wikimedia.org/wikipedia/commons/c/c6/TO_THE_ONE_SOMETHINGNESS.webp\", \"webp\", 984, 985},\n\t\t{\"https://upload.wikimedia.org/wikipedia/commons/d/d0/01_Das_Sandberg-Modell.gif\", \"gif\", 1917, 1533},\n\t\t{\"https://upload.wikimedia.org/wikipedia/commons/6/62/102Cervus.jpg\", \"jpeg\", 270, 230},\n\t}\n)\n\nfunc TestMain(m *testing.M) {\n\tclient.Init()\n\tm.Run()\n}\n\nfunc TestDecode(t *testing.T) {\n\t// Bytes read: varies sometimes\n\t// jpeg: 1063892\n\t// png: 294462\n\t// webp: 99529\n\t// gif: 956153\n\t// jpeg#01: 32805\n\tfor _, c := range cases {\n\t\tt.Run(\"Decode:\"+c.format, func(t *testing.T) {\n\t\t\tresp, err := http.Get(c.url)\n\t\t\tassert.NoError(t, err)\n\t\t\tdefer resp.Body.Close()\n\t\t\treader := &CountingReader{reader: resp.Body}\n\t\t\timg, format, err := image.Decode(reader)\n\t\t\tassert.NoError(t, err)\n\t\t\tsize := img.Bounds().Size()\n\t\t\tassert.Equal(t, c.format, format)\n\t\t\tassert.Equal(t, c.width, size.X)\n\t\t\tassert.Equal(t, c.height, size.Y)\n\t\t\tt.Logf(\"Bytes read: %d\", reader.BytesRead)\n\t\t})\n\t}\n\n\t// Bytes read:\n\t// jpeg: 4096\n\t// png: 4096\n\t// webp: 4096\n\t// gif: 4096\n\t// jpeg#01: 4096\n\tfor _, c := range cases {\n\t\tt.Run(\"DecodeConfig:\"+c.format, func(t *testing.T) {\n\t\t\tresp, err := http.Get(c.url)\n\t\t\tassert.NoError(t, err)\n\t\t\tdefer resp.Body.Close()\n\t\t\treader := &CountingReader{reader: resp.Body}\n\t\t\tconfig, format, err := image.DecodeConfig(reader)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, c.format, format)\n\t\t\tassert.Equal(t, c.width, config.Width)\n\t\t\tassert.Equal(t, c.height, config.Height)\n\t\t\tt.Logf(\"Bytes read: %d\", reader.BytesRead)\n\t\t})\n\t}\n}\n\nfunc TestBase64(t *testing.T) {\n\t// Bytes read:\n\t// jpeg: 1063892\n\t// png: 294462\n\t// webp: 99072\n\t// gif: 953856\n\t// jpeg#01: 32805\n\tfor _, c := range cases {\n\t\tt.Run(\"Decode:\"+c.format, func(t *testing.T) {\n\t\t\tresp, err := http.Get(c.url)\n\t\t\tassert.NoError(t, err)\n\t\t\tdefer resp.Body.Close()\n\t\t\tdata, err := io.ReadAll(resp.Body)\n\t\t\tassert.NoError(t, err)\n\t\t\tencoded := base64.StdEncoding.EncodeToString(data)\n\t\t\tbody := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded))\n\t\t\treader := &CountingReader{reader: body}\n\t\t\timg, format, err := image.Decode(reader)\n\t\t\tassert.NoError(t, err)\n\t\t\tsize := img.Bounds().Size()\n\t\t\tassert.Equal(t, c.format, format)\n\t\t\tassert.Equal(t, c.width, size.X)\n\t\t\tassert.Equal(t, c.height, size.Y)\n\t\t\tt.Logf(\"Bytes read: %d\", reader.BytesRead)\n\t\t})\n\t}\n\n\t// Bytes read:\n\t// jpeg: 1536\n\t// png: 768\n\t// webp: 768\n\t// gif: 1536\n\t// jpeg#01: 3840\n\tfor _, c := range cases {\n\t\tt.Run(\"DecodeConfig:\"+c.format, func(t *testing.T) {\n\t\t\tresp, err := http.Get(c.url)\n\t\t\tassert.NoError(t, err)\n\t\t\tdefer resp.Body.Close()\n\t\t\tdata, err := io.ReadAll(resp.Body)\n\t\t\tassert.NoError(t, err)\n\t\t\tencoded := base64.StdEncoding.EncodeToString(data)\n\t\t\tbody := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded))\n\t\t\treader := &CountingReader{reader: body}\n\t\t\tconfig, format, err := image.DecodeConfig(reader)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, c.format, format)\n\t\t\tassert.Equal(t, c.width, config.Width)\n\t\t\tassert.Equal(t, c.height, config.Height)\n\t\t\tt.Logf(\"Bytes read: %d\", reader.BytesRead)\n\t\t})\n\t}\n}\n\nfunc TestGetImageSize(t *testing.T) {\n\tfor i, c := range cases {\n\t\tt.Run(\"Decode:\"+strconv.Itoa(i), func(t *testing.T) {\n\t\t\twidth, height, err := img.GetImageSize(c.url)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, c.width, width)\n\t\t\tassert.Equal(t, c.height, height)\n\t\t})\n\t}\n}\n\nfunc TestGetImageSizeFromBase64(t *testing.T) {\n\tfor i, c := range cases {\n\t\tt.Run(\"Decode:\"+strconv.Itoa(i), func(t *testing.T) {\n\t\t\tresp, err := http.Get(c.url)\n\t\t\tassert.NoError(t, err)\n\t\t\tdefer resp.Body.Close()\n\t\t\tdata, err := io.ReadAll(resp.Body)\n\t\t\tassert.NoError(t, err)\n\t\t\tencoded := base64.StdEncoding.EncodeToString(data)\n\t\t\twidth, height, err := img.GetImageSizeFromBase64(encoded)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, c.width, width)\n\t\t\tassert.Equal(t, c.height, height)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "common/init.go",
    "content": "package common\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nvar (\n\tPort         = flag.Int(\"port\", 3000, \"the listening port\")\n\tPrintVersion = flag.Bool(\"version\", false, \"print version and exit\")\n\tPrintHelp    = flag.Bool(\"help\", false, \"print help and exit\")\n\tLogDir       = flag.String(\"log-dir\", \"./logs\", \"specify the log directory\")\n)\n\nfunc printHelp() {\n\tfmt.Println(\"One API \" + Version + \" - All in one API service for OpenAI API.\")\n\tfmt.Println(\"Copyright (C) 2023 JustSong. All rights reserved.\")\n\tfmt.Println(\"GitHub: https://github.com/songquanpeng/one-api\")\n\tfmt.Println(\"Usage: one-api [--port <port>] [--log-dir <log directory>] [--version] [--help]\")\n}\n\nfunc Init() {\n\tflag.Parse()\n\n\tif *PrintVersion {\n\t\tfmt.Println(Version)\n\t\tos.Exit(0)\n\t}\n\n\tif *PrintHelp {\n\t\tprintHelp()\n\t\tos.Exit(0)\n\t}\n\n\tif os.Getenv(\"SESSION_SECRET\") != \"\" {\n\t\tif os.Getenv(\"SESSION_SECRET\") == \"random_string\" {\n\t\t\tlogger.SysError(\"SESSION_SECRET is set to an example value, please change it to a random string.\")\n\t\t} else {\n\t\t\tconfig.SessionSecret = os.Getenv(\"SESSION_SECRET\")\n\t\t}\n\t}\n\tif os.Getenv(\"SQLITE_PATH\") != \"\" {\n\t\tSQLitePath = os.Getenv(\"SQLITE_PATH\")\n\t}\n\tif *LogDir != \"\" {\n\t\tvar err error\n\t\t*LogDir, err = filepath.Abs(*LogDir)\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tif _, err := os.Stat(*LogDir); os.IsNotExist(err) {\n\t\t\terr = os.Mkdir(*LogDir, 0777)\n\t\t\tif err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t\tlogger.LogDir = *LogDir\n\t}\n}\n"
  },
  {
    "path": "common/logger/constants.go",
    "content": "package logger\n\nvar LogDir string\n"
  },
  {
    "path": "common/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n)\n\ntype loggerLevel string\n\nconst (\n\tloggerDEBUG loggerLevel = \"DEBUG\"\n\tloggerINFO  loggerLevel = \"INFO\"\n\tloggerWarn  loggerLevel = \"WARN\"\n\tloggerError loggerLevel = \"ERROR\"\n\tloggerFatal loggerLevel = \"FATAL\"\n)\n\nvar setupLogOnce sync.Once\n\nfunc SetupLogger() {\n\tsetupLogOnce.Do(func() {\n\t\tif LogDir != \"\" {\n\t\t\tvar logPath string\n\t\t\tif config.OnlyOneLogFile {\n\t\t\t\tlogPath = filepath.Join(LogDir, \"oneapi.log\")\n\t\t\t} else {\n\t\t\t\tlogPath = filepath.Join(LogDir, fmt.Sprintf(\"oneapi-%s.log\", time.Now().Format(\"20060102\")))\n\t\t\t}\n\t\t\tfd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)\n\t\t\tif err != nil {\n\t\t\t\tlog.Fatal(\"failed to open log file\")\n\t\t\t}\n\t\t\tgin.DefaultWriter = io.MultiWriter(os.Stdout, fd)\n\t\t\tgin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)\n\t\t}\n\t})\n}\n\nfunc SysLog(s string) {\n\tlogHelper(nil, loggerINFO, s)\n}\n\nfunc SysLogf(format string, a ...any) {\n\tlogHelper(nil, loggerINFO, fmt.Sprintf(format, a...))\n}\n\nfunc SysWarn(s string) {\n\tlogHelper(nil, loggerWarn, s)\n}\n\nfunc SysWarnf(format string, a ...any) {\n\tlogHelper(nil, loggerWarn, fmt.Sprintf(format, a...))\n}\n\nfunc SysError(s string) {\n\tlogHelper(nil, loggerError, s)\n}\n\nfunc SysErrorf(format string, a ...any) {\n\tlogHelper(nil, loggerError, fmt.Sprintf(format, a...))\n}\n\nfunc Debug(ctx context.Context, msg string) {\n\tif !config.DebugEnabled {\n\t\treturn\n\t}\n\tlogHelper(ctx, loggerDEBUG, msg)\n}\n\nfunc Info(ctx context.Context, msg string) {\n\tlogHelper(ctx, loggerINFO, msg)\n}\n\nfunc Warn(ctx context.Context, msg string) {\n\tlogHelper(ctx, loggerWarn, msg)\n}\n\nfunc Error(ctx context.Context, msg string) {\n\tlogHelper(ctx, loggerError, msg)\n}\n\nfunc Debugf(ctx context.Context, format string, a ...any) {\n\tif !config.DebugEnabled {\n\t\treturn\n\t}\n\tlogHelper(ctx, loggerDEBUG, fmt.Sprintf(format, a...))\n}\n\nfunc Infof(ctx context.Context, format string, a ...any) {\n\tlogHelper(ctx, loggerINFO, fmt.Sprintf(format, a...))\n}\n\nfunc Warnf(ctx context.Context, format string, a ...any) {\n\tlogHelper(ctx, loggerWarn, fmt.Sprintf(format, a...))\n}\n\nfunc Errorf(ctx context.Context, format string, a ...any) {\n\tlogHelper(ctx, loggerError, fmt.Sprintf(format, a...))\n}\n\nfunc FatalLog(s string) {\n\tlogHelper(nil, loggerFatal, s)\n}\n\nfunc FatalLogf(format string, a ...any) {\n\tlogHelper(nil, loggerFatal, fmt.Sprintf(format, a...))\n}\n\nfunc logHelper(ctx context.Context, level loggerLevel, msg string) {\n\twriter := gin.DefaultErrorWriter\n\tif level == loggerINFO {\n\t\twriter = gin.DefaultWriter\n\t}\n\tvar requestId string\n\tif ctx != nil {\n\t\trawRequestId := helper.GetRequestID(ctx)\n\t\tif rawRequestId != \"\" {\n\t\t\trequestId = fmt.Sprintf(\" | %s\", rawRequestId)\n\t\t}\n\t}\n\tlineInfo, funcName := getLineInfo()\n\tnow := time.Now()\n\t_, _ = fmt.Fprintf(writer, \"[%s] %v%s%s %s%s \\n\", level, now.Format(\"2006/01/02 - 15:04:05\"), requestId, lineInfo, funcName, msg)\n\tSetupLogger()\n\tif level == loggerFatal {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc getLineInfo() (string, string) {\n\tfuncName := \"[unknown] \"\n\tpc, file, line, ok := runtime.Caller(3)\n\tif ok {\n\t\tif fn := runtime.FuncForPC(pc); fn != nil {\n\t\t\tparts := strings.Split(fn.Name(), \".\")\n\t\t\tfuncName = \"[\" + parts[len(parts)-1] + \"] \"\n\t\t}\n\t} else {\n\t\tfile = \"unknown\"\n\t\tline = 0\n\t}\n\tparts := strings.Split(file, \"one-api/\")\n\tif len(parts) > 1 {\n\t\tfile = parts[1]\n\t}\n\treturn fmt.Sprintf(\" | %s:%d\", file, line), funcName\n}\n"
  },
  {
    "path": "common/message/email.go",
    "content": "package message\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/smtp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n)\n\nfunc shouldAuth() bool {\n\treturn config.SMTPAccount != \"\" || config.SMTPToken != \"\"\n}\n\nfunc SendEmail(subject string, receiver string, content string) error {\n\tif receiver == \"\" {\n\t\treturn fmt.Errorf(\"receiver is empty\")\n\t}\n\tif config.SMTPFrom == \"\" { // for compatibility\n\t\tconfig.SMTPFrom = config.SMTPAccount\n\t}\n\tencodedSubject := fmt.Sprintf(\"=?UTF-8?B?%s?=\", base64.StdEncoding.EncodeToString([]byte(subject)))\n\n\t// Extract domain from SMTPFrom\n\tparts := strings.Split(config.SMTPFrom, \"@\")\n\tvar domain string\n\tif len(parts) > 1 {\n\t\tdomain = parts[1]\n\t}\n\t// Generate a unique Message-ID\n\tbuf := make([]byte, 16)\n\t_, err := rand.Read(buf)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmessageId := fmt.Sprintf(\"<%x@%s>\", buf, domain)\n\n\tmail := []byte(fmt.Sprintf(\"To: %s\\r\\n\"+\n\t\t\"From: %s<%s>\\r\\n\"+\n\t\t\"Subject: %s\\r\\n\"+\n\t\t\"Message-ID: %s\\r\\n\"+ // add Message-ID header to avoid being treated as spam, RFC 5322\n\t\t\"Date: %s\\r\\n\"+\n\t\t\"Content-Type: text/html; charset=UTF-8\\r\\n\\r\\n%s\\r\\n\",\n\t\treceiver, config.SystemName, config.SMTPFrom, encodedSubject, messageId, time.Now().Format(time.RFC1123Z), content))\n\n\tauth := smtp.PlainAuth(\"\", config.SMTPAccount, config.SMTPToken, config.SMTPServer)\n\taddr := fmt.Sprintf(\"%s:%d\", config.SMTPServer, config.SMTPPort)\n\tto := strings.Split(receiver, \";\")\n\n\tif config.SMTPPort == 465 || !shouldAuth() {\n\t\t// need advanced client\n\t\tvar conn net.Conn\n\t\tvar err error\n\t\tif config.SMTPPort == 465 {\n\t\t\ttlsConfig := &tls.Config{\n\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\tServerName:         config.SMTPServer,\n\t\t\t}\n\t\t\tconn, err = tls.Dial(\"tcp\", fmt.Sprintf(\"%s:%d\", config.SMTPServer, config.SMTPPort), tlsConfig)\n\t\t} else {\n\t\t\tconn, err = net.Dial(\"tcp\", fmt.Sprintf(\"%s:%d\", config.SMTPServer, config.SMTPPort))\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tclient, err := smtp.NewClient(conn, config.SMTPServer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer client.Close()\n\t\tif shouldAuth() {\n\t\t\tif err = client.Auth(auth); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err = client.Mail(config.SMTPFrom); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treceiverEmails := strings.Split(receiver, \";\")\n\t\tfor _, receiver := range receiverEmails {\n\t\t\tif err = client.Rcpt(receiver); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tw, err := client.Data()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = w.Write(mail)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = w.Close()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}\n\terr = smtp.SendMail(addr, auth, config.SMTPAccount, to, mail)\n\tif err != nil && strings.Contains(err.Error(), \"short response\") { // 部分提供商返回该错误，但实际上邮件已经发送成功\n\t\tlogger.SysWarnf(\"short response from SMTP server, return nil instead of error: %s\", err.Error())\n\t\treturn nil\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "common/message/main.go",
    "content": "package message\n\nimport (\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n)\n\nconst (\n\tByAll           = \"all\"\n\tByEmail         = \"email\"\n\tByMessagePusher = \"message_pusher\"\n)\n\nfunc Notify(by string, title string, description string, content string) error {\n\tif by == ByEmail {\n\t\treturn SendEmail(title, config.RootUserEmail, content)\n\t}\n\tif by == ByMessagePusher {\n\t\treturn SendMessage(title, description, content)\n\t}\n\treturn fmt.Errorf(\"unknown notify method: %s\", by)\n}\n"
  },
  {
    "path": "common/message/message-pusher.go",
    "content": "package message\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"net/http\"\n)\n\ntype request struct {\n\tTitle       string `json:\"title\"`\n\tDescription string `json:\"description\"`\n\tContent     string `json:\"content\"`\n\tURL         string `json:\"url\"`\n\tChannel     string `json:\"channel\"`\n\tToken       string `json:\"token\"`\n}\n\ntype response struct {\n\tSuccess bool   `json:\"success\"`\n\tMessage string `json:\"message\"`\n}\n\nfunc SendMessage(title string, description string, content string) error {\n\tif config.MessagePusherAddress == \"\" {\n\t\treturn errors.New(\"message pusher address is not set\")\n\t}\n\treq := request{\n\t\tTitle:       title,\n\t\tDescription: description,\n\t\tContent:     content,\n\t\tToken:       config.MessagePusherToken,\n\t}\n\tdata, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresp, err := http.Post(config.MessagePusherAddress,\n\t\t\"application/json\", bytes.NewBuffer(data))\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar res response\n\terr = json.NewDecoder(resp.Body).Decode(&res)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !res.Success {\n\t\treturn errors.New(res.Message)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/message/template.go",
    "content": "package message\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n)\n\n// EmailTemplate 生成美观的 HTML 邮件内容\nfunc EmailTemplate(title, content string) string {\n\treturn fmt.Sprintf(`\n<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 20px; font-family: Arial, sans-serif; line-height: 1.6; background-color: #f4f4f4;\">\n    <div style=\"max-width: 600px; margin: 20px auto; padding: 30px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\">\n        <div style=\"text-align: center; margin-bottom: 30px;\">\n            <h2 style=\"color: #333; margin: 0; font-size: 24px;\">%s</h2>\n        </div>\n        <div style=\"color: #555; font-size: 16px;\">\n            %s\n        </div>\n        <div style=\"margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #888; font-size: 14px; text-align: center;\">\n            <p style=\"margin: 5px 0;\">此邮件由系统自动发送，请勿直接回复</p>\n            <p style=\"margin: 5px 0;\">%s</p>\n        </div>\n    </div>\n</body>\n</html>\n`, title, content, config.SystemName)\n}\n"
  },
  {
    "path": "common/network/ip.go",
    "content": "package network\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"net\"\n\t\"strings\"\n)\n\nfunc splitSubnets(subnets string) []string {\n\tres := strings.Split(subnets, \",\")\n\tfor i := 0; i < len(res); i++ {\n\t\tres[i] = strings.TrimSpace(res[i])\n\t}\n\treturn res\n}\n\nfunc isValidSubnet(subnet string) error {\n\t_, _, err := net.ParseCIDR(subnet)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse subnet: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc isIpInSubnet(ctx context.Context, ip string, subnet string) bool {\n\t_, ipNet, err := net.ParseCIDR(subnet)\n\tif err != nil {\n\t\tlogger.Errorf(ctx, \"failed to parse subnet: %s\", err.Error())\n\t\treturn false\n\t}\n\treturn ipNet.Contains(net.ParseIP(ip))\n}\n\nfunc IsValidSubnets(subnets string) error {\n\tfor _, subnet := range splitSubnets(subnets) {\n\t\tif err := isValidSubnet(subnet); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc IsIpInSubnets(ctx context.Context, ip string, subnets string) bool {\n\tfor _, subnet := range splitSubnets(subnets) {\n\t\tif isIpInSubnet(ctx, ip, subnet) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "common/network/ip_test.go",
    "content": "package network\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t. \"github.com/smartystreets/goconvey/convey\"\n)\n\nfunc TestIsIpInSubnet(t *testing.T) {\n\tctx := context.Background()\n\tip1 := \"192.168.0.5\"\n\tip2 := \"125.216.250.89\"\n\tsubnet := \"192.168.0.0/24\"\n\tConvey(\"TestIsIpInSubnet\", t, func() {\n\t\tSo(isIpInSubnet(ctx, ip1, subnet), ShouldBeTrue)\n\t\tSo(isIpInSubnet(ctx, ip2, subnet), ShouldBeFalse)\n\t})\n}\n"
  },
  {
    "path": "common/random/main.go",
    "content": "package random\n\nimport (\n\t\"github.com/google/uuid\"\n\t\"math/rand\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc GetUUID() string {\n\tcode := uuid.New().String()\n\tcode = strings.Replace(code, \"-\", \"\", -1)\n\treturn code\n}\n\nconst keyChars = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\nconst keyNumbers = \"0123456789\"\n\nfunc init() {\n\trand.Seed(time.Now().UnixNano())\n}\n\nfunc GenerateKey() string {\n\trand.Seed(time.Now().UnixNano())\n\tkey := make([]byte, 48)\n\tfor i := 0; i < 16; i++ {\n\t\tkey[i] = keyChars[rand.Intn(len(keyChars))]\n\t}\n\tuuid_ := GetUUID()\n\tfor i := 0; i < 32; i++ {\n\t\tc := uuid_[i]\n\t\tif i%2 == 0 && c >= 'a' && c <= 'z' {\n\t\t\tc = c - 'a' + 'A'\n\t\t}\n\t\tkey[i+16] = c\n\t}\n\treturn string(key)\n}\n\nfunc GetRandomString(length int) string {\n\trand.Seed(time.Now().UnixNano())\n\tkey := make([]byte, length)\n\tfor i := 0; i < length; i++ {\n\t\tkey[i] = keyChars[rand.Intn(len(keyChars))]\n\t}\n\treturn string(key)\n}\n\nfunc GetRandomNumberString(length int) string {\n\trand.Seed(time.Now().UnixNano())\n\tkey := make([]byte, length)\n\tfor i := 0; i < length; i++ {\n\t\tkey[i] = keyNumbers[rand.Intn(len(keyNumbers))]\n\t}\n\treturn string(key)\n}\n\n// RandRange returns a random number between min and max (max is not included)\nfunc RandRange(min, max int) int {\n\treturn min + rand.Intn(max-min)\n}\n"
  },
  {
    "path": "common/rate-limit.go",
    "content": "package common\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\ntype InMemoryRateLimiter struct {\n\tstore              map[string]*[]int64\n\tmutex              sync.Mutex\n\texpirationDuration time.Duration\n}\n\nfunc (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {\n\tif l.store == nil {\n\t\tl.mutex.Lock()\n\t\tif l.store == nil {\n\t\t\tl.store = make(map[string]*[]int64)\n\t\t\tl.expirationDuration = expirationDuration\n\t\t\tif expirationDuration > 0 {\n\t\t\t\tgo l.clearExpiredItems()\n\t\t\t}\n\t\t}\n\t\tl.mutex.Unlock()\n\t}\n}\n\nfunc (l *InMemoryRateLimiter) clearExpiredItems() {\n\tfor {\n\t\ttime.Sleep(l.expirationDuration)\n\t\tl.mutex.Lock()\n\t\tnow := time.Now().Unix()\n\t\tfor key := range l.store {\n\t\t\tqueue := l.store[key]\n\t\t\tsize := len(*queue)\n\t\t\tif size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {\n\t\t\t\tdelete(l.store, key)\n\t\t\t}\n\t\t}\n\t\tl.mutex.Unlock()\n\t}\n}\n\n// Request parameter duration's unit is seconds\nfunc (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {\n\tl.mutex.Lock()\n\tdefer l.mutex.Unlock()\n\t// [old <-- new]\n\tqueue, ok := l.store[key]\n\tnow := time.Now().Unix()\n\tif ok {\n\t\tif len(*queue) < maxRequestNum {\n\t\t\t*queue = append(*queue, now)\n\t\t\treturn true\n\t\t} else {\n\t\t\tif now-(*queue)[0] >= duration {\n\t\t\t\t*queue = (*queue)[1:]\n\t\t\t\t*queue = append(*queue, now)\n\t\t\t\treturn true\n\t\t\t} else {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t} else {\n\t\ts := make([]int64, 0, maxRequestNum)\n\t\tl.store[key] = &s\n\t\t*(l.store[key]) = append(*(l.store[key]), now)\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "common/redis.go",
    "content": "package common\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-redis/redis/v8\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n)\n\nvar RDB redis.Cmdable\nvar RedisEnabled = true\n\n// InitRedisClient This function is called after init()\nfunc InitRedisClient() (err error) {\n\tif os.Getenv(\"REDIS_CONN_STRING\") == \"\" {\n\t\tRedisEnabled = false\n\t\tlogger.SysLog(\"REDIS_CONN_STRING not set, Redis is not enabled\")\n\t\treturn nil\n\t}\n\tif os.Getenv(\"SYNC_FREQUENCY\") == \"\" {\n\t\tRedisEnabled = false\n\t\tlogger.SysLog(\"SYNC_FREQUENCY not set, Redis is disabled\")\n\t\treturn nil\n\t}\n\tredisConnString := os.Getenv(\"REDIS_CONN_STRING\")\n\tif os.Getenv(\"REDIS_MASTER_NAME\") == \"\" {\n\t\tlogger.SysLog(\"Redis is enabled\")\n\t\topt, err := redis.ParseURL(redisConnString)\n\t\tif err != nil {\n\t\t\tlogger.FatalLog(\"failed to parse Redis connection string: \" + err.Error())\n\t\t}\n\t\tRDB = redis.NewClient(opt)\n\t} else {\n\t\t// cluster mode\n\t\tlogger.SysLog(\"Redis cluster mode enabled\")\n\t\tRDB = redis.NewUniversalClient(&redis.UniversalOptions{\n\t\t\tAddrs:      strings.Split(redisConnString, \",\"),\n\t\t\tPassword:   os.Getenv(\"REDIS_PASSWORD\"),\n\t\t\tMasterName: os.Getenv(\"REDIS_MASTER_NAME\"),\n\t\t})\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t_, err = RDB.Ping(ctx).Result()\n\tif err != nil {\n\t\tlogger.FatalLog(\"Redis ping test failed: \" + err.Error())\n\t}\n\treturn err\n}\n\nfunc ParseRedisOption() *redis.Options {\n\topt, err := redis.ParseURL(os.Getenv(\"REDIS_CONN_STRING\"))\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to parse Redis connection string: \" + err.Error())\n\t}\n\treturn opt\n}\n\nfunc RedisSet(key string, value string, expiration time.Duration) error {\n\tctx := context.Background()\n\treturn RDB.Set(ctx, key, value, expiration).Err()\n}\n\nfunc RedisGet(key string) (string, error) {\n\tctx := context.Background()\n\treturn RDB.Get(ctx, key).Result()\n}\n\nfunc RedisDel(key string) error {\n\tctx := context.Background()\n\treturn RDB.Del(ctx, key).Err()\n}\n\nfunc RedisDecrease(key string, value int64) error {\n\tctx := context.Background()\n\treturn RDB.DecrBy(ctx, key, value).Err()\n}\n"
  },
  {
    "path": "common/render/render.go",
    "content": "package render\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n)\n\nfunc StringData(c *gin.Context, str string) {\n\tstr = strings.TrimPrefix(str, \"data: \")\n\tstr = strings.TrimSuffix(str, \"\\r\")\n\tc.Render(-1, common.CustomEvent{Data: \"data: \" + str})\n\tc.Writer.Flush()\n}\n\nfunc ObjectData(c *gin.Context, object interface{}) error {\n\tjsonData, err := json.Marshal(object)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error marshalling object: %w\", err)\n\t}\n\tStringData(c, string(jsonData))\n\treturn nil\n}\n\nfunc Done(c *gin.Context) {\n\tStringData(c, \"[DONE]\")\n}\n"
  },
  {
    "path": "common/utils/array.go",
    "content": "package utils\n\nfunc DeDuplication(slice []string) []string {\n\tm := make(map[string]bool)\n\tfor _, v := range slice {\n\t\tm[v] = true\n\t}\n\tresult := make([]string, 0, len(m))\n\tfor v := range m {\n\t\tresult = append(result, v)\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "common/utils.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n)\n\nfunc LogQuota(quota int64) string {\n\tif config.DisplayInCurrencyEnabled {\n\t\treturn fmt.Sprintf(\"＄%.6f 额度\", float64(quota)/config.QuotaPerUnit)\n\t} else {\n\t\treturn fmt.Sprintf(\"%d 点额度\", quota)\n\t}\n}\n"
  },
  {
    "path": "common/validate.go",
    "content": "package common\n\nimport \"github.com/go-playground/validator/v10\"\n\nvar Validate *validator.Validate\n\nfunc init() {\n\tValidate = validator.New()\n}\n"
  },
  {
    "path": "common/verification.go",
    "content": "package common\n\nimport (\n\t\"github.com/google/uuid\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype verificationValue struct {\n\tcode string\n\ttime time.Time\n}\n\nconst (\n\tEmailVerificationPurpose = \"v\"\n\tPasswordResetPurpose     = \"r\"\n)\n\nvar verificationMutex sync.Mutex\nvar verificationMap map[string]verificationValue\nvar verificationMapMaxSize = 10\nvar VerificationValidMinutes = 10\n\nfunc GenerateVerificationCode(length int) string {\n\tcode := uuid.New().String()\n\tcode = strings.Replace(code, \"-\", \"\", -1)\n\tif length == 0 {\n\t\treturn code\n\t}\n\treturn code[:length]\n}\n\nfunc RegisterVerificationCodeWithKey(key string, code string, purpose string) {\n\tverificationMutex.Lock()\n\tdefer verificationMutex.Unlock()\n\tverificationMap[purpose+key] = verificationValue{\n\t\tcode: code,\n\t\ttime: time.Now(),\n\t}\n\tif len(verificationMap) > verificationMapMaxSize {\n\t\tremoveExpiredPairs()\n\t}\n}\n\nfunc VerifyCodeWithKey(key string, code string, purpose string) bool {\n\tverificationMutex.Lock()\n\tdefer verificationMutex.Unlock()\n\tvalue, okay := verificationMap[purpose+key]\n\tnow := time.Now()\n\tif !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {\n\t\treturn false\n\t}\n\treturn code == value.code\n}\n\nfunc DeleteKey(key string, purpose string) {\n\tverificationMutex.Lock()\n\tdefer verificationMutex.Unlock()\n\tdelete(verificationMap, purpose+key)\n}\n\n// no lock inside, so the caller must lock the verificationMap before calling!\nfunc removeExpiredPairs() {\n\tnow := time.Now()\n\tfor key := range verificationMap {\n\t\tif int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {\n\t\t\tdelete(verificationMap, key)\n\t\t}\n\t}\n}\n\nfunc init() {\n\tverificationMutex.Lock()\n\tdefer verificationMutex.Unlock()\n\tverificationMap = make(map[string]verificationValue)\n}\n"
  },
  {
    "path": "controller/auth/github.go",
    "content": "package auth\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\ntype GitHubOAuthResponse struct {\n\tAccessToken string `json:\"access_token\"`\n\tScope       string `json:\"scope\"`\n\tTokenType   string `json:\"token_type\"`\n}\n\ntype GitHubUser struct {\n\tLogin string `json:\"login\"`\n\tName  string `json:\"name\"`\n\tEmail string `json:\"email\"`\n}\n\nfunc getGitHubUserInfoByCode(code string) (*GitHubUser, error) {\n\tif code == \"\" {\n\t\treturn nil, errors.New(\"无效的参数\")\n\t}\n\tvalues := map[string]string{\"client_id\": config.GitHubClientId, \"client_secret\": config.GitHubClientSecret, \"code\": code}\n\tjsonData, err := json.Marshal(values)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err := http.NewRequest(\"POST\", \"https://github.com/login/oauth/access_token\", bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tclient := http.Client{\n\t\tTimeout: 5 * time.Second,\n\t}\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysLog(err.Error())\n\t\treturn nil, errors.New(\"无法连接至 GitHub 服务器，请稍后重试！\")\n\t}\n\tdefer res.Body.Close()\n\tvar oAuthResponse GitHubOAuthResponse\n\terr = json.NewDecoder(res.Body).Decode(&oAuthResponse)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err = http.NewRequest(\"GET\", \"https://api.github.com/user\", nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", oAuthResponse.AccessToken))\n\tres2, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysLog(err.Error())\n\t\treturn nil, errors.New(\"无法连接至 GitHub 服务器，请稍后重试！\")\n\t}\n\tdefer res2.Body.Close()\n\tvar githubUser GitHubUser\n\terr = json.NewDecoder(res2.Body).Decode(&githubUser)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif githubUser.Login == \"\" {\n\t\treturn nil, errors.New(\"返回值非法，用户字段为空，请稍后重试！\")\n\t}\n\treturn &githubUser, nil\n}\n\nfunc GitHubOAuth(c *gin.Context) {\n\tctx := c.Request.Context()\n\tsession := sessions.Default(c)\n\tstate := c.Query(\"state\")\n\tif state == \"\" || session.Get(\"oauth_state\") == nil || state != session.Get(\"oauth_state\").(string) {\n\t\tc.JSON(http.StatusForbidden, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"state is empty or not same\",\n\t\t})\n\t\treturn\n\t}\n\tusername := session.Get(\"username\")\n\tif username != nil {\n\t\tGitHubBind(c)\n\t\treturn\n\t}\n\n\tif !config.GitHubOAuthEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"管理员未开启通过 GitHub 登录以及注册\",\n\t\t})\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\tgithubUser, err := getGitHubUserInfoByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tGitHubId: githubUser.Login,\n\t}\n\tif model.IsGitHubIdAlreadyTaken(user.GitHubId) {\n\t\terr := user.FillUserByGitHubId()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif config.RegisterEnabled {\n\t\t\tuser.Username = \"github_\" + strconv.Itoa(model.GetMaxUserId()+1)\n\t\t\tif githubUser.Name != \"\" {\n\t\t\t\tuser.DisplayName = githubUser.Name\n\t\t\t} else {\n\t\t\t\tuser.DisplayName = \"GitHub User\"\n\t\t\t}\n\t\t\tuser.Email = githubUser.Email\n\t\t\tuser.Role = model.RoleCommonUser\n\t\t\tuser.Status = model.UserStatusEnabled\n\n\t\t\tif err := user.Insert(ctx, 0); err != nil {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": err.Error(),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"管理员关闭了新用户注册\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.Status != model.UserStatusEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"用户已被封禁\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcontroller.SetupLogin(&user, c)\n}\n\nfunc GitHubBind(c *gin.Context) {\n\tif !config.GitHubOAuthEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"管理员未开启通过 GitHub 登录以及注册\",\n\t\t})\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\tgithubUser, err := getGitHubUserInfoByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tGitHubId: githubUser.Login,\n\t}\n\tif model.IsGitHubIdAlreadyTaken(user.GitHubId) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"该 GitHub 账户已被绑定\",\n\t\t})\n\t\treturn\n\t}\n\tsession := sessions.Default(c)\n\tid := session.Get(\"id\")\n\t// id := c.GetInt(\"id\")  // critical bug!\n\tuser.Id = id.(int)\n\terr = user.FillUserById()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser.GitHubId = githubUser.Login\n\terr = user.Update(false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"bind\",\n\t})\n\treturn\n}\n\nfunc GenerateOAuthCode(c *gin.Context) {\n\tsession := sessions.Default(c)\n\tstate := random.GetRandomString(12)\n\tsession.Set(\"oauth_state\", state)\n\terr := session.Save()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    state,\n\t})\n}\n"
  },
  {
    "path": "controller/auth/lark.go",
    "content": "package auth\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\ntype LarkOAuthResponse struct {\n\tAccessToken string `json:\"access_token\"`\n}\n\ntype LarkUser struct {\n\tName   string `json:\"name\"`\n\tOpenID string `json:\"open_id\"`\n}\n\nfunc getLarkUserInfoByCode(code string) (*LarkUser, error) {\n\tif code == \"\" {\n\t\treturn nil, errors.New(\"无效的参数\")\n\t}\n\tvalues := map[string]string{\n\t\t\"client_id\":     config.LarkClientId,\n\t\t\"client_secret\": config.LarkClientSecret,\n\t\t\"code\":          code,\n\t\t\"grant_type\":    \"authorization_code\",\n\t\t\"redirect_uri\":  fmt.Sprintf(\"%s/oauth/lark\", config.ServerAddress),\n\t}\n\tjsonData, err := json.Marshal(values)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err := http.NewRequest(\"POST\", \"https://open.feishu.cn/open-apis/authen/v2/oauth/token\", bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tclient := http.Client{\n\t\tTimeout: 5 * time.Second,\n\t}\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysLog(err.Error())\n\t\treturn nil, errors.New(\"无法连接至飞书服务器，请稍后重试！\")\n\t}\n\tdefer res.Body.Close()\n\tvar oAuthResponse LarkOAuthResponse\n\terr = json.NewDecoder(res.Body).Decode(&oAuthResponse)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err = http.NewRequest(\"GET\", \"https://passport.feishu.cn/suite/passport/oauth/userinfo\", nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", oAuthResponse.AccessToken))\n\tres2, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysLog(err.Error())\n\t\treturn nil, errors.New(\"无法连接至飞书服务器，请稍后重试！\")\n\t}\n\tvar larkUser LarkUser\n\terr = json.NewDecoder(res2.Body).Decode(&larkUser)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &larkUser, nil\n}\n\nfunc LarkOAuth(c *gin.Context) {\n\tctx := c.Request.Context()\n\tsession := sessions.Default(c)\n\tstate := c.Query(\"state\")\n\tif state == \"\" || session.Get(\"oauth_state\") == nil || state != session.Get(\"oauth_state\").(string) {\n\t\tc.JSON(http.StatusForbidden, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"state is empty or not same\",\n\t\t})\n\t\treturn\n\t}\n\tusername := session.Get(\"username\")\n\tif username != nil {\n\t\tLarkBind(c)\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\tlarkUser, err := getLarkUserInfoByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tLarkId: larkUser.OpenID,\n\t}\n\tif model.IsLarkIdAlreadyTaken(user.LarkId) {\n\t\terr := user.FillUserByLarkId()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif config.RegisterEnabled {\n\t\t\tuser.Username = \"lark_\" + strconv.Itoa(model.GetMaxUserId()+1)\n\t\t\tif larkUser.Name != \"\" {\n\t\t\t\tuser.DisplayName = larkUser.Name\n\t\t\t} else {\n\t\t\t\tuser.DisplayName = \"Lark User\"\n\t\t\t}\n\t\t\tuser.Role = model.RoleCommonUser\n\t\t\tuser.Status = model.UserStatusEnabled\n\n\t\t\tif err := user.Insert(ctx, 0); err != nil {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": err.Error(),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"管理员关闭了新用户注册\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.Status != model.UserStatusEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"用户已被封禁\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcontroller.SetupLogin(&user, c)\n}\n\nfunc LarkBind(c *gin.Context) {\n\tcode := c.Query(\"code\")\n\tlarkUser, err := getLarkUserInfoByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tLarkId: larkUser.OpenID,\n\t}\n\tif model.IsLarkIdAlreadyTaken(user.LarkId) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"该飞书账户已被绑定\",\n\t\t})\n\t\treturn\n\t}\n\tsession := sessions.Default(c)\n\tid := session.Get(\"id\")\n\t// id := c.GetInt(\"id\")  // critical bug!\n\tuser.Id = id.(int)\n\terr = user.FillUserById()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser.LarkId = larkUser.OpenID\n\terr = user.Update(false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"bind\",\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/auth/oidc.go",
    "content": "package auth\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\ntype OidcResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tIDToken      string `json:\"id_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tTokenType    string `json:\"token_type\"`\n\tExpiresIn    int    `json:\"expires_in\"`\n\tScope        string `json:\"scope\"`\n}\n\ntype OidcUser struct {\n\tOpenID            string `json:\"sub\"`\n\tEmail             string `json:\"email\"`\n\tName              string `json:\"name\"`\n\tPreferredUsername string `json:\"preferred_username\"`\n\tPicture           string `json:\"picture\"`\n}\n\nfunc getOidcUserInfoByCode(code string) (*OidcUser, error) {\n\tif code == \"\" {\n\t\treturn nil, errors.New(\"无效的参数\")\n\t}\n\tvalues := map[string]string{\n\t\t\"client_id\":     config.OidcClientId,\n\t\t\"client_secret\": config.OidcClientSecret,\n\t\t\"code\":          code,\n\t\t\"grant_type\":    \"authorization_code\",\n\t\t\"redirect_uri\":  fmt.Sprintf(\"%s/oauth/oidc\", config.ServerAddress),\n\t}\n\tjsonData, err := json.Marshal(values)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err := http.NewRequest(\"POST\", config.OidcTokenEndpoint, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept\", \"application/json\")\n\tclient := http.Client{\n\t\tTimeout: 5 * time.Second,\n\t}\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysLog(err.Error())\n\t\treturn nil, errors.New(\"无法连接至 OIDC 服务器，请稍后重试！\")\n\t}\n\tdefer res.Body.Close()\n\tvar oidcResponse OidcResponse\n\terr = json.NewDecoder(res.Body).Decode(&oidcResponse)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err = http.NewRequest(\"GET\", config.OidcUserinfoEndpoint, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+oidcResponse.AccessToken)\n\tres2, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysLog(err.Error())\n\t\treturn nil, errors.New(\"无法连接至 OIDC 服务器，请稍后重试！\")\n\t}\n\tvar oidcUser OidcUser\n\terr = json.NewDecoder(res2.Body).Decode(&oidcUser)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &oidcUser, nil\n}\n\nfunc OidcAuth(c *gin.Context) {\n\tctx := c.Request.Context()\n\tsession := sessions.Default(c)\n\tstate := c.Query(\"state\")\n\tif state == \"\" || session.Get(\"oauth_state\") == nil || state != session.Get(\"oauth_state\").(string) {\n\t\tc.JSON(http.StatusForbidden, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"state is empty or not same\",\n\t\t})\n\t\treturn\n\t}\n\tusername := session.Get(\"username\")\n\tif username != nil {\n\t\tOidcBind(c)\n\t\treturn\n\t}\n\tif !config.OidcEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"管理员未开启通过 OIDC 登录以及注册\",\n\t\t})\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\toidcUser, err := getOidcUserInfoByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tOidcId: oidcUser.OpenID,\n\t}\n\tif model.IsOidcIdAlreadyTaken(user.OidcId) {\n\t\terr := user.FillUserByOidcId()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif config.RegisterEnabled {\n\t\t\tuser.Email = oidcUser.Email\n\t\t\tif oidcUser.PreferredUsername != \"\" {\n\t\t\t\tuser.Username = oidcUser.PreferredUsername\n\t\t\t} else {\n\t\t\t\tuser.Username = \"oidc_\" + strconv.Itoa(model.GetMaxUserId()+1)\n\t\t\t}\n\t\t\tif oidcUser.Name != \"\" {\n\t\t\t\tuser.DisplayName = oidcUser.Name\n\t\t\t} else {\n\t\t\t\tuser.DisplayName = \"OIDC User\"\n\t\t\t}\n\t\t\terr := user.Insert(ctx, 0)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": err.Error(),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"管理员关闭了新用户注册\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.Status != model.UserStatusEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"用户已被封禁\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcontroller.SetupLogin(&user, c)\n}\n\nfunc OidcBind(c *gin.Context) {\n\tif !config.OidcEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"管理员未开启通过 OIDC 登录以及注册\",\n\t\t})\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\toidcUser, err := getOidcUserInfoByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tOidcId: oidcUser.OpenID,\n\t}\n\tif model.IsOidcIdAlreadyTaken(user.OidcId) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"该 OIDC 账户已被绑定\",\n\t\t})\n\t\treturn\n\t}\n\tsession := sessions.Default(c)\n\tid := session.Get(\"id\")\n\t// id := c.GetInt(\"id\")  // critical bug!\n\tuser.Id = id.(int)\n\terr = user.FillUserById()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser.OidcId = oidcUser.OpenID\n\terr = user.Update(false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"bind\",\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/auth/wechat.go",
    "content": "package auth\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\ntype wechatLoginResponse struct {\n\tSuccess bool   `json:\"success\"`\n\tMessage string `json:\"message\"`\n\tData    string `json:\"data\"`\n}\n\nfunc getWeChatIdByCode(code string) (string, error) {\n\tif code == \"\" {\n\t\treturn \"\", errors.New(\"无效的参数\")\n\t}\n\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/api/wechat/user?code=%s\", config.WeChatServerAddress, code), nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", config.WeChatServerToken)\n\tclient := http.Client{\n\t\tTimeout: 5 * time.Second,\n\t}\n\thttpResponse, err := client.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer httpResponse.Body.Close()\n\tvar res wechatLoginResponse\n\terr = json.NewDecoder(httpResponse.Body).Decode(&res)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif !res.Success {\n\t\treturn \"\", errors.New(res.Message)\n\t}\n\tif res.Data == \"\" {\n\t\treturn \"\", errors.New(\"验证码错误或已过期\")\n\t}\n\treturn res.Data, nil\n}\n\nfunc WeChatAuth(c *gin.Context) {\n\tctx := c.Request.Context()\n\tif !config.WeChatAuthEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"管理员未开启通过微信登录以及注册\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\twechatId, err := getWeChatIdByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": err.Error(),\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tWeChatId: wechatId,\n\t}\n\tif model.IsWeChatIdAlreadyTaken(wechatId) {\n\t\terr := user.FillUserByWeChatId()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif config.RegisterEnabled {\n\t\t\tuser.Username = \"wechat_\" + strconv.Itoa(model.GetMaxUserId()+1)\n\t\t\tuser.DisplayName = \"WeChat User\"\n\t\t\tuser.Role = model.RoleCommonUser\n\t\t\tuser.Status = model.UserStatusEnabled\n\n\t\t\tif err := user.Insert(ctx, 0); err != nil {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": err.Error(),\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"管理员关闭了新用户注册\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user.Status != model.UserStatusEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"用户已被封禁\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcontroller.SetupLogin(&user, c)\n}\n\nfunc WeChatBind(c *gin.Context) {\n\tif !config.WeChatAuthEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"管理员未开启通过微信登录以及注册\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcode := c.Query(\"code\")\n\twechatId, err := getWeChatIdByCode(code)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": err.Error(),\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tif model.IsWeChatIdAlreadyTaken(wechatId) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"该微信账号已被绑定\",\n\t\t})\n\t\treturn\n\t}\n\tid := c.GetInt(ctxkey.Id)\n\tuser := model.User{\n\t\tId: id,\n\t}\n\terr = user.FillUserById()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser.WeChatId = wechatId\n\terr = user.Update(false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/billing.go",
    "content": "package controller\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/model\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc GetSubscription(c *gin.Context) {\n\tvar remainQuota int64\n\tvar usedQuota int64\n\tvar err error\n\tvar token *model.Token\n\tvar expiredTime int64\n\tif config.DisplayTokenStatEnabled {\n\t\ttokenId := c.GetInt(ctxkey.TokenId)\n\t\ttoken, err = model.GetTokenById(tokenId)\n\t\tif err == nil {\n\t\t\texpiredTime = token.ExpiredTime\n\t\t\tremainQuota = token.RemainQuota\n\t\t\tusedQuota = token.UsedQuota\n\t\t}\n\t} else {\n\t\tuserId := c.GetInt(ctxkey.Id)\n\t\tremainQuota, err = model.GetUserQuota(userId)\n\t\tif err != nil {\n\t\t\tusedQuota, err = model.GetUserUsedQuota(userId)\n\t\t}\n\t}\n\tif expiredTime <= 0 {\n\t\texpiredTime = 0\n\t}\n\tif err != nil {\n\t\tError := relaymodel.Error{\n\t\t\tMessage: err.Error(),\n\t\t\tType:    \"upstream_error\",\n\t\t}\n\t\tc.JSON(200, gin.H{\n\t\t\t\"error\": Error,\n\t\t})\n\t\treturn\n\t}\n\tquota := remainQuota + usedQuota\n\tamount := float64(quota)\n\tif config.DisplayInCurrencyEnabled {\n\t\tamount /= config.QuotaPerUnit\n\t}\n\tif token != nil && token.UnlimitedQuota {\n\t\tamount = 100000000\n\t}\n\tsubscription := OpenAISubscriptionResponse{\n\t\tObject:             \"billing_subscription\",\n\t\tHasPaymentMethod:   true,\n\t\tSoftLimitUSD:       amount,\n\t\tHardLimitUSD:       amount,\n\t\tSystemHardLimitUSD: amount,\n\t\tAccessUntil:        expiredTime,\n\t}\n\tc.JSON(200, subscription)\n\treturn\n}\n\nfunc GetUsage(c *gin.Context) {\n\tvar quota int64\n\tvar err error\n\tvar token *model.Token\n\tif config.DisplayTokenStatEnabled {\n\t\ttokenId := c.GetInt(ctxkey.TokenId)\n\t\ttoken, err = model.GetTokenById(tokenId)\n\t\tquota = token.UsedQuota\n\t} else {\n\t\tuserId := c.GetInt(ctxkey.Id)\n\t\tquota, err = model.GetUserUsedQuota(userId)\n\t}\n\tif err != nil {\n\t\tError := relaymodel.Error{\n\t\t\tMessage: err.Error(),\n\t\t\tType:    \"one_api_error\",\n\t\t}\n\t\tc.JSON(200, gin.H{\n\t\t\t\"error\": Error,\n\t\t})\n\t\treturn\n\t}\n\tamount := float64(quota)\n\tif config.DisplayInCurrencyEnabled {\n\t\tamount /= config.QuotaPerUnit\n\t}\n\tusage := OpenAIUsageResponse{\n\t\tObject:     \"list\",\n\t\tTotalUsage: amount * 100,\n\t}\n\tc.JSON(200, usage)\n\treturn\n}\n"
  },
  {
    "path": "controller/channel-billing.go",
    "content": "package controller\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/monitor\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// https://github.com/songquanpeng/one-api/issues/79\n\ntype OpenAISubscriptionResponse struct {\n\tObject             string  `json:\"object\"`\n\tHasPaymentMethod   bool    `json:\"has_payment_method\"`\n\tSoftLimitUSD       float64 `json:\"soft_limit_usd\"`\n\tHardLimitUSD       float64 `json:\"hard_limit_usd\"`\n\tSystemHardLimitUSD float64 `json:\"system_hard_limit_usd\"`\n\tAccessUntil        int64   `json:\"access_until\"`\n}\n\ntype OpenAIUsageDailyCost struct {\n\tTimestamp float64 `json:\"timestamp\"`\n\tLineItems []struct {\n\t\tName string  `json:\"name\"`\n\t\tCost float64 `json:\"cost\"`\n\t}\n}\n\ntype OpenAICreditGrants struct {\n\tObject         string  `json:\"object\"`\n\tTotalGranted   float64 `json:\"total_granted\"`\n\tTotalUsed      float64 `json:\"total_used\"`\n\tTotalAvailable float64 `json:\"total_available\"`\n}\n\ntype OpenAIUsageResponse struct {\n\tObject string `json:\"object\"`\n\t//DailyCosts []OpenAIUsageDailyCost `json:\"daily_costs\"`\n\tTotalUsage float64 `json:\"total_usage\"` // unit: 0.01 dollar\n}\n\ntype OpenAISBUsageResponse struct {\n\tMsg  string `json:\"msg\"`\n\tData *struct {\n\t\tCredit string `json:\"credit\"`\n\t} `json:\"data\"`\n}\n\ntype AIProxyUserOverviewResponse struct {\n\tSuccess   bool   `json:\"success\"`\n\tMessage   string `json:\"message\"`\n\tErrorCode int    `json:\"error_code\"`\n\tData      struct {\n\t\tTotalPoints float64 `json:\"totalPoints\"`\n\t} `json:\"data\"`\n}\n\ntype API2GPTUsageResponse struct {\n\tObject         string  `json:\"object\"`\n\tTotalGranted   float64 `json:\"total_granted\"`\n\tTotalUsed      float64 `json:\"total_used\"`\n\tTotalRemaining float64 `json:\"total_remaining\"`\n}\n\ntype APGC2DGPTUsageResponse struct {\n\t//Grants         interface{} `json:\"grants\"`\n\tObject         string  `json:\"object\"`\n\tTotalAvailable float64 `json:\"total_available\"`\n\tTotalGranted   float64 `json:\"total_granted\"`\n\tTotalUsed      float64 `json:\"total_used\"`\n}\n\ntype SiliconFlowUsageResponse struct {\n\tCode    int    `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tStatus  bool   `json:\"status\"`\n\tData    struct {\n\t\tID            string `json:\"id\"`\n\t\tName          string `json:\"name\"`\n\t\tImage         string `json:\"image\"`\n\t\tEmail         string `json:\"email\"`\n\t\tIsAdmin       bool   `json:\"isAdmin\"`\n\t\tBalance       string `json:\"balance\"`\n\t\tStatus        string `json:\"status\"`\n\t\tIntroduction  string `json:\"introduction\"`\n\t\tRole          string `json:\"role\"`\n\t\tChargeBalance string `json:\"chargeBalance\"`\n\t\tTotalBalance  string `json:\"totalBalance\"`\n\t\tCategory      string `json:\"category\"`\n\t} `json:\"data\"`\n}\n\ntype DeepSeekUsageResponse struct {\n\tIsAvailable  bool `json:\"is_available\"`\n\tBalanceInfos []struct {\n\t\tCurrency        string `json:\"currency\"`\n\t\tTotalBalance    string `json:\"total_balance\"`\n\t\tGrantedBalance  string `json:\"granted_balance\"`\n\t\tToppedUpBalance string `json:\"topped_up_balance\"`\n\t} `json:\"balance_infos\"`\n}\n\ntype OpenRouterResponse struct {\n\tData struct {\n\t\tTotalCredits float64 `json:\"total_credits\"`\n\t\tTotalUsage   float64 `json:\"total_usage\"`\n\t} `json:\"data\"`\n}\n\n// GetAuthHeader get auth header\nfunc GetAuthHeader(token string) http.Header {\n\th := http.Header{}\n\th.Add(\"Authorization\", fmt.Sprintf(\"Bearer %s\", token))\n\treturn h\n}\n\nfunc GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {\n\treq, err := http.NewRequest(method, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor k := range headers {\n\t\treq.Header.Add(k, headers.Get(k))\n\t}\n\tres, err := client.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"status code: %d\", res.StatusCode)\n\t}\n\tbody, err := io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = res.Body.Close()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn body, nil\n}\n\nfunc updateChannelCloseAIBalance(channel *model.Channel) (float64, error) {\n\turl := fmt.Sprintf(\"%s/dashboard/billing/credit_grants\", channel.GetBaseURL())\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := OpenAICreditGrants{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tchannel.UpdateBalance(response.TotalAvailable)\n\treturn response.TotalAvailable, nil\n}\n\nfunc updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) {\n\turl := fmt.Sprintf(\"https://api.openai-sb.com/sb-api/user/status?api_key=%s\", channel.Key)\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := OpenAISBUsageResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif response.Data == nil {\n\t\treturn 0, errors.New(response.Msg)\n\t}\n\tbalance, err := strconv.ParseFloat(response.Data.Credit, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tchannel.UpdateBalance(balance)\n\treturn balance, nil\n}\n\nfunc updateChannelAIProxyBalance(channel *model.Channel) (float64, error) {\n\turl := \"https://aiproxy.io/api/report/getUserOverview\"\n\theaders := http.Header{}\n\theaders.Add(\"Api-Key\", channel.Key)\n\tbody, err := GetResponseBody(\"GET\", url, channel, headers)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := AIProxyUserOverviewResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif !response.Success {\n\t\treturn 0, fmt.Errorf(\"code: %d, message: %s\", response.ErrorCode, response.Message)\n\t}\n\tchannel.UpdateBalance(response.Data.TotalPoints)\n\treturn response.Data.TotalPoints, nil\n}\n\nfunc updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) {\n\turl := \"https://api.api2gpt.com/dashboard/billing/credit_grants\"\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := API2GPTUsageResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tchannel.UpdateBalance(response.TotalRemaining)\n\treturn response.TotalRemaining, nil\n}\n\nfunc updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) {\n\turl := \"https://api.aigc2d.com/dashboard/billing/credit_grants\"\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := APGC2DGPTUsageResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tchannel.UpdateBalance(response.TotalAvailable)\n\treturn response.TotalAvailable, nil\n}\n\nfunc updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) {\n\turl := \"https://api.siliconflow.cn/v1/user/info\"\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := SiliconFlowUsageResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif response.Code != 20000 {\n\t\treturn 0, fmt.Errorf(\"code: %d, message: %s\", response.Code, response.Message)\n\t}\n\tbalance, err := strconv.ParseFloat(response.Data.TotalBalance, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tchannel.UpdateBalance(balance)\n\treturn balance, nil\n}\n\nfunc updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {\n\turl := \"https://api.deepseek.com/user/balance\"\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := DeepSeekUsageResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tindex := -1\n\tfor i, balanceInfo := range response.BalanceInfos {\n\t\tif balanceInfo.Currency == \"CNY\" {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\tif index == -1 {\n\t\treturn 0, errors.New(\"currency CNY not found\")\n\t}\n\tbalance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tchannel.UpdateBalance(balance)\n\treturn balance, nil\n}\n\nfunc updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) {\n\turl := \"https://openrouter.ai/api/v1/credits\"\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresponse := OpenRouterResponse{}\n\terr = json.Unmarshal(body, &response)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tbalance := response.Data.TotalCredits - response.Data.TotalUsage\n\tchannel.UpdateBalance(balance)\n\treturn balance, nil\n}\n\nfunc updateChannelBalance(channel *model.Channel) (float64, error) {\n\tbaseURL := channeltype.ChannelBaseURLs[channel.Type]\n\tif channel.GetBaseURL() == \"\" {\n\t\tchannel.BaseURL = &baseURL\n\t}\n\tswitch channel.Type {\n\tcase channeltype.OpenAI:\n\t\tif channel.GetBaseURL() != \"\" {\n\t\t\tbaseURL = channel.GetBaseURL()\n\t\t}\n\tcase channeltype.Azure:\n\t\treturn 0, errors.New(\"尚未实现\")\n\tcase channeltype.Custom:\n\t\tbaseURL = channel.GetBaseURL()\n\tcase channeltype.CloseAI:\n\t\treturn updateChannelCloseAIBalance(channel)\n\tcase channeltype.OpenAISB:\n\t\treturn updateChannelOpenAISBBalance(channel)\n\tcase channeltype.AIProxy:\n\t\treturn updateChannelAIProxyBalance(channel)\n\tcase channeltype.API2GPT:\n\t\treturn updateChannelAPI2GPTBalance(channel)\n\tcase channeltype.AIGC2D:\n\t\treturn updateChannelAIGC2DBalance(channel)\n\tcase channeltype.SiliconFlow:\n\t\treturn updateChannelSiliconFlowBalance(channel)\n\tcase channeltype.DeepSeek:\n\t\treturn updateChannelDeepSeekBalance(channel)\n\tcase channeltype.OpenRouter:\n\t\treturn updateChannelOpenRouterBalance(channel)\n\tdefault:\n\t\treturn 0, errors.New(\"尚未实现\")\n\t}\n\turl := fmt.Sprintf(\"%s/v1/dashboard/billing/subscription\", baseURL)\n\n\tbody, err := GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tsubscription := OpenAISubscriptionResponse{}\n\terr = json.Unmarshal(body, &subscription)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tnow := time.Now()\n\tstartDate := fmt.Sprintf(\"%s-01\", now.Format(\"2006-01\"))\n\tendDate := now.Format(\"2006-01-02\")\n\tif !subscription.HasPaymentMethod {\n\t\tstartDate = now.AddDate(0, 0, -100).Format(\"2006-01-02\")\n\t}\n\turl = fmt.Sprintf(\"%s/v1/dashboard/billing/usage?start_date=%s&end_date=%s\", baseURL, startDate, endDate)\n\tbody, err = GetResponseBody(\"GET\", url, channel, GetAuthHeader(channel.Key))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tusage := OpenAIUsageResponse{}\n\terr = json.Unmarshal(body, &usage)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tbalance := subscription.HardLimitUSD - usage.TotalUsage/100\n\tchannel.UpdateBalance(balance)\n\treturn balance, nil\n}\n\nfunc UpdateChannelBalance(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tchannel, err := model.GetChannelById(id, true)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tbalance, err := updateChannelBalance(channel)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"balance\": balance,\n\t})\n\treturn\n}\n\nfunc updateAllChannelsBalance() error {\n\tchannels, err := model.GetAllChannels(0, 0, \"all\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, channel := range channels {\n\t\tif channel.Status != model.ChannelStatusEnabled {\n\t\t\tcontinue\n\t\t}\n\t\t// TODO: support Azure\n\t\tif channel.Type != channeltype.OpenAI && channel.Type != channeltype.Custom {\n\t\t\tcontinue\n\t\t}\n\t\tbalance, err := updateChannelBalance(channel)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t} else {\n\t\t\t// err is nil & balance <= 0 means quota is used up\n\t\t\tif balance <= 0 {\n\t\t\t\tmonitor.DisableChannel(channel.Id, channel.Name, \"余额不足\")\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(config.RequestInterval)\n\t}\n\treturn nil\n}\n\nfunc UpdateAllChannelsBalance(c *gin.Context) {\n\t//err := updateAllChannelsBalance()\n\t//if err != nil {\n\t//\tc.JSON(http.StatusOK, gin.H{\n\t//\t\t\"success\": false,\n\t//\t\t\"message\": err.Error(),\n\t//\t})\n\t//\treturn\n\t//}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc AutomaticallyUpdateChannels(frequency int) {\n\tfor {\n\t\ttime.Sleep(time.Duration(frequency) * time.Minute)\n\t\tlogger.SysLog(\"updating all channels\")\n\t\t_ = updateAllChannelsBalance()\n\t\tlogger.SysLog(\"channels update done\")\n\t}\n}\n"
  },
  {
    "path": "controller/channel-test.go",
    "content": "package controller\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/message\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/monitor\"\n\t\"github.com/songquanpeng/one-api/relay\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/controller\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {\n\tif model == \"\" {\n\t\tmodel = \"gpt-3.5-turbo\"\n\t}\n\ttestRequest := &relaymodel.GeneralOpenAIRequest{\n\t\tModel: model,\n\t}\n\ttestMessage := relaymodel.Message{\n\t\tRole:    \"user\",\n\t\tContent: config.TestPrompt,\n\t}\n\ttestRequest.Messages = append(testRequest.Messages, testMessage)\n\treturn testRequest\n}\n\nfunc parseTestResponse(resp string) (*openai.TextResponse, string, error) {\n\tvar response openai.TextResponse\n\terr := json.Unmarshal([]byte(resp), &response)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tif len(response.Choices) == 0 {\n\t\treturn nil, \"\", errors.New(\"response has no choices\")\n\t}\n\tstringContent, ok := response.Choices[0].Content.(string)\n\tif !ok {\n\t\treturn nil, \"\", errors.New(\"response content is not string\")\n\t}\n\treturn &response, stringContent, nil\n}\n\nfunc testChannel(ctx context.Context, channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (responseMessage string, err error, openaiErr *relaymodel.Error) {\n\tstartTime := time.Now()\n\tw := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(w)\n\tc.Request = &http.Request{\n\t\tMethod: \"POST\",\n\t\tURL:    &url.URL{Path: \"/v1/chat/completions\"},\n\t\tBody:   nil,\n\t\tHeader: make(http.Header),\n\t}\n\tc.Request.Header.Set(\"Authorization\", \"Bearer \"+channel.Key)\n\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\tc.Set(ctxkey.Channel, channel.Type)\n\tc.Set(ctxkey.BaseURL, channel.GetBaseURL())\n\tcfg, _ := channel.LoadConfig()\n\tc.Set(ctxkey.Config, cfg)\n\tmiddleware.SetupContextForSelectedChannel(c, channel, \"\")\n\tmeta := meta.GetByContext(c)\n\tapiType := channeltype.ToAPIType(channel.Type)\n\tadaptor := relay.GetAdaptor(apiType)\n\tif adaptor == nil {\n\t\treturn \"\", fmt.Errorf(\"invalid api type: %d, adaptor is nil\", apiType), nil\n\t}\n\tadaptor.Init(meta)\n\tmodelName := request.Model\n\tmodelMap := channel.GetModelMapping()\n\tif modelName == \"\" || !strings.Contains(channel.Models, modelName) {\n\t\tmodelNames := strings.Split(channel.Models, \",\")\n\t\tif len(modelNames) > 0 {\n\t\t\tmodelName = modelNames[0]\n\t\t}\n\t}\n\tif modelMap != nil && modelMap[modelName] != \"\" {\n\t\tmodelName = modelMap[modelName]\n\t}\n\tmeta.OriginModelName, meta.ActualModelName = request.Model, modelName\n\trequest.Model = modelName\n\tconvertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request)\n\tif err != nil {\n\t\treturn \"\", err, nil\n\t}\n\tjsonData, err := json.Marshal(convertedRequest)\n\tif err != nil {\n\t\treturn \"\", err, nil\n\t}\n\tdefer func() {\n\t\tlogContent := fmt.Sprintf(\"渠道 %s 测试成功，响应：%s\", channel.Name, responseMessage)\n\t\tif err != nil || openaiErr != nil {\n\t\t\terrorMessage := \"\"\n\t\t\tif err != nil {\n\t\t\t\terrorMessage = err.Error()\n\t\t\t} else {\n\t\t\t\terrorMessage = openaiErr.Message\n\t\t\t}\n\t\t\tlogContent = fmt.Sprintf(\"渠道 %s 测试失败，错误：%s\", channel.Name, errorMessage)\n\t\t}\n\t\tgo model.RecordTestLog(ctx, &model.Log{\n\t\t\tChannelId:   channel.Id,\n\t\t\tModelName:   modelName,\n\t\t\tContent:     logContent,\n\t\t\tElapsedTime: helper.CalcElapsedTime(startTime),\n\t\t})\n\t}()\n\tlogger.SysLog(string(jsonData))\n\trequestBody := bytes.NewBuffer(jsonData)\n\tc.Request.Body = io.NopCloser(requestBody)\n\tresp, err := adaptor.DoRequest(c, meta, requestBody)\n\tif err != nil {\n\t\treturn \"\", err, nil\n\t}\n\tif resp != nil && resp.StatusCode != http.StatusOK {\n\t\terr := controller.RelayErrorHandler(resp)\n\t\terrorMessage := err.Error.Message\n\t\tif errorMessage != \"\" {\n\t\t\terrorMessage = \", error message: \" + errorMessage\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"http status code: %d%s\", resp.StatusCode, errorMessage), &err.Error\n\t}\n\tusage, respErr := adaptor.DoResponse(c, resp, meta)\n\tif respErr != nil {\n\t\treturn \"\", fmt.Errorf(\"%s\", respErr.Error.Message), &respErr.Error\n\t}\n\tif usage == nil {\n\t\treturn \"\", errors.New(\"usage is nil\"), nil\n\t}\n\trawResponse := w.Body.String()\n\t_, responseMessage, err = parseTestResponse(rawResponse)\n\tif err != nil {\n\t\tlogger.SysError(fmt.Sprintf(\"failed to parse error: %s, \\nresponse: %s\", err.Error(), rawResponse))\n\t\treturn \"\", err, nil\n\t}\n\tresult := w.Result()\n\t// print result.Body\n\trespBody, err := io.ReadAll(result.Body)\n\tif err != nil {\n\t\treturn \"\", err, nil\n\t}\n\tlogger.SysLog(fmt.Sprintf(\"testing channel #%d, response: \\n%s\", channel.Id, string(respBody)))\n\treturn responseMessage, nil, nil\n}\n\nfunc TestChannel(c *gin.Context) {\n\tctx := c.Request.Context()\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tchannel, err := model.GetChannelById(id, true)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tmodelName := c.Query(\"model\")\n\ttestRequest := buildTestRequest(modelName)\n\ttik := time.Now()\n\tresponseMessage, err, _ := testChannel(ctx, channel, testRequest)\n\ttok := time.Now()\n\tmilliseconds := tok.Sub(tik).Milliseconds()\n\tif err != nil {\n\t\tmilliseconds = 0\n\t}\n\tgo channel.UpdateResponseTime(milliseconds)\n\tconsumedTime := float64(milliseconds) / 1000.0\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\":   false,\n\t\t\t\"message\":   err.Error(),\n\t\t\t\"time\":      consumedTime,\n\t\t\t\"modelName\": modelName,\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\":   true,\n\t\t\"message\":   responseMessage,\n\t\t\"time\":      consumedTime,\n\t\t\"modelName\": modelName,\n\t})\n\treturn\n}\n\nvar testAllChannelsLock sync.Mutex\nvar testAllChannelsRunning bool = false\n\nfunc testChannels(ctx context.Context, notify bool, scope string) error {\n\tif config.RootUserEmail == \"\" {\n\t\tconfig.RootUserEmail = model.GetRootUserEmail()\n\t}\n\ttestAllChannelsLock.Lock()\n\tif testAllChannelsRunning {\n\t\ttestAllChannelsLock.Unlock()\n\t\treturn errors.New(\"测试已在运行中\")\n\t}\n\ttestAllChannelsRunning = true\n\ttestAllChannelsLock.Unlock()\n\tchannels, err := model.GetAllChannels(0, 0, scope)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar disableThreshold = int64(config.ChannelDisableThreshold * 1000)\n\tif disableThreshold == 0 {\n\t\tdisableThreshold = 10000000 // a impossible value\n\t}\n\tgo func() {\n\t\tfor _, channel := range channels {\n\t\t\tisChannelEnabled := channel.Status == model.ChannelStatusEnabled\n\t\t\ttik := time.Now()\n\t\t\ttestRequest := buildTestRequest(\"\")\n\t\t\t_, err, openaiErr := testChannel(ctx, channel, testRequest)\n\t\t\ttok := time.Now()\n\t\t\tmilliseconds := tok.Sub(tik).Milliseconds()\n\t\t\tif isChannelEnabled && milliseconds > disableThreshold {\n\t\t\t\terr = fmt.Errorf(\"响应时间 %.2fs 超过阈值 %.2fs\", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)\n\t\t\t\tif config.AutomaticDisableChannelEnabled {\n\t\t\t\t\tmonitor.DisableChannel(channel.Id, channel.Name, err.Error())\n\t\t\t\t} else {\n\t\t\t\t\t_ = message.Notify(message.ByAll, fmt.Sprintf(\"渠道 %s （%d）测试超时\", channel.Name, channel.Id), \"\", err.Error())\n\t\t\t\t}\n\t\t\t}\n\t\t\tif isChannelEnabled && monitor.ShouldDisableChannel(openaiErr, -1) {\n\t\t\t\tmonitor.DisableChannel(channel.Id, channel.Name, err.Error())\n\t\t\t}\n\t\t\tif !isChannelEnabled && monitor.ShouldEnableChannel(err, openaiErr) {\n\t\t\t\tmonitor.EnableChannel(channel.Id, channel.Name)\n\t\t\t}\n\t\t\tchannel.UpdateResponseTime(milliseconds)\n\t\t\ttime.Sleep(config.RequestInterval)\n\t\t}\n\t\ttestAllChannelsLock.Lock()\n\t\ttestAllChannelsRunning = false\n\t\ttestAllChannelsLock.Unlock()\n\t\tif notify {\n\t\t\terr := message.Notify(message.ByAll, \"渠道测试完成\", \"\", \"渠道测试完成，如果没有收到禁用通知，说明所有渠道都正常\")\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(fmt.Sprintf(\"failed to send email: %s\", err.Error()))\n\t\t\t}\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc TestChannels(c *gin.Context) {\n\tctx := c.Request.Context()\n\tscope := c.Query(\"scope\")\n\tif scope == \"\" {\n\t\tscope = \"all\"\n\t}\n\terr := testChannels(ctx, true, scope)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc AutomaticallyTestChannels(frequency int) {\n\tctx := context.Background()\n\tfor {\n\t\ttime.Sleep(time.Duration(frequency) * time.Minute)\n\t\tlogger.SysLog(\"testing all channels\")\n\t\t_ = testChannels(ctx, false, \"all\")\n\t\tlogger.SysLog(\"channel test finished\")\n\t}\n}\n"
  },
  {
    "path": "controller/channel.go",
    "content": "package controller\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nfunc GetAllChannels(c *gin.Context) {\n\tp, _ := strconv.Atoi(c.Query(\"p\"))\n\tif p < 0 {\n\t\tp = 0\n\t}\n\tchannels, err := model.GetAllChannels(p*config.ItemsPerPage, config.ItemsPerPage, \"limited\")\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    channels,\n\t})\n\treturn\n}\n\nfunc SearchChannels(c *gin.Context) {\n\tkeyword := c.Query(\"keyword\")\n\tchannels, err := model.SearchChannels(keyword)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    channels,\n\t})\n\treturn\n}\n\nfunc GetChannel(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tchannel, err := model.GetChannelById(id, false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    channel,\n\t})\n\treturn\n}\n\nfunc AddChannel(c *gin.Context) {\n\tchannel := model.Channel{}\n\terr := c.ShouldBindJSON(&channel)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tchannel.CreatedTime = helper.GetTimestamp()\n\tkeys := strings.Split(channel.Key, \"\\n\")\n\tchannels := make([]model.Channel, 0, len(keys))\n\tfor _, key := range keys {\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tlocalChannel := channel\n\t\tlocalChannel.Key = key\n\t\tchannels = append(channels, localChannel)\n\t}\n\terr = model.BatchInsertChannels(channels)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc DeleteChannel(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tchannel := model.Channel{Id: id}\n\terr := channel.Delete()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc DeleteDisabledChannel(c *gin.Context) {\n\trows, err := model.DeleteDisabledChannel()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    rows,\n\t})\n\treturn\n}\n\nfunc UpdateChannel(c *gin.Context) {\n\tchannel := model.Channel{}\n\terr := c.ShouldBindJSON(&channel)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\terr = channel.Update()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    channel,\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/group.go",
    "content": "package controller\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"net/http\"\n)\n\nfunc GetGroups(c *gin.Context) {\n\tgroupNames := make([]string, 0)\n\tfor groupName := range billingratio.GroupRatio {\n\t\tgroupNames = append(groupNames, groupName)\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    groupNames,\n\t})\n}\n"
  },
  {
    "path": "controller/log.go",
    "content": "package controller\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"net/http\"\n\t\"strconv\"\n)\n\nfunc GetAllLogs(c *gin.Context) {\n\tp, _ := strconv.Atoi(c.Query(\"p\"))\n\tif p < 0 {\n\t\tp = 0\n\t}\n\tlogType, _ := strconv.Atoi(c.Query(\"type\"))\n\tstartTimestamp, _ := strconv.ParseInt(c.Query(\"start_timestamp\"), 10, 64)\n\tendTimestamp, _ := strconv.ParseInt(c.Query(\"end_timestamp\"), 10, 64)\n\tusername := c.Query(\"username\")\n\ttokenName := c.Query(\"token_name\")\n\tmodelName := c.Query(\"model_name\")\n\tchannel, _ := strconv.Atoi(c.Query(\"channel\"))\n\tlogs, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, p*config.ItemsPerPage, config.ItemsPerPage, channel)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    logs,\n\t})\n\treturn\n}\n\nfunc GetUserLogs(c *gin.Context) {\n\tp, _ := strconv.Atoi(c.Query(\"p\"))\n\tif p < 0 {\n\t\tp = 0\n\t}\n\tuserId := c.GetInt(ctxkey.Id)\n\tlogType, _ := strconv.Atoi(c.Query(\"type\"))\n\tstartTimestamp, _ := strconv.ParseInt(c.Query(\"start_timestamp\"), 10, 64)\n\tendTimestamp, _ := strconv.ParseInt(c.Query(\"end_timestamp\"), 10, 64)\n\ttokenName := c.Query(\"token_name\")\n\tmodelName := c.Query(\"model_name\")\n\tlogs, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, p*config.ItemsPerPage, config.ItemsPerPage)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    logs,\n\t})\n\treturn\n}\n\nfunc SearchAllLogs(c *gin.Context) {\n\tkeyword := c.Query(\"keyword\")\n\tlogs, err := model.SearchAllLogs(keyword)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    logs,\n\t})\n\treturn\n}\n\nfunc SearchUserLogs(c *gin.Context) {\n\tkeyword := c.Query(\"keyword\")\n\tuserId := c.GetInt(ctxkey.Id)\n\tlogs, err := model.SearchUserLogs(userId, keyword)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    logs,\n\t})\n\treturn\n}\n\nfunc GetLogsStat(c *gin.Context) {\n\tlogType, _ := strconv.Atoi(c.Query(\"type\"))\n\tstartTimestamp, _ := strconv.ParseInt(c.Query(\"start_timestamp\"), 10, 64)\n\tendTimestamp, _ := strconv.ParseInt(c.Query(\"end_timestamp\"), 10, 64)\n\ttokenName := c.Query(\"token_name\")\n\tusername := c.Query(\"username\")\n\tmodelName := c.Query(\"model_name\")\n\tchannel, _ := strconv.Atoi(c.Query(\"channel\"))\n\tquotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel)\n\t//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, \"\")\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\": gin.H{\n\t\t\t\"quota\": quotaNum,\n\t\t\t//\"token\": tokenNum,\n\t\t},\n\t})\n\treturn\n}\n\nfunc GetLogsSelfStat(c *gin.Context) {\n\tusername := c.GetString(ctxkey.Username)\n\tlogType, _ := strconv.Atoi(c.Query(\"type\"))\n\tstartTimestamp, _ := strconv.ParseInt(c.Query(\"start_timestamp\"), 10, 64)\n\tendTimestamp, _ := strconv.ParseInt(c.Query(\"end_timestamp\"), 10, 64)\n\ttokenName := c.Query(\"token_name\")\n\tmodelName := c.Query(\"model_name\")\n\tchannel, _ := strconv.Atoi(c.Query(\"channel\"))\n\tquotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel)\n\t//tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName)\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\": gin.H{\n\t\t\t\"quota\": quotaNum,\n\t\t\t//\"token\": tokenNum,\n\t\t},\n\t})\n\treturn\n}\n\nfunc DeleteHistoryLogs(c *gin.Context) {\n\ttargetTimestamp, _ := strconv.ParseInt(c.Query(\"target_timestamp\"), 10, 64)\n\tif targetTimestamp == 0 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"target timestamp is required\",\n\t\t})\n\t\treturn\n\t}\n\tcount, err := model.DeleteOldLog(targetTimestamp)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    count,\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/misc.go",
    "content": "package controller\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/i18n\"\n\t\"github.com/songquanpeng/one-api/common/message\"\n\t\"github.com/songquanpeng/one-api/model\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc GetStatus(c *gin.Context) {\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\": gin.H{\n\t\t\t\"version\":                     common.Version,\n\t\t\t\"start_time\":                  common.StartTime,\n\t\t\t\"email_verification\":          config.EmailVerificationEnabled,\n\t\t\t\"github_oauth\":                config.GitHubOAuthEnabled,\n\t\t\t\"github_client_id\":            config.GitHubClientId,\n\t\t\t\"lark_client_id\":              config.LarkClientId,\n\t\t\t\"system_name\":                 config.SystemName,\n\t\t\t\"logo\":                        config.Logo,\n\t\t\t\"footer_html\":                 config.Footer,\n\t\t\t\"wechat_qrcode\":               config.WeChatAccountQRCodeImageURL,\n\t\t\t\"wechat_login\":                config.WeChatAuthEnabled,\n\t\t\t\"server_address\":              config.ServerAddress,\n\t\t\t\"turnstile_check\":             config.TurnstileCheckEnabled,\n\t\t\t\"turnstile_site_key\":          config.TurnstileSiteKey,\n\t\t\t\"top_up_link\":                 config.TopUpLink,\n\t\t\t\"chat_link\":                   config.ChatLink,\n\t\t\t\"quota_per_unit\":              config.QuotaPerUnit,\n\t\t\t\"display_in_currency\":         config.DisplayInCurrencyEnabled,\n\t\t\t\"oidc\":                        config.OidcEnabled,\n\t\t\t\"oidc_client_id\":              config.OidcClientId,\n\t\t\t\"oidc_well_known\":             config.OidcWellKnown,\n\t\t\t\"oidc_authorization_endpoint\": config.OidcAuthorizationEndpoint,\n\t\t\t\"oidc_token_endpoint\":         config.OidcTokenEndpoint,\n\t\t\t\"oidc_userinfo_endpoint\":      config.OidcUserinfoEndpoint,\n\t\t},\n\t})\n\treturn\n}\n\nfunc GetNotice(c *gin.Context) {\n\tconfig.OptionMapRWMutex.RLock()\n\tdefer config.OptionMapRWMutex.RUnlock()\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    config.OptionMap[\"Notice\"],\n\t})\n\treturn\n}\n\nfunc GetAbout(c *gin.Context) {\n\tconfig.OptionMapRWMutex.RLock()\n\tdefer config.OptionMapRWMutex.RUnlock()\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    config.OptionMap[\"About\"],\n\t})\n\treturn\n}\n\nfunc GetHomePageContent(c *gin.Context) {\n\tconfig.OptionMapRWMutex.RLock()\n\tdefer config.OptionMapRWMutex.RUnlock()\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    config.OptionMap[\"HomePageContent\"],\n\t})\n\treturn\n}\n\nfunc SendEmailVerification(c *gin.Context) {\n\temail := c.Query(\"email\")\n\tif err := common.Validate.Var(email, \"required,email\"); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif config.EmailDomainRestrictionEnabled {\n\t\tallowed := false\n\t\tfor _, domain := range config.EmailDomainWhitelist {\n\t\t\tif strings.HasSuffix(email, \"@\"+domain) {\n\t\t\t\tallowed = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !allowed {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"管理员启用了邮箱域名白名单，您的邮箱地址的域名不在白名单中\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\tif model.IsEmailAlreadyTaken(email) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"邮箱地址已被占用\",\n\t\t})\n\t\treturn\n\t}\n\tcode := common.GenerateVerificationCode(6)\n\tcommon.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)\n\tsubject := fmt.Sprintf(\"%s 邮箱验证邮件\", config.SystemName)\n\tcontent := message.EmailTemplate(\n\t\tsubject,\n\t\tfmt.Sprintf(`\n\t\t\t<p>您好！</p>\n\t\t\t<p>您正在进行 %s 邮箱验证。</p>\n\t\t\t<p>您的验证码为：</p>\n\t\t\t<p style=\"font-size: 24px; font-weight: bold; color: #333; background-color: #f8f8f8; padding: 10px; text-align: center; border-radius: 4px;\">%s</p>\n\t\t\t<p style=\"color: #666;\">验证码 %d 分钟内有效，如果不是本人操作，请忽略。</p>\n\t\t`, config.SystemName, code, common.VerificationValidMinutes),\n\t)\n\terr := message.SendEmail(subject, email, content)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc SendPasswordResetEmail(c *gin.Context) {\n\temail := c.Query(\"email\")\n\tif err := common.Validate.Var(email, \"required,email\"); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif !model.IsEmailAlreadyTaken(email) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"该邮箱地址未注册\",\n\t\t})\n\t\treturn\n\t}\n\tcode := common.GenerateVerificationCode(0)\n\tcommon.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)\n\tlink := fmt.Sprintf(\"%s/user/reset?email=%s&token=%s\", config.ServerAddress, email, code)\n\tsubject := fmt.Sprintf(\"%s 密码重置\", config.SystemName)\n\tcontent := message.EmailTemplate(\n\t\tsubject,\n\t\tfmt.Sprintf(`\n\t\t\t<p>您好！</p>\n\t\t\t<p>您正在进行 %s 密码重置。</p>\n\t\t\t<p>请点击下面的按钮进行密码重置：</p>\n\t\t\t<p style=\"text-align: center; margin: 30px 0;\">\n\t\t\t\t<a href=\"%s\" style=\"background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;\">重置密码</a>\n\t\t\t</p>\n\t\t\t<p style=\"color: #666;\">如果按钮无法点击，请复制以下链接到浏览器中打开：</p>\n\t\t\t<p style=\"background-color: #f8f8f8; padding: 10px; border-radius: 4px; word-break: break-all;\">%s</p>\n\t\t\t<p style=\"color: #666;\">重置链接 %d 分钟内有效，如果不是本人操作，请忽略。</p>\n\t\t`, config.SystemName, link, link, common.VerificationValidMinutes),\n\t)\n\terr := message.SendEmail(subject, email, content)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": fmt.Sprintf(\"%s%s\", i18n.Translate(c, \"send_email_failed\"), err.Error()),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\ntype PasswordResetRequest struct {\n\tEmail string `json:\"email\"`\n\tToken string `json:\"token\"`\n}\n\nfunc ResetPassword(c *gin.Context) {\n\tvar req PasswordResetRequest\n\terr := json.NewDecoder(c.Request.Body).Decode(&req)\n\tif req.Email == \"\" || req.Token == \"\" {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"重置链接非法或已过期\",\n\t\t})\n\t\treturn\n\t}\n\tpassword := common.GenerateVerificationCode(12)\n\terr = model.ResetUserPasswordByEmail(req.Email, password)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tcommon.DeleteKey(req.Email, common.PasswordResetPurpose)\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    password,\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/model.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/model\"\n\trelay \"github.com/songquanpeng/one-api/relay\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/apitype\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// https://platform.openai.com/docs/api-reference/models/list\n\ntype OpenAIModelPermission struct {\n\tId                 string  `json:\"id\"`\n\tObject             string  `json:\"object\"`\n\tCreated            int     `json:\"created\"`\n\tAllowCreateEngine  bool    `json:\"allow_create_engine\"`\n\tAllowSampling      bool    `json:\"allow_sampling\"`\n\tAllowLogprobs      bool    `json:\"allow_logprobs\"`\n\tAllowSearchIndices bool    `json:\"allow_search_indices\"`\n\tAllowView          bool    `json:\"allow_view\"`\n\tAllowFineTuning    bool    `json:\"allow_fine_tuning\"`\n\tOrganization       string  `json:\"organization\"`\n\tGroup              *string `json:\"group\"`\n\tIsBlocking         bool    `json:\"is_blocking\"`\n}\n\ntype OpenAIModels struct {\n\tId         string                  `json:\"id\"`\n\tObject     string                  `json:\"object\"`\n\tCreated    int                     `json:\"created\"`\n\tOwnedBy    string                  `json:\"owned_by\"`\n\tPermission []OpenAIModelPermission `json:\"permission\"`\n\tRoot       string                  `json:\"root\"`\n\tParent     *string                 `json:\"parent\"`\n}\n\nvar models []OpenAIModels\nvar modelsMap map[string]OpenAIModels\nvar channelId2Models map[int][]string\n\nfunc init() {\n\tvar permission []OpenAIModelPermission\n\tpermission = append(permission, OpenAIModelPermission{\n\t\tId:                 \"modelperm-LwHkVFn8AcMItP432fKKDIKJ\",\n\t\tObject:             \"model_permission\",\n\t\tCreated:            1626777600,\n\t\tAllowCreateEngine:  true,\n\t\tAllowSampling:      true,\n\t\tAllowLogprobs:      true,\n\t\tAllowSearchIndices: false,\n\t\tAllowView:          true,\n\t\tAllowFineTuning:    false,\n\t\tOrganization:       \"*\",\n\t\tGroup:              nil,\n\t\tIsBlocking:         false,\n\t})\n\t// https://platform.openai.com/docs/models/model-endpoint-compatibility\n\tfor i := 0; i < apitype.Dummy; i++ {\n\t\tif i == apitype.AIProxyLibrary {\n\t\t\tcontinue\n\t\t}\n\t\tadaptor := relay.GetAdaptor(i)\n\t\tchannelName := adaptor.GetChannelName()\n\t\tmodelNames := adaptor.GetModelList()\n\t\tfor _, modelName := range modelNames {\n\t\t\tmodels = append(models, OpenAIModels{\n\t\t\t\tId:         modelName,\n\t\t\t\tObject:     \"model\",\n\t\t\t\tCreated:    1626777600,\n\t\t\t\tOwnedBy:    channelName,\n\t\t\t\tPermission: permission,\n\t\t\t\tRoot:       modelName,\n\t\t\t\tParent:     nil,\n\t\t\t})\n\t\t}\n\t}\n\tfor _, channelType := range openai.CompatibleChannels {\n\t\tif channelType == channeltype.Azure {\n\t\t\tcontinue\n\t\t}\n\t\tchannelName, channelModelList := openai.GetCompatibleChannelMeta(channelType)\n\t\tfor _, modelName := range channelModelList {\n\t\t\tmodels = append(models, OpenAIModels{\n\t\t\t\tId:         modelName,\n\t\t\t\tObject:     \"model\",\n\t\t\t\tCreated:    1626777600,\n\t\t\t\tOwnedBy:    channelName,\n\t\t\t\tPermission: permission,\n\t\t\t\tRoot:       modelName,\n\t\t\t\tParent:     nil,\n\t\t\t})\n\t\t}\n\t}\n\tmodelsMap = make(map[string]OpenAIModels)\n\tfor _, model := range models {\n\t\tmodelsMap[model.Id] = model\n\t}\n\tchannelId2Models = make(map[int][]string)\n\tfor i := 1; i < channeltype.Dummy; i++ {\n\t\tadaptor := relay.GetAdaptor(channeltype.ToAPIType(i))\n\t\tmeta := &meta.Meta{\n\t\t\tChannelType: i,\n\t\t}\n\t\tadaptor.Init(meta)\n\t\tchannelId2Models[i] = adaptor.GetModelList()\n\t}\n}\n\nfunc DashboardListModels(c *gin.Context) {\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    channelId2Models,\n\t})\n}\n\nfunc ListAllModels(c *gin.Context) {\n\tc.JSON(200, gin.H{\n\t\t\"object\": \"list\",\n\t\t\"data\":   models,\n\t})\n}\n\nfunc ListModels(c *gin.Context) {\n\tctx := c.Request.Context()\n\tvar availableModels []string\n\tif c.GetString(ctxkey.AvailableModels) != \"\" {\n\t\tavailableModels = strings.Split(c.GetString(ctxkey.AvailableModels), \",\")\n\t} else {\n\t\tuserId := c.GetInt(ctxkey.Id)\n\t\tuserGroup, _ := model.CacheGetUserGroup(userId)\n\t\tavailableModels, _ = model.CacheGetGroupModels(ctx, userGroup)\n\t}\n\tmodelSet := make(map[string]bool)\n\tfor _, availableModel := range availableModels {\n\t\tmodelSet[availableModel] = true\n\t}\n\tavailableOpenAIModels := make([]OpenAIModels, 0)\n\tfor _, model := range models {\n\t\tif _, ok := modelSet[model.Id]; ok {\n\t\t\tmodelSet[model.Id] = false\n\t\t\tavailableOpenAIModels = append(availableOpenAIModels, model)\n\t\t}\n\t}\n\tfor modelName, ok := range modelSet {\n\t\tif ok {\n\t\t\tavailableOpenAIModels = append(availableOpenAIModels, OpenAIModels{\n\t\t\t\tId:      modelName,\n\t\t\t\tObject:  \"model\",\n\t\t\t\tCreated: 1626777600,\n\t\t\t\tOwnedBy: \"custom\",\n\t\t\t\tRoot:    modelName,\n\t\t\t\tParent:  nil,\n\t\t\t})\n\t\t}\n\t}\n\tc.JSON(200, gin.H{\n\t\t\"object\": \"list\",\n\t\t\"data\":   availableOpenAIModels,\n\t})\n}\n\nfunc RetrieveModel(c *gin.Context) {\n\tmodelId := c.Param(\"model\")\n\tif model, ok := modelsMap[modelId]; ok {\n\t\tc.JSON(200, model)\n\t} else {\n\t\tError := relaymodel.Error{\n\t\t\tMessage: fmt.Sprintf(\"The model '%s' does not exist\", modelId),\n\t\t\tType:    \"invalid_request_error\",\n\t\t\tParam:   \"model\",\n\t\t\tCode:    \"model_not_found\",\n\t\t}\n\t\tc.JSON(200, gin.H{\n\t\t\t\"error\": Error,\n\t\t})\n\t}\n}\n\nfunc GetUserAvailableModels(c *gin.Context) {\n\tctx := c.Request.Context()\n\tid := c.GetInt(ctxkey.Id)\n\tuserGroup, err := model.CacheGetUserGroup(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tmodels, err := model.CacheGetGroupModels(ctx, userGroup)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    models,\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/option.go",
    "content": "package controller\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/i18n\"\n\t\"github.com/songquanpeng/one-api/model\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc GetOptions(c *gin.Context) {\n\tvar options []*model.Option\n\tconfig.OptionMapRWMutex.Lock()\n\tfor k, v := range config.OptionMap {\n\t\tif strings.HasSuffix(k, \"Token\") || strings.HasSuffix(k, \"Secret\") {\n\t\t\tcontinue\n\t\t}\n\t\toptions = append(options, &model.Option{\n\t\t\tKey:   k,\n\t\t\tValue: helper.Interface2String(v),\n\t\t})\n\t}\n\tconfig.OptionMapRWMutex.Unlock()\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    options,\n\t})\n\treturn\n}\n\nfunc UpdateOption(c *gin.Context) {\n\tvar option model.Option\n\terr := json.NewDecoder(c.Request.Body).Decode(&option)\n\tif err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tswitch option.Key {\n\tcase \"Theme\":\n\t\tif !config.ValidThemes[option.Value] {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无效的主题\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\tcase \"GitHubOAuthEnabled\":\n\t\tif option.Value == \"true\" && config.GitHubClientId == \"\" {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法启用 GitHub OAuth，请先填入 GitHub Client Id 以及 GitHub Client Secret！\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\tcase \"EmailDomainRestrictionEnabled\":\n\t\tif option.Value == \"true\" && len(config.EmailDomainWhitelist) == 0 {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法启用邮箱域名限制，请先填入限制的邮箱域名！\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\tcase \"WeChatAuthEnabled\":\n\t\tif option.Value == \"true\" && config.WeChatServerAddress == \"\" {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法启用微信登录，请先填入微信登录相关配置信息！\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\tcase \"TurnstileCheckEnabled\":\n\t\tif option.Value == \"true\" && config.TurnstileSiteKey == \"\" {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法启用 Turnstile 校验，请先填入 Turnstile 校验相关配置信息！\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\terr = model.UpdateOption(option.Key, option.Value)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/redemption.go",
    "content": "package controller\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"net/http\"\n\t\"strconv\"\n)\n\nfunc GetAllRedemptions(c *gin.Context) {\n\tp, _ := strconv.Atoi(c.Query(\"p\"))\n\tif p < 0 {\n\t\tp = 0\n\t}\n\tredemptions, err := model.GetAllRedemptions(p*config.ItemsPerPage, config.ItemsPerPage)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    redemptions,\n\t})\n\treturn\n}\n\nfunc SearchRedemptions(c *gin.Context) {\n\tkeyword := c.Query(\"keyword\")\n\tredemptions, err := model.SearchRedemptions(keyword)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    redemptions,\n\t})\n\treturn\n}\n\nfunc GetRedemption(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tredemption, err := model.GetRedemptionById(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    redemption,\n\t})\n\treturn\n}\n\nfunc AddRedemption(c *gin.Context) {\n\tredemption := model.Redemption{}\n\terr := c.ShouldBindJSON(&redemption)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif len(redemption.Name) == 0 || len(redemption.Name) > 20 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"兑换码名称长度必须在1-20之间\",\n\t\t})\n\t\treturn\n\t}\n\tif redemption.Count <= 0 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"兑换码个数必须大于0\",\n\t\t})\n\t\treturn\n\t}\n\tif redemption.Count > 100 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"一次兑换码批量生成的个数不能大于 100\",\n\t\t})\n\t\treturn\n\t}\n\tvar keys []string\n\tfor i := 0; i < redemption.Count; i++ {\n\t\tkey := random.GetUUID()\n\t\tcleanRedemption := model.Redemption{\n\t\t\tUserId:      c.GetInt(ctxkey.Id),\n\t\t\tName:        redemption.Name,\n\t\t\tKey:         key,\n\t\t\tCreatedTime: helper.GetTimestamp(),\n\t\t\tQuota:       redemption.Quota,\n\t\t}\n\t\terr = cleanRedemption.Insert()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t\t\"data\":    keys,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tkeys = append(keys, key)\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    keys,\n\t})\n\treturn\n}\n\nfunc DeleteRedemption(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\terr := model.DeleteRedemptionById(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc UpdateRedemption(c *gin.Context) {\n\tstatusOnly := c.Query(\"status_only\")\n\tredemption := model.Redemption{}\n\terr := c.ShouldBindJSON(&redemption)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tcleanRedemption, err := model.GetRedemptionById(redemption.Id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif statusOnly != \"\" {\n\t\tcleanRedemption.Status = redemption.Status\n\t} else {\n\t\t// If you add more fields, please also update redemption.Update()\n\t\tcleanRedemption.Name = redemption.Name\n\t\tcleanRedemption.Quota = redemption.Quota\n\t}\n\terr = cleanRedemption.Update()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    cleanRedemption,\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/relay.go",
    "content": "package controller\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n\tdbmodel \"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/monitor\"\n\t\"github.com/songquanpeng/one-api/relay/controller\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\n// https://platform.openai.com/docs/api-reference/chat\n\nfunc relayHelper(c *gin.Context, relayMode int) *model.ErrorWithStatusCode {\n\tvar err *model.ErrorWithStatusCode\n\tswitch relayMode {\n\tcase relaymode.ImagesGenerations:\n\t\terr = controller.RelayImageHelper(c, relayMode)\n\tcase relaymode.AudioSpeech:\n\t\tfallthrough\n\tcase relaymode.AudioTranslation:\n\t\tfallthrough\n\tcase relaymode.AudioTranscription:\n\t\terr = controller.RelayAudioHelper(c, relayMode)\n\tcase relaymode.Proxy:\n\t\terr = controller.RelayProxyHelper(c, relayMode)\n\tdefault:\n\t\terr = controller.RelayTextHelper(c)\n\t}\n\treturn err\n}\n\nfunc Relay(c *gin.Context) {\n\tctx := c.Request.Context()\n\trelayMode := relaymode.GetByPath(c.Request.URL.Path)\n\tif config.DebugEnabled {\n\t\trequestBody, _ := common.GetRequestBody(c)\n\t\tlogger.Debugf(ctx, \"request body: %s\", string(requestBody))\n\t}\n\tchannelId := c.GetInt(ctxkey.ChannelId)\n\tuserId := c.GetInt(ctxkey.Id)\n\tbizErr := relayHelper(c, relayMode)\n\tif bizErr == nil {\n\t\tmonitor.Emit(channelId, true)\n\t\treturn\n\t}\n\tlastFailedChannelId := channelId\n\tchannelName := c.GetString(ctxkey.ChannelName)\n\tgroup := c.GetString(ctxkey.Group)\n\toriginalModel := c.GetString(ctxkey.OriginalModel)\n\tgo processChannelRelayError(ctx, userId, channelId, channelName, *bizErr)\n\trequestId := c.GetString(helper.RequestIdKey)\n\tretryTimes := config.RetryTimes\n\tif !shouldRetry(c, bizErr.StatusCode) {\n\t\tlogger.Errorf(ctx, \"relay error happen, status code is %d, won't retry in this case\", bizErr.StatusCode)\n\t\tretryTimes = 0\n\t}\n\tfor i := retryTimes; i > 0; i-- {\n\t\tchannel, err := dbmodel.CacheGetRandomSatisfiedChannel(group, originalModel, i != retryTimes)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(ctx, \"CacheGetRandomSatisfiedChannel failed: %+v\", err)\n\t\t\tbreak\n\t\t}\n\t\tlogger.Infof(ctx, \"using channel #%d to retry (remain times %d)\", channel.Id, i)\n\t\tif channel.Id == lastFailedChannelId {\n\t\t\tcontinue\n\t\t}\n\t\tmiddleware.SetupContextForSelectedChannel(c, channel, originalModel)\n\t\trequestBody, err := common.GetRequestBody(c)\n\t\tc.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))\n\t\tbizErr = relayHelper(c, relayMode)\n\t\tif bizErr == nil {\n\t\t\treturn\n\t\t}\n\t\tchannelId := c.GetInt(ctxkey.ChannelId)\n\t\tlastFailedChannelId = channelId\n\t\tchannelName := c.GetString(ctxkey.ChannelName)\n\t\tgo processChannelRelayError(ctx, userId, channelId, channelName, *bizErr)\n\t}\n\tif bizErr != nil {\n\t\tif bizErr.StatusCode == http.StatusTooManyRequests {\n\t\t\tbizErr.Error.Message = \"当前分组上游负载已饱和，请稍后再试\"\n\t\t}\n\n\t\t// BUG: bizErr is in race condition\n\t\tbizErr.Error.Message = helper.MessageWithRequestId(bizErr.Error.Message, requestId)\n\t\tc.JSON(bizErr.StatusCode, gin.H{\n\t\t\t\"error\": bizErr.Error,\n\t\t})\n\t}\n}\n\nfunc shouldRetry(c *gin.Context, statusCode int) bool {\n\tif _, ok := c.Get(ctxkey.SpecificChannelId); ok {\n\t\treturn false\n\t}\n\tif statusCode == http.StatusTooManyRequests {\n\t\treturn true\n\t}\n\tif statusCode/100 == 5 {\n\t\treturn true\n\t}\n\tif statusCode == http.StatusBadRequest {\n\t\treturn false\n\t}\n\tif statusCode/100 == 2 {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc processChannelRelayError(ctx context.Context, userId int, channelId int, channelName string, err model.ErrorWithStatusCode) {\n\tlogger.Errorf(ctx, \"relay error (channel id %d, user id: %d): %s\", channelId, userId, err.Message)\n\t// https://platform.openai.com/docs/guides/error-codes/api-errors\n\tif monitor.ShouldDisableChannel(&err.Error, err.StatusCode) {\n\t\tmonitor.DisableChannel(channelId, channelName, err.Message)\n\t} else {\n\t\tmonitor.Emit(channelId, false)\n\t}\n}\n\nfunc RelayNotImplemented(c *gin.Context) {\n\terr := model.Error{\n\t\tMessage: \"API not implemented\",\n\t\tType:    \"one_api_error\",\n\t\tParam:   \"\",\n\t\tCode:    \"api_not_implemented\",\n\t}\n\tc.JSON(http.StatusNotImplemented, gin.H{\n\t\t\"error\": err,\n\t})\n}\n\nfunc RelayNotFound(c *gin.Context) {\n\terr := model.Error{\n\t\tMessage: fmt.Sprintf(\"Invalid URL (%s %s)\", c.Request.Method, c.Request.URL.Path),\n\t\tType:    \"invalid_request_error\",\n\t\tParam:   \"\",\n\t\tCode:    \"\",\n\t}\n\tc.JSON(http.StatusNotFound, gin.H{\n\t\t\"error\": err,\n\t})\n}\n"
  },
  {
    "path": "controller/token.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/network\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"net/http\"\n\t\"strconv\"\n)\n\nfunc GetAllTokens(c *gin.Context) {\n\tuserId := c.GetInt(ctxkey.Id)\n\tp, _ := strconv.Atoi(c.Query(\"p\"))\n\tif p < 0 {\n\t\tp = 0\n\t}\n\n\torder := c.Query(\"order\")\n\ttokens, err := model.GetAllUserTokens(userId, p*config.ItemsPerPage, config.ItemsPerPage, order)\n\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    tokens,\n\t})\n\treturn\n}\n\nfunc SearchTokens(c *gin.Context) {\n\tuserId := c.GetInt(ctxkey.Id)\n\tkeyword := c.Query(\"keyword\")\n\ttokens, err := model.SearchUserTokens(userId, keyword)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    tokens,\n\t})\n\treturn\n}\n\nfunc GetToken(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tuserId := c.GetInt(ctxkey.Id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\ttoken, err := model.GetTokenByIds(id, userId)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    token,\n\t})\n\treturn\n}\n\nfunc GetTokenStatus(c *gin.Context) {\n\ttokenId := c.GetInt(ctxkey.TokenId)\n\tuserId := c.GetInt(ctxkey.Id)\n\ttoken, err := model.GetTokenByIds(tokenId, userId)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\texpiredAt := token.ExpiredTime\n\tif expiredAt == -1 {\n\t\texpiredAt = 0\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"object\":          \"credit_summary\",\n\t\t\"total_granted\":   token.RemainQuota,\n\t\t\"total_used\":      0, // not supported currently\n\t\t\"total_available\": token.RemainQuota,\n\t\t\"expires_at\":      expiredAt * 1000,\n\t})\n}\n\nfunc validateToken(c *gin.Context, token model.Token) error {\n\tif len(token.Name) > 30 {\n\t\treturn fmt.Errorf(\"令牌名称过长\")\n\t}\n\tif token.Subnet != nil && *token.Subnet != \"\" {\n\t\terr := network.IsValidSubnets(*token.Subnet)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"无效的网段：%s\", err.Error())\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc AddToken(c *gin.Context) {\n\ttoken := model.Token{}\n\terr := c.ShouldBindJSON(&token)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\terr = validateToken(c, token)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": fmt.Sprintf(\"参数错误：%s\", err.Error()),\n\t\t})\n\t\treturn\n\t}\n\n\tcleanToken := model.Token{\n\t\tUserId:         c.GetInt(ctxkey.Id),\n\t\tName:           token.Name,\n\t\tKey:            random.GenerateKey(),\n\t\tCreatedTime:    helper.GetTimestamp(),\n\t\tAccessedTime:   helper.GetTimestamp(),\n\t\tExpiredTime:    token.ExpiredTime,\n\t\tRemainQuota:    token.RemainQuota,\n\t\tUnlimitedQuota: token.UnlimitedQuota,\n\t\tModels:         token.Models,\n\t\tSubnet:         token.Subnet,\n\t}\n\terr = cleanToken.Insert()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    cleanToken,\n\t})\n\treturn\n}\n\nfunc DeleteToken(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tuserId := c.GetInt(ctxkey.Id)\n\terr := model.DeleteTokenById(id, userId)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc UpdateToken(c *gin.Context) {\n\tuserId := c.GetInt(ctxkey.Id)\n\tstatusOnly := c.Query(\"status_only\")\n\ttoken := model.Token{}\n\terr := c.ShouldBindJSON(&token)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\terr = validateToken(c, token)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": fmt.Sprintf(\"参数错误：%s\", err.Error()),\n\t\t})\n\t\treturn\n\t}\n\tcleanToken, err := model.GetTokenByIds(token.Id, userId)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif token.Status == model.TokenStatusEnabled {\n\t\tif cleanToken.Status == model.TokenStatusExpired && cleanToken.ExpiredTime <= helper.GetTimestamp() && cleanToken.ExpiredTime != -1 {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"令牌已过期，无法启用，请先修改令牌过期时间，或者设置为永不过期\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tif cleanToken.Status == model.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"令牌可用额度已用尽，无法启用，请先修改令牌剩余额度，或者设置为无限额度\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\tif statusOnly != \"\" {\n\t\tcleanToken.Status = token.Status\n\t} else {\n\t\t// If you add more fields, please also update token.Update()\n\t\tcleanToken.Name = token.Name\n\t\tcleanToken.ExpiredTime = token.ExpiredTime\n\t\tcleanToken.RemainQuota = token.RemainQuota\n\t\tcleanToken.UnlimitedQuota = token.UnlimitedQuota\n\t\tcleanToken.Models = token.Models\n\t\tcleanToken.Subnet = token.Subnet\n\t}\n\terr = cleanToken.Update()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    cleanToken,\n\t})\n\treturn\n}\n"
  },
  {
    "path": "controller/user.go",
    "content": "package controller\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/i18n\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\ntype LoginRequest struct {\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n}\n\nfunc Login(c *gin.Context) {\n\tif !config.PasswordLoginEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"管理员关闭了密码登录\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tvar loginRequest LoginRequest\n\terr := json.NewDecoder(c.Request.Body).Decode(&loginRequest)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tusername := loginRequest.Username\n\tpassword := loginRequest.Password\n\tif username == \"\" || password == \"\" {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\terr = user.ValidateAndFill()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": err.Error(),\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tSetupLogin(&user, c)\n}\n\n// setup session & cookies and then return user info\nfunc SetupLogin(user *model.User, c *gin.Context) {\n\tsession := sessions.Default(c)\n\tsession.Set(\"id\", user.Id)\n\tsession.Set(\"username\", user.Username)\n\tsession.Set(\"role\", user.Role)\n\tsession.Set(\"status\", user.Status)\n\terr := session.Save()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"无法保存会话信息，请重试\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tcleanUser := model.User{\n\t\tId:          user.Id,\n\t\tUsername:    user.Username,\n\t\tDisplayName: user.DisplayName,\n\t\tRole:        user.Role,\n\t\tStatus:      user.Status,\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"message\": \"\",\n\t\t\"success\": true,\n\t\t\"data\":    cleanUser,\n\t})\n}\n\nfunc Logout(c *gin.Context) {\n\tsession := sessions.Default(c)\n\tsession.Clear()\n\terr := session.Save()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": err.Error(),\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"message\": \"\",\n\t\t\"success\": true,\n\t})\n}\n\nfunc Register(c *gin.Context) {\n\tctx := c.Request.Context()\n\tif !config.RegisterEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"管理员关闭了新用户注册\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tif !config.PasswordRegisterEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"message\": \"管理员关闭了通过密码进行注册，请使用第三方账户验证的形式进行注册\",\n\t\t\t\"success\": false,\n\t\t})\n\t\treturn\n\t}\n\tvar user model.User\n\terr := json.NewDecoder(c.Request.Body).Decode(&user)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif err := common.Validate.Struct(&user); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_input\"),\n\t\t})\n\t\treturn\n\t}\n\tif config.EmailVerificationEnabled {\n\t\tif user.Email == \"\" || user.VerificationCode == \"\" {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"管理员开启了邮箱验证，请输入邮箱地址和验证码\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tif !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"验证码错误或已过期\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\taffCode := user.AffCode // this code is the inviter's code, not the user's own code\n\tinviterId, _ := model.GetUserIdByAffCode(affCode)\n\tcleanUser := model.User{\n\t\tUsername:    user.Username,\n\t\tPassword:    user.Password,\n\t\tDisplayName: user.Username,\n\t\tInviterId:   inviterId,\n\t}\n\tif config.EmailVerificationEnabled {\n\t\tcleanUser.Email = user.Email\n\t}\n\tif err := cleanUser.Insert(ctx, inviterId); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc GetAllUsers(c *gin.Context) {\n\tp, _ := strconv.Atoi(c.Query(\"p\"))\n\tif p < 0 {\n\t\tp = 0\n\t}\n\n\torder := c.DefaultQuery(\"order\", \"\")\n\tusers, err := model.GetAllUsers(p*config.ItemsPerPage, config.ItemsPerPage, order)\n\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    users,\n\t})\n}\n\nfunc SearchUsers(c *gin.Context) {\n\tkeyword := c.Query(\"keyword\")\n\tusers, err := model.SearchUsers(keyword)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    users,\n\t})\n\treturn\n}\n\nfunc GetUser(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser, err := model.GetUserById(id, false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tmyRole := c.GetInt(ctxkey.Role)\n\tif myRole <= user.Role && myRole != model.RoleRootUser {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无权获取同级或更高等级用户的信息\",\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    user,\n\t})\n\treturn\n}\n\nfunc GetUserDashboard(c *gin.Context) {\n\tid := c.GetInt(ctxkey.Id)\n\tnow := time.Now()\n\tstartOfDay := now.Truncate(24*time.Hour).AddDate(0, 0, -6).Unix()\n\tendOfDay := now.Truncate(24 * time.Hour).Add(24*time.Hour - time.Second).Unix()\n\n\tdashboards, err := model.SearchLogsByDayAndModel(id, int(startOfDay), int(endOfDay))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无法获取统计信息\",\n\t\t\t\"data\":    nil,\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    dashboards,\n\t})\n\treturn\n}\n\nfunc GenerateAccessToken(c *gin.Context) {\n\tid := c.GetInt(ctxkey.Id)\n\tuser, err := model.GetUserById(id, true)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser.AccessToken = random.GetUUID()\n\n\tif model.DB.Where(\"access_token = ?\", user.AccessToken).First(user).RowsAffected != 0 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"请重试，系统生成的 UUID 竟然重复了！\",\n\t\t})\n\t\treturn\n\t}\n\n\tif err := user.Update(false); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    user.AccessToken,\n\t})\n\treturn\n}\n\nfunc GetAffCode(c *gin.Context) {\n\tid := c.GetInt(ctxkey.Id)\n\tuser, err := model.GetUserById(id, true)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif user.AffCode == \"\" {\n\t\tuser.AffCode = random.GetRandomString(4)\n\t\tif err := user.Update(false); err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    user.AffCode,\n\t})\n\treturn\n}\n\nfunc GetSelf(c *gin.Context) {\n\tid := c.GetInt(ctxkey.Id)\n\tuser, err := model.GetUserById(id, false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    user,\n\t})\n\treturn\n}\n\nfunc UpdateUser(c *gin.Context) {\n\tctx := c.Request.Context()\n\tvar updatedUser model.User\n\terr := json.NewDecoder(c.Request.Body).Decode(&updatedUser)\n\tif err != nil || updatedUser.Id == 0 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif updatedUser.Password == \"\" {\n\t\tupdatedUser.Password = \"$I_LOVE_U\" // make Validator happy :)\n\t}\n\tif err := common.Validate.Struct(&updatedUser); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_input\"),\n\t\t})\n\t\treturn\n\t}\n\toriginUser, err := model.GetUserById(updatedUser.Id, false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tmyRole := c.GetInt(ctxkey.Role)\n\tif myRole <= originUser.Role && myRole != model.RoleRootUser {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无权更新同权限等级或更高权限等级的用户信息\",\n\t\t})\n\t\treturn\n\t}\n\tif myRole <= updatedUser.Role && myRole != model.RoleRootUser {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无权将其他用户权限等级提升到大于等于自己的权限等级\",\n\t\t})\n\t\treturn\n\t}\n\tif updatedUser.Password == \"$I_LOVE_U\" {\n\t\tupdatedUser.Password = \"\" // rollback to what it should be\n\t}\n\tupdatePassword := updatedUser.Password != \"\"\n\tif err := updatedUser.Update(updatePassword); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif originUser.Quota != updatedUser.Quota {\n\t\tmodel.RecordLog(ctx, originUser.Id, model.LogTypeManage, fmt.Sprintf(\"管理员将用户额度从 %s修改为 %s\", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota)))\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc UpdateSelf(c *gin.Context) {\n\tvar user model.User\n\terr := json.NewDecoder(c.Request.Body).Decode(&user)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif user.Password == \"\" {\n\t\tuser.Password = \"$I_LOVE_U\" // make Validator happy :)\n\t}\n\tif err := common.Validate.Struct(&user); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"输入不合法 \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tcleanUser := model.User{\n\t\tId:          c.GetInt(ctxkey.Id),\n\t\tUsername:    user.Username,\n\t\tPassword:    user.Password,\n\t\tDisplayName: user.DisplayName,\n\t}\n\tif user.Password == \"$I_LOVE_U\" {\n\t\tuser.Password = \"\" // rollback to what it should be\n\t\tcleanUser.Password = \"\"\n\t}\n\tupdatePassword := user.Password != \"\"\n\tif err := cleanUser.Update(updatePassword); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc DeleteUser(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\toriginUser, err := model.GetUserById(id, false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tmyRole := c.GetInt(\"role\")\n\tif myRole <= originUser.Role {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无权删除同权限等级或更高权限等级的用户\",\n\t\t})\n\t\treturn\n\t}\n\terr = model.DeleteUserById(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"message\": \"\",\n\t\t})\n\t\treturn\n\t}\n}\n\nfunc DeleteSelf(c *gin.Context) {\n\tid := c.GetInt(\"id\")\n\tuser, _ := model.GetUserById(id, false)\n\n\tif user.Role == model.RoleRootUser {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"不能删除超级管理员账户\",\n\t\t})\n\t\treturn\n\t}\n\n\terr := model.DeleteUserById(id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\nfunc CreateUser(c *gin.Context) {\n\tctx := c.Request.Context()\n\tvar user model.User\n\terr := json.NewDecoder(c.Request.Body).Decode(&user)\n\tif err != nil || user.Username == \"\" || user.Password == \"\" {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tif err := common.Validate.Struct(&user); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_input\"),\n\t\t})\n\t\treturn\n\t}\n\tif user.DisplayName == \"\" {\n\t\tuser.DisplayName = user.Username\n\t}\n\tmyRole := c.GetInt(\"role\")\n\tif user.Role >= myRole {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无法创建权限大于等于自己的用户\",\n\t\t})\n\t\treturn\n\t}\n\t// Even for admin users, we cannot fully trust them!\n\tcleanUser := model.User{\n\t\tUsername:    user.Username,\n\t\tPassword:    user.Password,\n\t\tDisplayName: user.DisplayName,\n\t}\n\tif err := cleanUser.Insert(ctx, 0); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\ntype ManageRequest struct {\n\tUsername string `json:\"username\"`\n\tAction   string `json:\"action\"`\n}\n\n// ManageUser Only admin user can do this\nfunc ManageUser(c *gin.Context) {\n\tvar req ManageRequest\n\terr := json.NewDecoder(c.Request.Body).Decode(&req)\n\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": i18n.Translate(c, \"invalid_parameter\"),\n\t\t})\n\t\treturn\n\t}\n\tuser := model.User{\n\t\tUsername: req.Username,\n\t}\n\t// Fill attributes\n\tmodel.DB.Where(&user).First(&user)\n\tif user.Id == 0 {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"用户不存在\",\n\t\t})\n\t\treturn\n\t}\n\tmyRole := c.GetInt(\"role\")\n\tif myRole <= user.Role && myRole != model.RoleRootUser {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无权更新同权限等级或更高权限等级的用户信息\",\n\t\t})\n\t\treturn\n\t}\n\tswitch req.Action {\n\tcase \"disable\":\n\t\tuser.Status = model.UserStatusDisabled\n\t\tif user.Role == model.RoleRootUser {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法禁用超级管理员用户\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\tcase \"enable\":\n\t\tuser.Status = model.UserStatusEnabled\n\tcase \"delete\":\n\t\tif user.Role == model.RoleRootUser {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法删除超级管理员用户\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tif err := user.Delete(); err != nil {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": err.Error(),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\tcase \"promote\":\n\t\tif myRole != model.RoleRootUser {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"普通管理员用户无法提升其他用户为管理员\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tif user.Role >= model.RoleAdminUser {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"该用户已经是管理员\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tuser.Role = model.RoleAdminUser\n\tcase \"demote\":\n\t\tif user.Role == model.RoleRootUser {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无法降级超级管理员用户\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tif user.Role == model.RoleCommonUser {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"该用户已经是普通用户\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tuser.Role = model.RoleCommonUser\n\t}\n\n\tif err := user.Update(false); err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tclearUser := model.User{\n\t\tRole:   user.Role,\n\t\tStatus: user.Status,\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    clearUser,\n\t})\n\treturn\n}\n\nfunc EmailBind(c *gin.Context) {\n\temail := c.Query(\"email\")\n\tcode := c.Query(\"code\")\n\tif !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"验证码错误或已过期\",\n\t\t})\n\t\treturn\n\t}\n\tid := c.GetInt(\"id\")\n\tuser := model.User{\n\t\tId: id,\n\t}\n\terr := user.FillUserById()\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tuser.Email = email\n\t// no need to check if this email already taken, because we have used verification code to check it\n\terr = user.Update(false)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif user.Role == model.RoleRootUser {\n\t\tconfig.RootUserEmail = email\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n\ntype topUpRequest struct {\n\tKey string `json:\"key\"`\n}\n\nfunc TopUp(c *gin.Context) {\n\tctx := c.Request.Context()\n\treq := topUpRequest{}\n\terr := c.ShouldBindJSON(&req)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tid := c.GetInt(\"id\")\n\tquota, err := model.Redeem(ctx, req.Key, id)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t\t\"data\":    quota,\n\t})\n\treturn\n}\n\ntype adminTopUpRequest struct {\n\tUserId int    `json:\"user_id\"`\n\tQuota  int    `json:\"quota\"`\n\tRemark string `json:\"remark\"`\n}\n\nfunc AdminTopUp(c *gin.Context) {\n\tctx := c.Request.Context()\n\treq := adminTopUpRequest{}\n\terr := c.ShouldBindJSON(&req)\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\terr = model.IncreaseUserQuota(req.UserId, int64(req.Quota))\n\tif err != nil {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\tif req.Remark == \"\" {\n\t\treq.Remark = fmt.Sprintf(\"通过 API 充值 %s\", common.LogQuota(int64(req.Quota)))\n\t}\n\tmodel.RecordTopupLog(ctx, req.UserId, req.Remark, req.Quota)\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"message\": \"\",\n\t})\n\treturn\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3.4'\n\nservices:\n  one-api:\n    image: \"${REGISTRY:-docker.io}/justsong/one-api:latest\"\n    container_name: one-api\n    restart: always\n    command: --log-dir /app/logs\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./data/oneapi:/data\n      - ./logs:/app/logs\n    environment:\n      - SQL_DSN=oneapi:123456@tcp(db:3306)/one-api  # 修改此行，或注释掉以使用 SQLite 作为数据库\n      - REDIS_CONN_STRING=redis://redis\n      - SESSION_SECRET=random_string  # 修改为随机字符串\n      - TZ=Asia/Shanghai\n#      - NODE_TYPE=slave  # 多机部署时从节点取消注释该行\n#      - SYNC_FREQUENCY=60  # 需要定期从数据库加载数据时取消注释该行\n#      - FRONTEND_BASE_URL=https://openai.justsong.cn  # 多机部署时从节点取消注释该行\n    depends_on:\n      - redis\n      - db\n    healthcheck:\n      test: [ \"CMD-SHELL\", \"wget -q -O - http://localhost:3000/api/status | grep -o '\\\"success\\\":\\\\s*true' | awk -F: '{print $2}'\" ]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n\n  redis:\n    image: \"${REGISTRY:-docker.io}/redis:latest\"\n    container_name: redis\n    restart: always\n\n  db:\n    image: \"${REGISTRY:-docker.io}/mysql:8.2.0\"\n    restart: always\n    container_name: mysql\n    volumes:\n      - ./data/mysql:/var/lib/mysql  # 挂载目录，持久化存储\n    ports:\n      - '3306:3306'\n    environment:\n      TZ: Asia/Shanghai   # 设置时区\n      MYSQL_ROOT_PASSWORD: 'OneAPI@justsong' # 设置 root 用户的密码\n      MYSQL_USER: oneapi   # 创建专用用户\n      MYSQL_PASSWORD: '123456'    # 设置专用用户密码\n      MYSQL_DATABASE: one-api   # 自动创建数据库"
  },
  {
    "path": "docs/API.md",
    "content": "# 使用 API 操控 & 扩展 One API\n> 欢迎提交 PR 在此放上你的拓展项目。\n\n例如，虽然 One API 本身没有直接支持支付，但是你可以通过系统扩展的 API 来实现支付功能。\n\n又或者你想自定义渠道管理策略，也可以通过 API 来实现渠道的禁用与启用。\n\n## 鉴权\nOne API 支持两种鉴权方式：Cookie 和 Token，对于 Token，参照下图获取：\n\n![image](https://github.com/songquanpeng/songquanpeng.github.io/assets/39998050/c15281a7-83ed-47cb-a1f6-913cb6bf4a7c)\n\n之后，将 Token 作为请求头的 Authorization 字段的值即可，例如下面使用 Token 调用测试渠道的 API：\n![image](https://github.com/songquanpeng/songquanpeng.github.io/assets/39998050/1273b7ae-cb60-4c0d-93a6-b1cbc039c4f8)\n\n## 请求格式与响应格式\nOne API 使用 JSON 格式进行请求和响应。\n\n对于响应体，一般格式如下：\n```json\n{\n  \"message\": \"请求信息\",\n  \"success\": true,\n  \"data\": {}\n}\n```\n\n## API 列表\n> 当前 API 列表不全，请自行通过浏览器抓取前端请求\n\n如果现有的 API 没有办法满足你的需求，欢迎提交 issue 讨论。\n\n### 获取当前登录用户信息\n**GET** `/api/user/self`\n\n### 为给定用户充值额度\n**POST** `/api/topup`\n```json\n{\n  \"user_id\": 1,\n  \"quota\": 100000,\n  \"remark\": \"充值 100000 额度\"\n}\n```\n\n## 其他\n### 充值链接上的附加参数\nOne API 会在用户点击充值按钮的时候，将用户的信息和充值信息附加在链接上，例如：\n`https://example.com?username=root&user_id=1&transaction_id=4b3eed80-55d5-443f-bd44-fb18c648c837`\n\n你可以通过解析链接上的参数来获取用户信息和充值信息，然后调用 API 来为用户充值。\n\n注意，不是所有主题都支持该功能，欢迎 PR 补齐。"
  },
  {
    "path": "go.mod",
    "content": "module github.com/songquanpeng/one-api\n\ngo 1.20\n\nrequire (\n\tcloud.google.com/go/iam v1.1.10\n\tgithub.com/aws/aws-sdk-go-v2 v1.27.0\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.17.15\n\tgithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.3\n\tgithub.com/gin-contrib/cors v1.7.2\n\tgithub.com/gin-contrib/gzip v1.0.1\n\tgithub.com/gin-contrib/sessions v1.0.1\n\tgithub.com/gin-contrib/static v1.1.2\n\tgithub.com/gin-gonic/gin v1.10.0\n\tgithub.com/go-playground/validator/v10 v10.20.0\n\tgithub.com/go-redis/redis/v8 v8.11.5\n\tgithub.com/golang-jwt/jwt v3.2.2+incompatible\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/websocket v1.5.1\n\tgithub.com/jinzhu/copier v0.4.0\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/patrickmn/go-cache v2.1.0+incompatible\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/pkoukk/tiktoken-go v0.1.7\n\tgithub.com/smartystreets/goconvey v1.8.1\n\tgithub.com/stretchr/testify v1.9.0\n\tgolang.org/x/crypto v0.31.0\n\tgolang.org/x/image v0.18.0\n\tgolang.org/x/sync v0.10.0\n\tgoogle.golang.org/api v0.187.0\n\tgorm.io/driver/mysql v1.5.6\n\tgorm.io/driver/postgres v1.5.7\n\tgorm.io/driver/sqlite v1.5.1\n\tgorm.io/gorm v1.25.10\n)\n\nrequire (\n\tcloud.google.com/go/auth v0.6.1 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect\n\tcloud.google.com/go/compute/metadata v0.3.0 // indirect\n\tfilippo.io/edwards25519 v1.1.0 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7 // indirect\n\tgithub.com/aws/smithy-go v1.20.2 // indirect\n\tgithub.com/bytedance/sonic v1.11.6 // indirect\n\tgithub.com/bytedance/sonic/loader v0.1.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.4 // indirect\n\tgithub.com/cloudwego/iasm v0.2.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/dlclark/regexp2 v1.11.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/fsnotify/fsnotify v1.7.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.3 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-logr/logr v1.4.1 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-sql-driver/mysql v1.8.1 // indirect\n\tgithub.com/goccy/go-json v0.10.3 // indirect\n\tgithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect\n\tgithub.com/golang/protobuf v1.5.4 // indirect\n\tgithub.com/google/s2a-go v0.1.7 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.12.5 // indirect\n\tgithub.com/gopherjs/gopherjs v1.17.2 // indirect\n\tgithub.com/gorilla/context v1.1.2 // indirect\n\tgithub.com/gorilla/securecookie v1.1.2 // indirect\n\tgithub.com/gorilla/sessions v1.2.2 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect\n\tgithub.com/jackc/pgx/v5 v5.5.5 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.1 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/jtolds/gls v4.20.0+incompatible // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.7 // indirect\n\tgithub.com/kr/text v0.2.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-sqlite3 v1.14.24 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.2 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/smarty/assertions v1.15.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.12 // indirect\n\tgo.opencensus.io v0.24.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect\n\tgo.opentelemetry.io/otel v1.24.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.24.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.24.0 // indirect\n\tgolang.org/x/arch v0.8.0 // indirect\n\tgolang.org/x/net v0.26.0 // indirect\n\tgolang.org/x/oauth2 v0.21.0 // indirect\n\tgolang.org/x/sys v0.28.0 // indirect\n\tgolang.org/x/text v0.21.0 // indirect\n\tgolang.org/x/time v0.5.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d // indirect\n\tgoogle.golang.org/grpc v1.64.1 // indirect\n\tgoogle.golang.org/protobuf v1.34.2 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go/auth v0.6.1 h1:T0Zw1XM5c1GlpN2HYr2s+m3vr1p2wy+8VN+Z1FKxW38=\ncloud.google.com/go/auth v0.6.1/go.mod h1:eFHG7zDzbXHKmjJddFG/rBlcGp6t25SwRUiEQSlO4x4=\ncloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=\ncloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=\ncloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=\ncloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=\ncloud.google.com/go/iam v1.1.10 h1:ZSAr64oEhQSClwBL670MsJAW5/RLiC6kfw3Bqmd5ZDI=\ncloud.google.com/go/iam v1.1.10/go.mod h1:iEgMq62sg8zx446GCaijmA2Miwg5o3UbO+nI47WHJps=\nfilippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/aws/aws-sdk-go-v2 v1.27.0 h1:7bZWKoXhzI+mMR/HjdMx8ZCC5+6fY0lS5tr0bbgiLlo=\ngithub.com/aws/aws-sdk-go-v2 v1.27.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.17.15 h1:YDexlvDRCA8ems2T5IP1xkMtOZ1uLJOCJdTr0igs5zo=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.17.15/go.mod h1:vxHggqW6hFNaeNC0WyXS3VdyjcV0a4KMUY4dKJ96buU=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7 h1:lf/8VTF2cM+N4SLzaYJERKEWAXq8MOMpZfU6wEPWsPk=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7/go.mod h1:4SjkU7QiqK2M9oozyMzfZ/23LmUY+h3oFqhdeP5OMiI=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7 h1:4OYVp0705xu8yjdyoWix0r9wPIRXnIzzOoUpQVHIJ/g=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7/go.mod h1:vd7ESTEvI76T2Na050gODNmNU7+OyKrIKroYTu4ABiI=\ngithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.3 h1:Fihjyd6DeNjcawBEGLH9dkIEUi6AdhucDKPE9nJ4QiY=\ngithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.3/go.mod h1:opvUj3ismqSCxYc+m4WIjPL0ewZGtvp0ess7cKvBPOQ=\ngithub.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=\ngithub.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=\ngithub.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=\ngithub.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=\ngithub.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=\ngithub.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=\ngithub.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=\ngithub.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=\ngithub.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=\ngithub.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=\ngithub.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=\ngithub.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=\ngithub.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4=\ngithub.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI=\ngithub.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=\ngithub.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=\ngithub.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=\ngithub.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=\ngithub.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=\ngithub.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=\ngithub.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=\ngithub.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=\ngithub.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=\ngithub.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=\ngithub.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=\ngithub.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=\ngithub.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=\ngithub.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=\ngithub.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=\ngithub.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=\ngithub.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=\ngithub.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=\ngithub.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=\ngithub.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=\ngithub.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=\ngithub.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=\ngithub.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=\ngithub.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=\ngithub.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=\ngithub.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=\ngithub.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=\ngithub.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=\ngithub.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=\ngithub.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=\ngithub.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=\ngithub.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=\ngithub.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=\ngithub.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=\ngithub.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=\ngithub.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=\ngithub.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=\ngithub.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=\ngithub.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=\ngithub.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=\ngithub.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=\ngithub.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=\ngithub.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=\ngithub.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=\ngithub.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=\ngithub.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=\ngithub.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngo.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=\ngo.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=\ngo.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=\ngo.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=\ngo.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=\ngo.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=\ngo.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=\ngo.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=\ngolang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=\ngolang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=\ngolang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=\ngolang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=\ngolang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=\ngolang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/api v0.187.0 h1:Mxs7VATVC2v7CY+7Xwm4ndkX71hpElcvx0D1Ji/p1eo=\ngoogle.golang.org/api v0.187.0/go.mod h1:KIHlTc4x7N7gKKuVsdmfBXN13yEEWXWFURWY6SBp2gk=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d h1:k3zyW3BYYR30e8v3x0bTDdE9vpYFjZHK+HcyqkrppWk=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=\ngoogle.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=\ngoogle.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=\ngoogle.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=\ngoogle.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=\ngoogle.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=\ngorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=\ngorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=\ngorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=\ngorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=\ngorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ=\ngorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=\ngorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=\ngorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-contrib/sessions/cookie\"\n\t\"github.com/gin-gonic/gin\"\n\t_ \"github.com/joho/godotenv/autoload\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/i18n\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/router\"\n)\n\n//go:embed web/build/*\nvar buildFS embed.FS\n\nfunc main() {\n\tcommon.Init()\n\tlogger.SetupLogger()\n\tlogger.SysLogf(\"One API %s started\", common.Version)\n\n\tif os.Getenv(\"GIN_MODE\") != gin.DebugMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\tif config.DebugEnabled {\n\t\tlogger.SysLog(\"running in debug mode\")\n\t}\n\n\t// Initialize SQL Database\n\tmodel.InitDB()\n\tmodel.InitLogDB()\n\n\tvar err error\n\terr = model.CreateRootAccountIfNeed()\n\tif err != nil {\n\t\tlogger.FatalLog(\"database init error: \" + err.Error())\n\t}\n\tdefer func() {\n\t\terr := model.CloseDB()\n\t\tif err != nil {\n\t\t\tlogger.FatalLog(\"failed to close database: \" + err.Error())\n\t\t}\n\t}()\n\n\t// Initialize Redis\n\terr = common.InitRedisClient()\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to initialize Redis: \" + err.Error())\n\t}\n\n\t// Initialize options\n\tmodel.InitOptionMap()\n\tlogger.SysLog(fmt.Sprintf(\"using theme %s\", config.Theme))\n\tif common.RedisEnabled {\n\t\t// for compatibility with old versions\n\t\tconfig.MemoryCacheEnabled = true\n\t}\n\tif config.MemoryCacheEnabled {\n\t\tlogger.SysLog(\"memory cache enabled\")\n\t\tlogger.SysLog(fmt.Sprintf(\"sync frequency: %d seconds\", config.SyncFrequency))\n\t\tmodel.InitChannelCache()\n\t}\n\tif config.MemoryCacheEnabled {\n\t\tgo model.SyncOptions(config.SyncFrequency)\n\t\tgo model.SyncChannelCache(config.SyncFrequency)\n\t}\n\tif os.Getenv(\"CHANNEL_TEST_FREQUENCY\") != \"\" {\n\t\tfrequency, err := strconv.Atoi(os.Getenv(\"CHANNEL_TEST_FREQUENCY\"))\n\t\tif err != nil {\n\t\t\tlogger.FatalLog(\"failed to parse CHANNEL_TEST_FREQUENCY: \" + err.Error())\n\t\t}\n\t\tgo controller.AutomaticallyTestChannels(frequency)\n\t}\n\tif os.Getenv(\"BATCH_UPDATE_ENABLED\") == \"true\" {\n\t\tconfig.BatchUpdateEnabled = true\n\t\tlogger.SysLog(\"batch update enabled with interval \" + strconv.Itoa(config.BatchUpdateInterval) + \"s\")\n\t\tmodel.InitBatchUpdater()\n\t}\n\tif config.EnableMetric {\n\t\tlogger.SysLog(\"metric enabled, will disable channel if too much request failed\")\n\t}\n\topenai.InitTokenEncoders()\n\tclient.Init()\n\n\t// Initialize i18n\n\tif err := i18n.Init(); err != nil {\n\t\tlogger.FatalLog(\"failed to initialize i18n: \" + err.Error())\n\t}\n\n\t// Initialize HTTP server\n\tserver := gin.New()\n\tserver.Use(gin.Recovery())\n\t// This will cause SSE not to work!!!\n\t//server.Use(gzip.Gzip(gzip.DefaultCompression))\n\tserver.Use(middleware.RequestId())\n\tserver.Use(middleware.Language())\n\tmiddleware.SetUpLogger(server)\n\t// Initialize session store\n\tstore := cookie.NewStore([]byte(config.SessionSecret))\n\tserver.Use(sessions.Sessions(\"session\", store))\n\n\trouter.SetRouter(server, buildFS)\n\tvar port = os.Getenv(\"PORT\")\n\tif port == \"\" {\n\t\tport = strconv.Itoa(*common.Port)\n\t}\n\tlogger.SysLogf(\"server started on http://localhost:%s\", port)\n\terr = server.Run(\":\" + port)\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to start HTTP server: \" + err.Error())\n\t}\n}\n"
  },
  {
    "path": "middleware/auth.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/blacklist\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/network\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc authHelper(c *gin.Context, minRole int) {\n\tsession := sessions.Default(c)\n\tusername := session.Get(\"username\")\n\trole := session.Get(\"role\")\n\tid := session.Get(\"id\")\n\tstatus := session.Get(\"status\")\n\tif username == nil {\n\t\t// Check access token\n\t\taccessToken := c.Request.Header.Get(\"Authorization\")\n\t\tif accessToken == \"\" {\n\t\t\tc.JSON(http.StatusUnauthorized, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无权进行此操作，未登录且未提供 access token\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t\tuser := model.ValidateAccessToken(accessToken)\n\t\tif user != nil && user.Username != \"\" {\n\t\t\t// Token is valid\n\t\t\tusername = user.Username\n\t\t\trole = user.Role\n\t\t\tid = user.Id\n\t\t\tstatus = user.Status\n\t\t} else {\n\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"message\": \"无权进行此操作，access token 无效\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t}\n\tif status.(int) == model.UserStatusDisabled || blacklist.IsUserBanned(id.(int)) {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"用户已被封禁\",\n\t\t})\n\t\tsession := sessions.Default(c)\n\t\tsession.Clear()\n\t\t_ = session.Save()\n\t\tc.Abort()\n\t\treturn\n\t}\n\tif role.(int) < minRole {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"message\": \"无权进行此操作，权限不足\",\n\t\t})\n\t\tc.Abort()\n\t\treturn\n\t}\n\tc.Set(\"username\", username)\n\tc.Set(\"role\", role)\n\tc.Set(\"id\", id)\n\tc.Next()\n}\n\nfunc UserAuth() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tauthHelper(c, model.RoleCommonUser)\n\t}\n}\n\nfunc AdminAuth() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tauthHelper(c, model.RoleAdminUser)\n\t}\n}\n\nfunc RootAuth() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tauthHelper(c, model.RoleRootUser)\n\t}\n}\n\nfunc TokenAuth() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tctx := c.Request.Context()\n\t\tkey := c.Request.Header.Get(\"Authorization\")\n\t\tkey = strings.TrimPrefix(key, \"Bearer \")\n\t\tkey = strings.TrimPrefix(key, \"sk-\")\n\t\tparts := strings.Split(key, \"-\")\n\t\tkey = parts[0]\n\t\ttoken, err := model.ValidateUserToken(key)\n\t\tif err != nil {\n\t\t\tabortWithMessage(c, http.StatusUnauthorized, err.Error())\n\t\t\treturn\n\t\t}\n\t\tif token.Subnet != nil && *token.Subnet != \"\" {\n\t\t\tif !network.IsIpInSubnets(ctx, c.ClientIP(), *token.Subnet) {\n\t\t\t\tabortWithMessage(c, http.StatusForbidden, fmt.Sprintf(\"该令牌只能在指定网段使用：%s，当前 ip：%s\", *token.Subnet, c.ClientIP()))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tuserEnabled, err := model.CacheIsUserEnabled(token.UserId)\n\t\tif err != nil {\n\t\t\tabortWithMessage(c, http.StatusInternalServerError, err.Error())\n\t\t\treturn\n\t\t}\n\t\tif !userEnabled || blacklist.IsUserBanned(token.UserId) {\n\t\t\tabortWithMessage(c, http.StatusForbidden, \"用户已被封禁\")\n\t\t\treturn\n\t\t}\n\t\trequestModel, err := getRequestModel(c)\n\t\tif err != nil && shouldCheckModel(c) {\n\t\t\tabortWithMessage(c, http.StatusBadRequest, err.Error())\n\t\t\treturn\n\t\t}\n\t\tc.Set(ctxkey.RequestModel, requestModel)\n\t\tif token.Models != nil && *token.Models != \"\" {\n\t\t\tc.Set(ctxkey.AvailableModels, *token.Models)\n\t\t\tif requestModel != \"\" && !isModelInList(requestModel, *token.Models) {\n\t\t\t\tabortWithMessage(c, http.StatusForbidden, fmt.Sprintf(\"该令牌无权使用模型：%s\", requestModel))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tc.Set(ctxkey.Id, token.UserId)\n\t\tc.Set(ctxkey.TokenId, token.Id)\n\t\tc.Set(ctxkey.TokenName, token.Name)\n\t\tif len(parts) > 1 {\n\t\t\tif model.IsAdmin(token.UserId) {\n\t\t\t\tc.Set(ctxkey.SpecificChannelId, parts[1])\n\t\t\t} else {\n\t\t\t\tabortWithMessage(c, http.StatusForbidden, \"普通用户不支持指定渠道\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// set channel id for proxy relay\n\t\tif channelId := c.Param(\"channelid\"); channelId != \"\" {\n\t\t\tc.Set(ctxkey.SpecificChannelId, channelId)\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\nfunc shouldCheckModel(c *gin.Context) bool {\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/completions\") {\n\t\treturn true\n\t}\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/chat/completions\") {\n\t\treturn true\n\t}\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/images\") {\n\t\treturn true\n\t}\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/audio\") {\n\t\treturn true\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "middleware/cache.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc Cache() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tif c.Request.RequestURI == \"/\" {\n\t\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\t} else {\n\t\t\tc.Header(\"Cache-Control\", \"max-age=604800\") // one week\n\t\t}\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "middleware/cors.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc CORS() gin.HandlerFunc {\n\tconfig := cors.DefaultConfig()\n\tconfig.AllowAllOrigins = true\n\tconfig.AllowCredentials = true\n\tconfig.AllowMethods = []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"}\n\tconfig.AllowHeaders = []string{\"*\"}\n\treturn cors.New(config)\n}\n"
  },
  {
    "path": "middleware/distributor.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n)\n\ntype ModelRequest struct {\n\tModel string `json:\"model\" form:\"model\"`\n}\n\nfunc Distribute() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tctx := c.Request.Context()\n\t\tuserId := c.GetInt(ctxkey.Id)\n\t\tuserGroup, _ := model.CacheGetUserGroup(userId)\n\t\tc.Set(ctxkey.Group, userGroup)\n\t\tvar requestModel string\n\t\tvar channel *model.Channel\n\t\tchannelId, ok := c.Get(ctxkey.SpecificChannelId)\n\t\tif ok {\n\t\t\tid, err := strconv.Atoi(channelId.(string))\n\t\t\tif err != nil {\n\t\t\t\tabortWithMessage(c, http.StatusBadRequest, \"无效的渠道 Id\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tchannel, err = model.GetChannelById(id, true)\n\t\t\tif err != nil {\n\t\t\t\tabortWithMessage(c, http.StatusBadRequest, \"无效的渠道 Id\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif channel.Status != model.ChannelStatusEnabled {\n\t\t\t\tabortWithMessage(c, http.StatusForbidden, \"该渠道已被禁用\")\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\trequestModel = c.GetString(ctxkey.RequestModel)\n\t\t\tvar err error\n\t\t\tchannel, err = model.CacheGetRandomSatisfiedChannel(userGroup, requestModel, false)\n\t\t\tif err != nil {\n\t\t\t\tmessage := fmt.Sprintf(\"当前分组 %s 下对于模型 %s 无可用渠道\", userGroup, requestModel)\n\t\t\t\tif channel != nil {\n\t\t\t\t\tlogger.SysError(fmt.Sprintf(\"渠道不存在：%d\", channel.Id))\n\t\t\t\t\tmessage = \"数据库一致性已被破坏，请联系管理员\"\n\t\t\t\t}\n\t\t\t\tabortWithMessage(c, http.StatusServiceUnavailable, message)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tlogger.Debugf(ctx, \"user id %d, user group: %s, request model: %s, using channel #%d\", userId, userGroup, requestModel, channel.Id)\n\t\tSetupContextForSelectedChannel(c, channel, requestModel)\n\t\tc.Next()\n\t}\n}\n\nfunc SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) {\n\tc.Set(ctxkey.Channel, channel.Type)\n\tc.Set(ctxkey.ChannelId, channel.Id)\n\tc.Set(ctxkey.ChannelName, channel.Name)\n\tif channel.SystemPrompt != nil && *channel.SystemPrompt != \"\" {\n\t\tc.Set(ctxkey.SystemPrompt, *channel.SystemPrompt)\n\t}\n\tc.Set(ctxkey.ModelMapping, channel.GetModelMapping())\n\tc.Set(ctxkey.OriginalModel, modelName) // for retry\n\tc.Request.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", channel.Key))\n\tc.Set(ctxkey.BaseURL, channel.GetBaseURL())\n\tcfg, _ := channel.LoadConfig()\n\t// this is for backward compatibility\n\tif channel.Other != nil {\n\t\tswitch channel.Type {\n\t\tcase channeltype.Azure:\n\t\t\tif cfg.APIVersion == \"\" {\n\t\t\t\tcfg.APIVersion = *channel.Other\n\t\t\t}\n\t\tcase channeltype.Xunfei:\n\t\t\tif cfg.APIVersion == \"\" {\n\t\t\t\tcfg.APIVersion = *channel.Other\n\t\t\t}\n\t\tcase channeltype.Gemini:\n\t\t\tif cfg.APIVersion == \"\" {\n\t\t\t\tcfg.APIVersion = *channel.Other\n\t\t\t}\n\t\tcase channeltype.AIProxyLibrary:\n\t\t\tif cfg.LibraryID == \"\" {\n\t\t\t\tcfg.LibraryID = *channel.Other\n\t\t\t}\n\t\tcase channeltype.Ali:\n\t\t\tif cfg.Plugin == \"\" {\n\t\t\t\tcfg.Plugin = *channel.Other\n\t\t\t}\n\t\t}\n\t}\n\tc.Set(ctxkey.Config, cfg)\n}\n"
  },
  {
    "path": "middleware/gzip.go",
    "content": "package middleware\n\nimport (\n\t\"compress/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc GzipDecodeMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif c.GetHeader(\"Content-Encoding\") == \"gzip\" {\n\t\t\tgzipReader, err := gzip.NewReader(c.Request.Body)\n\t\t\tif err != nil {\n\t\t\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer gzipReader.Close()\n\n\t\t\t// Replace the request body with the decompressed data\n\t\t\tc.Request.Body = io.NopCloser(gzipReader)\n\t\t}\n\n\t\t// Continue processing the request\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "middleware/language.go",
    "content": "package middleware\n\nimport (\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/i18n\"\n)\n\nfunc Language() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tlang := c.GetHeader(\"Accept-Language\")\n\t\tif lang == \"\" {\n\t\t\tlang = \"en\"\n\t\t}\n\t\tif strings.HasPrefix(strings.ToLower(lang), \"zh\") {\n\t\t\tlang = \"zh-CN\"\n\t\t} else {\n\t\t\tlang = \"en\"\n\t\t}\n\t\tc.Set(i18n.ContextKey, lang)\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "middleware/logger.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n)\n\nfunc SetUpLogger(server *gin.Engine) {\n\tserver.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {\n\t\tvar requestID string\n\t\tif param.Keys != nil {\n\t\t\trequestID = param.Keys[helper.RequestIdKey].(string)\n\t\t}\n\t\treturn fmt.Sprintf(\"[GIN] %s | %s | %3d | %13v | %15s | %7s %s\\n\",\n\t\t\tparam.TimeStamp.Format(\"2006/01/02 - 15:04:05\"),\n\t\t\trequestID,\n\t\t\tparam.StatusCode,\n\t\t\tparam.Latency,\n\t\t\tparam.ClientIP,\n\t\t\tparam.Method,\n\t\t\tparam.Path,\n\t\t)\n\t}))\n}\n"
  },
  {
    "path": "middleware/rate-limit.go",
    "content": "package middleware\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n)\n\nvar timeFormat = \"2006-01-02T15:04:05.000Z\"\n\nvar inMemoryRateLimiter common.InMemoryRateLimiter\n\nfunc redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {\n\tctx := context.Background()\n\trdb := common.RDB\n\tkey := \"rateLimit:\" + mark + c.ClientIP()\n\tlistLength, err := rdb.LLen(ctx, key).Result()\n\tif err != nil {\n\t\tfmt.Println(err.Error())\n\t\tc.Status(http.StatusInternalServerError)\n\t\tc.Abort()\n\t\treturn\n\t}\n\tif listLength < int64(maxRequestNum) {\n\t\trdb.LPush(ctx, key, time.Now().Format(timeFormat))\n\t\trdb.Expire(ctx, key, config.RateLimitKeyExpirationDuration)\n\t} else {\n\t\toldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()\n\t\toldTime, err := time.Parse(timeFormat, oldTimeStr)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tc.Status(http.StatusInternalServerError)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t\tnowTimeStr := time.Now().Format(timeFormat)\n\t\tnowTime, err := time.Parse(timeFormat, nowTimeStr)\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t\tc.Status(http.StatusInternalServerError)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t\t// time.Since will return negative number!\n\t\t// See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows\n\t\tif int64(nowTime.Sub(oldTime).Seconds()) < duration {\n\t\t\trdb.Expire(ctx, key, config.RateLimitKeyExpirationDuration)\n\t\t\tc.Status(http.StatusTooManyRequests)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t} else {\n\t\t\trdb.LPush(ctx, key, time.Now().Format(timeFormat))\n\t\t\trdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))\n\t\t\trdb.Expire(ctx, key, config.RateLimitKeyExpirationDuration)\n\t\t}\n\t}\n}\n\nfunc memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {\n\tkey := mark + c.ClientIP()\n\tif !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {\n\t\tc.Status(http.StatusTooManyRequests)\n\t\tc.Abort()\n\t\treturn\n\t}\n}\n\nfunc rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {\n\tif maxRequestNum == 0 || config.DebugEnabled {\n\t\treturn func(c *gin.Context) {\n\t\t\tc.Next()\n\t\t}\n\t}\n\tif common.RedisEnabled {\n\t\treturn func(c *gin.Context) {\n\t\t\tredisRateLimiter(c, maxRequestNum, duration, mark)\n\t\t}\n\t} else {\n\t\t// It's safe to call multi times.\n\t\tinMemoryRateLimiter.Init(config.RateLimitKeyExpirationDuration)\n\t\treturn func(c *gin.Context) {\n\t\t\tmemoryRateLimiter(c, maxRequestNum, duration, mark)\n\t\t}\n\t}\n}\n\nfunc GlobalWebRateLimit() func(c *gin.Context) {\n\treturn rateLimitFactory(config.GlobalWebRateLimitNum, config.GlobalWebRateLimitDuration, \"GW\")\n}\n\nfunc GlobalAPIRateLimit() func(c *gin.Context) {\n\treturn rateLimitFactory(config.GlobalApiRateLimitNum, config.GlobalApiRateLimitDuration, \"GA\")\n}\n\nfunc CriticalRateLimit() func(c *gin.Context) {\n\treturn rateLimitFactory(config.CriticalRateLimitNum, config.CriticalRateLimitDuration, \"CT\")\n}\n\nfunc DownloadRateLimit() func(c *gin.Context) {\n\treturn rateLimitFactory(config.DownloadRateLimitNum, config.DownloadRateLimitDuration, \"DW\")\n}\n\nfunc UploadRateLimit() func(c *gin.Context) {\n\treturn rateLimitFactory(config.UploadRateLimitNum, config.UploadRateLimitDuration, \"UP\")\n}\n"
  },
  {
    "path": "middleware/recover.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"net/http\"\n\t\"runtime/debug\"\n)\n\nfunc RelayPanicRecover() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tdefer func() {\n\t\t\tif err := recover(); err != nil {\n\t\t\t\tctx := c.Request.Context()\n\t\t\t\tlogger.Errorf(ctx, fmt.Sprintf(\"panic detected: %v\", err))\n\t\t\t\tlogger.Errorf(ctx, fmt.Sprintf(\"stacktrace from panic: %s\", string(debug.Stack())))\n\t\t\t\tlogger.Errorf(ctx, fmt.Sprintf(\"request: %s %s\", c.Request.Method, c.Request.URL.Path))\n\t\t\t\tbody, _ := common.GetRequestBody(c)\n\t\t\t\tlogger.Errorf(ctx, fmt.Sprintf(\"request body: %s\", string(body)))\n\t\t\t\tc.JSON(http.StatusInternalServerError, gin.H{\n\t\t\t\t\t\"error\": gin.H{\n\t\t\t\t\t\t\"message\": fmt.Sprintf(\"Panic detected, error: %v. Please submit an issue with the related log here: https://github.com/songquanpeng/one-api\", err),\n\t\t\t\t\t\t\"type\":    \"one_api_panic\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t\tc.Abort()\n\t\t\t}\n\t\t}()\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "middleware/request-id.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/helper\"\n)\n\nfunc RequestId() func(c *gin.Context) {\n\treturn func(c *gin.Context) {\n\t\tid := helper.GenRequestID()\n\t\tc.Set(helper.RequestIdKey, id)\n\t\tctx := helper.SetRequestID(c.Request.Context(), id)\n\t\tc.Request = c.Request.WithContext(ctx)\n\t\tc.Header(helper.RequestIdKey, id)\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "middleware/turnstile-check.go",
    "content": "package middleware\n\nimport (\n\t\"encoding/json\"\n\t\"github.com/gin-contrib/sessions\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"net/http\"\n\t\"net/url\"\n)\n\ntype turnstileCheckResponse struct {\n\tSuccess bool `json:\"success\"`\n}\n\nfunc TurnstileCheck() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif config.TurnstileCheckEnabled {\n\t\t\tsession := sessions.Default(c)\n\t\t\tturnstileChecked := session.Get(\"turnstile\")\n\t\t\tif turnstileChecked != nil {\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresponse := c.Query(\"turnstile\")\n\t\t\tif response == \"\" {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": \"Turnstile token 为空\",\n\t\t\t\t})\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t\trawRes, err := http.PostForm(\"https://challenges.cloudflare.com/turnstile/v0/siteverify\", url.Values{\n\t\t\t\t\"secret\":   {config.TurnstileSecretKey},\n\t\t\t\t\"response\": {response},\n\t\t\t\t\"remoteip\": {c.ClientIP()},\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(err.Error())\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": err.Error(),\n\t\t\t\t})\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer rawRes.Body.Close()\n\t\t\tvar res turnstileCheckResponse\n\t\t\terr = json.NewDecoder(rawRes.Body).Decode(&res)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(err.Error())\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": err.Error(),\n\t\t\t\t})\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !res.Success {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"message\": \"Turnstile 校验失败，请刷新重试！\",\n\t\t\t\t})\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsession.Set(\"turnstile\", true)\n\t\t\terr = session.Save()\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\t\t\"message\": \"无法保存会话信息，请重试\",\n\t\t\t\t\t\"success\": false,\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "middleware/utils.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"strings\"\n)\n\nfunc abortWithMessage(c *gin.Context, statusCode int, message string) {\n\tc.JSON(statusCode, gin.H{\n\t\t\"error\": gin.H{\n\t\t\t\"message\": helper.MessageWithRequestId(message, c.GetString(helper.RequestIdKey)),\n\t\t\t\"type\":    \"one_api_error\",\n\t\t},\n\t})\n\tc.Abort()\n\tlogger.Error(c.Request.Context(), message)\n}\n\nfunc getRequestModel(c *gin.Context) (string, error) {\n\tvar modelRequest ModelRequest\n\terr := common.UnmarshalBodyReusable(c, &modelRequest)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"common.UnmarshalBodyReusable failed: %w\", err)\n\t}\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/moderations\") {\n\t\tif modelRequest.Model == \"\" {\n\t\t\tmodelRequest.Model = \"text-moderation-stable\"\n\t\t}\n\t}\n\tif strings.HasSuffix(c.Request.URL.Path, \"embeddings\") {\n\t\tif modelRequest.Model == \"\" {\n\t\t\tmodelRequest.Model = c.Param(\"model\")\n\t\t}\n\t}\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/images/generations\") {\n\t\tif modelRequest.Model == \"\" {\n\t\t\tmodelRequest.Model = \"dall-e-2\"\n\t\t}\n\t}\n\tif strings.HasPrefix(c.Request.URL.Path, \"/v1/audio/transcriptions\") || strings.HasPrefix(c.Request.URL.Path, \"/v1/audio/translations\") {\n\t\tif modelRequest.Model == \"\" {\n\t\t\tmodelRequest.Model = \"whisper-1\"\n\t\t}\n\t}\n\treturn modelRequest.Model, nil\n}\n\nfunc isModelInList(modelName string, models string) bool {\n\tmodelList := strings.Split(models, \",\")\n\tfor _, model := range modelList {\n\t\tif modelName == model {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "model/ability.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/utils\"\n)\n\ntype Ability struct {\n\tGroup     string `json:\"group\" gorm:\"type:varchar(32);primaryKey;autoIncrement:false\"`\n\tModel     string `json:\"model\" gorm:\"primaryKey;autoIncrement:false\"`\n\tChannelId int    `json:\"channel_id\" gorm:\"primaryKey;autoIncrement:false;index\"`\n\tEnabled   bool   `json:\"enabled\"`\n\tPriority  *int64 `json:\"priority\" gorm:\"bigint;default:0;index\"`\n}\n\nfunc GetRandomSatisfiedChannel(group string, model string, ignoreFirstPriority bool) (*Channel, error) {\n\tability := Ability{}\n\tgroupCol := \"`group`\"\n\ttrueVal := \"1\"\n\tif common.UsingPostgreSQL {\n\t\tgroupCol = `\"group\"`\n\t\ttrueVal = \"true\"\n\t}\n\n\tvar err error = nil\n\tvar channelQuery *gorm.DB\n\tif ignoreFirstPriority {\n\t\tchannelQuery = DB.Where(groupCol+\" = ? and model = ? and enabled = \"+trueVal, group, model)\n\t} else {\n\t\tmaxPrioritySubQuery := DB.Model(&Ability{}).Select(\"MAX(priority)\").Where(groupCol+\" = ? and model = ? and enabled = \"+trueVal, group, model)\n\t\tchannelQuery = DB.Where(groupCol+\" = ? and model = ? and enabled = \"+trueVal+\" and priority = (?)\", group, model, maxPrioritySubQuery)\n\t}\n\tif common.UsingSQLite || common.UsingPostgreSQL {\n\t\terr = channelQuery.Order(\"RANDOM()\").First(&ability).Error\n\t} else {\n\t\terr = channelQuery.Order(\"RAND()\").First(&ability).Error\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tchannel := Channel{}\n\tchannel.Id = ability.ChannelId\n\terr = DB.First(&channel, \"id = ?\", ability.ChannelId).Error\n\treturn &channel, err\n}\n\nfunc (channel *Channel) AddAbilities() error {\n\tmodels_ := strings.Split(channel.Models, \",\")\n\tmodels_ = utils.DeDuplication(models_)\n\tgroups_ := strings.Split(channel.Group, \",\")\n\tabilities := make([]Ability, 0, len(models_))\n\tfor _, model := range models_ {\n\t\tfor _, group := range groups_ {\n\t\t\tability := Ability{\n\t\t\t\tGroup:     group,\n\t\t\t\tModel:     model,\n\t\t\t\tChannelId: channel.Id,\n\t\t\t\tEnabled:   channel.Status == ChannelStatusEnabled,\n\t\t\t\tPriority:  channel.Priority,\n\t\t\t}\n\t\t\tabilities = append(abilities, ability)\n\t\t}\n\t}\n\treturn DB.Create(&abilities).Error\n}\n\nfunc (channel *Channel) DeleteAbilities() error {\n\treturn DB.Where(\"channel_id = ?\", channel.Id).Delete(&Ability{}).Error\n}\n\n// UpdateAbilities updates abilities of this channel.\n// Make sure the channel is completed before calling this function.\nfunc (channel *Channel) UpdateAbilities() error {\n\t// A quick and dirty way to update abilities\n\t// First delete all abilities of this channel\n\terr := channel.DeleteAbilities()\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Then add new abilities\n\terr = channel.AddAbilities()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc UpdateAbilityStatus(channelId int, status bool) error {\n\treturn DB.Model(&Ability{}).Where(\"channel_id = ?\", channelId).Select(\"enabled\").Update(\"enabled\", status).Error\n}\n\nfunc GetGroupModels(ctx context.Context, group string) ([]string, error) {\n\tgroupCol := \"`group`\"\n\ttrueVal := \"1\"\n\tif common.UsingPostgreSQL {\n\t\tgroupCol = `\"group\"`\n\t\ttrueVal = \"true\"\n\t}\n\tvar models []string\n\terr := DB.Model(&Ability{}).Distinct(\"model\").Where(groupCol+\" = ? and enabled = \"+trueVal, group).Pluck(\"model\", &models).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsort.Strings(models)\n\treturn models, err\n}\n"
  },
  {
    "path": "model/cache.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"math/rand\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n)\n\nvar (\n\tTokenCacheSeconds         = config.SyncFrequency\n\tUserId2GroupCacheSeconds  = config.SyncFrequency\n\tUserId2QuotaCacheSeconds  = config.SyncFrequency\n\tUserId2StatusCacheSeconds = config.SyncFrequency\n\tGroupModelsCacheSeconds   = config.SyncFrequency\n)\n\nfunc CacheGetTokenByKey(key string) (*Token, error) {\n\tkeyCol := \"`key`\"\n\tif common.UsingPostgreSQL {\n\t\tkeyCol = `\"key\"`\n\t}\n\tvar token Token\n\tif !common.RedisEnabled {\n\t\terr := DB.Where(keyCol+\" = ?\", key).First(&token).Error\n\t\treturn &token, err\n\t}\n\ttokenObjectString, err := common.RedisGet(fmt.Sprintf(\"token:%s\", key))\n\tif err != nil {\n\t\terr := DB.Where(keyCol+\" = ?\", key).First(&token).Error\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tjsonBytes, err := json.Marshal(token)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\terr = common.RedisSet(fmt.Sprintf(\"token:%s\", key), string(jsonBytes), time.Duration(TokenCacheSeconds)*time.Second)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"Redis set token error: \" + err.Error())\n\t\t}\n\t\treturn &token, nil\n\t}\n\terr = json.Unmarshal([]byte(tokenObjectString), &token)\n\treturn &token, err\n}\n\nfunc CacheGetUserGroup(id int) (group string, err error) {\n\tif !common.RedisEnabled {\n\t\treturn GetUserGroup(id)\n\t}\n\tgroup, err = common.RedisGet(fmt.Sprintf(\"user_group:%d\", id))\n\tif err != nil {\n\t\tgroup, err = GetUserGroup(id)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\terr = common.RedisSet(fmt.Sprintf(\"user_group:%d\", id), group, time.Duration(UserId2GroupCacheSeconds)*time.Second)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"Redis set user group error: \" + err.Error())\n\t\t}\n\t}\n\treturn group, err\n}\n\nfunc fetchAndUpdateUserQuota(ctx context.Context, id int) (quota int64, err error) {\n\tquota, err = GetUserQuota(id)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\terr = common.RedisSet(fmt.Sprintf(\"user_quota:%d\", id), fmt.Sprintf(\"%d\", quota), time.Duration(UserId2QuotaCacheSeconds)*time.Second)\n\tif err != nil {\n\t\tlogger.Error(ctx, \"Redis set user quota error: \"+err.Error())\n\t}\n\treturn\n}\n\nfunc CacheGetUserQuota(ctx context.Context, id int) (quota int64, err error) {\n\tif !common.RedisEnabled {\n\t\treturn GetUserQuota(id)\n\t}\n\tquotaString, err := common.RedisGet(fmt.Sprintf(\"user_quota:%d\", id))\n\tif err != nil {\n\t\treturn fetchAndUpdateUserQuota(ctx, id)\n\t}\n\tquota, err = strconv.ParseInt(quotaString, 10, 64)\n\tif err != nil {\n\t\treturn 0, nil\n\t}\n\tif quota <= config.PreConsumedQuota { // when user's quota is less than pre-consumed quota, we need to fetch from db\n\t\tlogger.Infof(ctx, \"user %d's cached quota is too low: %d, refreshing from db\", quota, id)\n\t\treturn fetchAndUpdateUserQuota(ctx, id)\n\t}\n\treturn quota, nil\n}\n\nfunc CacheUpdateUserQuota(ctx context.Context, id int) error {\n\tif !common.RedisEnabled {\n\t\treturn nil\n\t}\n\tquota, err := CacheGetUserQuota(ctx, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = common.RedisSet(fmt.Sprintf(\"user_quota:%d\", id), fmt.Sprintf(\"%d\", quota), time.Duration(UserId2QuotaCacheSeconds)*time.Second)\n\treturn err\n}\n\nfunc CacheDecreaseUserQuota(id int, quota int64) error {\n\tif !common.RedisEnabled {\n\t\treturn nil\n\t}\n\terr := common.RedisDecrease(fmt.Sprintf(\"user_quota:%d\", id), int64(quota))\n\treturn err\n}\n\nfunc CacheIsUserEnabled(userId int) (bool, error) {\n\tif !common.RedisEnabled {\n\t\treturn IsUserEnabled(userId)\n\t}\n\tenabled, err := common.RedisGet(fmt.Sprintf(\"user_enabled:%d\", userId))\n\tif err == nil {\n\t\treturn enabled == \"1\", nil\n\t}\n\n\tuserEnabled, err := IsUserEnabled(userId)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tenabled = \"0\"\n\tif userEnabled {\n\t\tenabled = \"1\"\n\t}\n\terr = common.RedisSet(fmt.Sprintf(\"user_enabled:%d\", userId), enabled, time.Duration(UserId2StatusCacheSeconds)*time.Second)\n\tif err != nil {\n\t\tlogger.SysError(\"Redis set user enabled error: \" + err.Error())\n\t}\n\treturn userEnabled, err\n}\n\nfunc CacheGetGroupModels(ctx context.Context, group string) ([]string, error) {\n\tif !common.RedisEnabled {\n\t\treturn GetGroupModels(ctx, group)\n\t}\n\tmodelsStr, err := common.RedisGet(fmt.Sprintf(\"group_models:%s\", group))\n\tif err == nil {\n\t\treturn strings.Split(modelsStr, \",\"), nil\n\t}\n\tmodels, err := GetGroupModels(ctx, group)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = common.RedisSet(fmt.Sprintf(\"group_models:%s\", group), strings.Join(models, \",\"), time.Duration(GroupModelsCacheSeconds)*time.Second)\n\tif err != nil {\n\t\tlogger.SysError(\"Redis set group models error: \" + err.Error())\n\t}\n\treturn models, nil\n}\n\nvar group2model2channels map[string]map[string][]*Channel\nvar channelSyncLock sync.RWMutex\n\nfunc InitChannelCache() {\n\tnewChannelId2channel := make(map[int]*Channel)\n\tvar channels []*Channel\n\tDB.Where(\"status = ?\", ChannelStatusEnabled).Find(&channels)\n\tfor _, channel := range channels {\n\t\tnewChannelId2channel[channel.Id] = channel\n\t}\n\tvar abilities []*Ability\n\tDB.Find(&abilities)\n\tgroups := make(map[string]bool)\n\tfor _, ability := range abilities {\n\t\tgroups[ability.Group] = true\n\t}\n\tnewGroup2model2channels := make(map[string]map[string][]*Channel)\n\tfor group := range groups {\n\t\tnewGroup2model2channels[group] = make(map[string][]*Channel)\n\t}\n\tfor _, channel := range channels {\n\t\tgroups := strings.Split(channel.Group, \",\")\n\t\tfor _, group := range groups {\n\t\t\tmodels := strings.Split(channel.Models, \",\")\n\t\t\tfor _, model := range models {\n\t\t\t\tif _, ok := newGroup2model2channels[group][model]; !ok {\n\t\t\t\t\tnewGroup2model2channels[group][model] = make([]*Channel, 0)\n\t\t\t\t}\n\t\t\t\tnewGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel)\n\t\t\t}\n\t\t}\n\t}\n\n\t// sort by priority\n\tfor group, model2channels := range newGroup2model2channels {\n\t\tfor model, channels := range model2channels {\n\t\t\tsort.Slice(channels, func(i, j int) bool {\n\t\t\t\treturn channels[i].GetPriority() > channels[j].GetPriority()\n\t\t\t})\n\t\t\tnewGroup2model2channels[group][model] = channels\n\t\t}\n\t}\n\n\tchannelSyncLock.Lock()\n\tgroup2model2channels = newGroup2model2channels\n\tchannelSyncLock.Unlock()\n\tlogger.SysLog(\"channels synced from database\")\n}\n\nfunc SyncChannelCache(frequency int) {\n\tfor {\n\t\ttime.Sleep(time.Duration(frequency) * time.Second)\n\t\tlogger.SysLog(\"syncing channels from database\")\n\t\tInitChannelCache()\n\t}\n}\n\nfunc CacheGetRandomSatisfiedChannel(group string, model string, ignoreFirstPriority bool) (*Channel, error) {\n\tif !config.MemoryCacheEnabled {\n\t\treturn GetRandomSatisfiedChannel(group, model, ignoreFirstPriority)\n\t}\n\tchannelSyncLock.RLock()\n\tdefer channelSyncLock.RUnlock()\n\tchannels := group2model2channels[group][model]\n\tif len(channels) == 0 {\n\t\treturn nil, errors.New(\"channel not found\")\n\t}\n\tendIdx := len(channels)\n\t// choose by priority\n\tfirstChannel := channels[0]\n\tif firstChannel.GetPriority() > 0 {\n\t\tfor i := range channels {\n\t\t\tif channels[i].GetPriority() != firstChannel.GetPriority() {\n\t\t\t\tendIdx = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tidx := rand.Intn(endIdx)\n\tif ignoreFirstPriority {\n\t\tif endIdx < len(channels) { // which means there are more than one priority\n\t\t\tidx = random.RandRange(endIdx, len(channels))\n\t\t}\n\t}\n\treturn channels[idx], nil\n}\n"
  },
  {
    "path": "model/channel.go",
    "content": "package model\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"gorm.io/gorm\"\n)\n\nconst (\n\tChannelStatusUnknown          = 0\n\tChannelStatusEnabled          = 1 // don't use 0, 0 is the default value!\n\tChannelStatusManuallyDisabled = 2 // also don't use 0\n\tChannelStatusAutoDisabled     = 3\n)\n\ntype Channel struct {\n\tId                 int     `json:\"id\"`\n\tType               int     `json:\"type\" gorm:\"default:0\"`\n\tKey                string  `json:\"key\" gorm:\"type:text\"`\n\tStatus             int     `json:\"status\" gorm:\"default:1\"`\n\tName               string  `json:\"name\" gorm:\"index\"`\n\tWeight             *uint   `json:\"weight\" gorm:\"default:0\"`\n\tCreatedTime        int64   `json:\"created_time\" gorm:\"bigint\"`\n\tTestTime           int64   `json:\"test_time\" gorm:\"bigint\"`\n\tResponseTime       int     `json:\"response_time\"` // in milliseconds\n\tBaseURL            *string `json:\"base_url\" gorm:\"column:base_url;default:''\"`\n\tOther              *string `json:\"other\"`   // DEPRECATED: please save config to field Config\n\tBalance            float64 `json:\"balance\"` // in USD\n\tBalanceUpdatedTime int64   `json:\"balance_updated_time\" gorm:\"bigint\"`\n\tModels             string  `json:\"models\"`\n\tGroup              string  `json:\"group\" gorm:\"type:varchar(32);default:'default'\"`\n\tUsedQuota          int64   `json:\"used_quota\" gorm:\"bigint;default:0\"`\n\tModelMapping       *string `json:\"model_mapping\" gorm:\"type:varchar(1024);default:''\"`\n\tPriority           *int64  `json:\"priority\" gorm:\"bigint;default:0\"`\n\tConfig             string  `json:\"config\"`\n\tSystemPrompt       *string `json:\"system_prompt\" gorm:\"type:text\"`\n}\n\ntype ChannelConfig struct {\n\tRegion            string `json:\"region,omitempty\"`\n\tSK                string `json:\"sk,omitempty\"`\n\tAK                string `json:\"ak,omitempty\"`\n\tUserID            string `json:\"user_id,omitempty\"`\n\tAPIVersion        string `json:\"api_version,omitempty\"`\n\tLibraryID         string `json:\"library_id,omitempty\"`\n\tPlugin            string `json:\"plugin,omitempty\"`\n\tVertexAIProjectID string `json:\"vertex_ai_project_id,omitempty\"`\n\tVertexAIADC       string `json:\"vertex_ai_adc,omitempty\"`\n}\n\nfunc GetAllChannels(startIdx int, num int, scope string) ([]*Channel, error) {\n\tvar channels []*Channel\n\tvar err error\n\tswitch scope {\n\tcase \"all\":\n\t\terr = DB.Order(\"id desc\").Find(&channels).Error\n\tcase \"disabled\":\n\t\terr = DB.Order(\"id desc\").Where(\"status = ? or status = ?\", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled).Find(&channels).Error\n\tdefault:\n\t\terr = DB.Order(\"id desc\").Limit(num).Offset(startIdx).Omit(\"key\").Find(&channels).Error\n\t}\n\treturn channels, err\n}\n\nfunc SearchChannels(keyword string) (channels []*Channel, err error) {\n\terr = DB.Omit(\"key\").Where(\"id = ? or name LIKE ?\", helper.String2Int(keyword), keyword+\"%\").Find(&channels).Error\n\treturn channels, err\n}\n\nfunc GetChannelById(id int, selectAll bool) (*Channel, error) {\n\tchannel := Channel{Id: id}\n\tvar err error = nil\n\tif selectAll {\n\t\terr = DB.First(&channel, \"id = ?\", id).Error\n\t} else {\n\t\terr = DB.Omit(\"key\").First(&channel, \"id = ?\", id).Error\n\t}\n\treturn &channel, err\n}\n\nfunc BatchInsertChannels(channels []Channel) error {\n\tvar err error\n\terr = DB.Create(&channels).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, channel_ := range channels {\n\t\terr = channel_.AddAbilities()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (channel *Channel) GetPriority() int64 {\n\tif channel.Priority == nil {\n\t\treturn 0\n\t}\n\treturn *channel.Priority\n}\n\nfunc (channel *Channel) GetBaseURL() string {\n\tif channel.BaseURL == nil {\n\t\treturn \"\"\n\t}\n\treturn *channel.BaseURL\n}\n\nfunc (channel *Channel) GetModelMapping() map[string]string {\n\tif channel.ModelMapping == nil || *channel.ModelMapping == \"\" || *channel.ModelMapping == \"{}\" {\n\t\treturn nil\n\t}\n\tmodelMapping := make(map[string]string)\n\terr := json.Unmarshal([]byte(*channel.ModelMapping), &modelMapping)\n\tif err != nil {\n\t\tlogger.SysError(fmt.Sprintf(\"failed to unmarshal model mapping for channel %d, error: %s\", channel.Id, err.Error()))\n\t\treturn nil\n\t}\n\treturn modelMapping\n}\n\nfunc (channel *Channel) Insert() error {\n\tvar err error\n\terr = DB.Create(channel).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = channel.AddAbilities()\n\treturn err\n}\n\nfunc (channel *Channel) Update() error {\n\tvar err error\n\terr = DB.Model(channel).Updates(channel).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tDB.Model(channel).First(channel, \"id = ?\", channel.Id)\n\terr = channel.UpdateAbilities()\n\treturn err\n}\n\nfunc (channel *Channel) UpdateResponseTime(responseTime int64) {\n\terr := DB.Model(channel).Select(\"response_time\", \"test_time\").Updates(Channel{\n\t\tTestTime:     helper.GetTimestamp(),\n\t\tResponseTime: int(responseTime),\n\t}).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update response time: \" + err.Error())\n\t}\n}\n\nfunc (channel *Channel) UpdateBalance(balance float64) {\n\terr := DB.Model(channel).Select(\"balance_updated_time\", \"balance\").Updates(Channel{\n\t\tBalanceUpdatedTime: helper.GetTimestamp(),\n\t\tBalance:            balance,\n\t}).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update balance: \" + err.Error())\n\t}\n}\n\nfunc (channel *Channel) Delete() error {\n\tvar err error\n\terr = DB.Delete(channel).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = channel.DeleteAbilities()\n\treturn err\n}\n\nfunc (channel *Channel) LoadConfig() (ChannelConfig, error) {\n\tvar cfg ChannelConfig\n\tif channel.Config == \"\" {\n\t\treturn cfg, nil\n\t}\n\terr := json.Unmarshal([]byte(channel.Config), &cfg)\n\tif err != nil {\n\t\treturn cfg, err\n\t}\n\treturn cfg, nil\n}\n\nfunc UpdateChannelStatusById(id int, status int) {\n\terr := UpdateAbilityStatus(id, status == ChannelStatusEnabled)\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update ability status: \" + err.Error())\n\t}\n\terr = DB.Model(&Channel{}).Where(\"id = ?\", id).Update(\"status\", status).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update channel status: \" + err.Error())\n\t}\n}\n\nfunc UpdateChannelUsedQuota(id int, quota int64) {\n\tif config.BatchUpdateEnabled {\n\t\taddNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota)\n\t\treturn\n\t}\n\tupdateChannelUsedQuota(id, quota)\n}\n\nfunc updateChannelUsedQuota(id int, quota int64) {\n\terr := DB.Model(&Channel{}).Where(\"id = ?\", id).Update(\"used_quota\", gorm.Expr(\"used_quota + ?\", quota)).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update channel used quota: \" + err.Error())\n\t}\n}\n\nfunc DeleteChannelByStatus(status int64) (int64, error) {\n\tresult := DB.Where(\"status = ?\", status).Delete(&Channel{})\n\treturn result.RowsAffected, result.Error\n}\n\nfunc DeleteDisabledChannel() (int64, error) {\n\tresult := DB.Where(\"status = ? or status = ?\", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled).Delete(&Channel{})\n\treturn result.RowsAffected, result.Error\n}\n"
  },
  {
    "path": "model/log.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n)\n\ntype Log struct {\n\tId                int    `json:\"id\"`\n\tUserId            int    `json:\"user_id\" gorm:\"index\"`\n\tCreatedAt         int64  `json:\"created_at\" gorm:\"bigint;index:idx_created_at_type\"`\n\tType              int    `json:\"type\" gorm:\"index:idx_created_at_type\"`\n\tContent           string `json:\"content\"`\n\tUsername          string `json:\"username\" gorm:\"index:index_username_model_name,priority:2;default:''\"`\n\tTokenName         string `json:\"token_name\" gorm:\"index;default:''\"`\n\tModelName         string `json:\"model_name\" gorm:\"index;index:index_username_model_name,priority:1;default:''\"`\n\tQuota             int    `json:\"quota\" gorm:\"default:0\"`\n\tPromptTokens      int    `json:\"prompt_tokens\" gorm:\"default:0\"`\n\tCompletionTokens  int    `json:\"completion_tokens\" gorm:\"default:0\"`\n\tChannelId         int    `json:\"channel\" gorm:\"index\"`\n\tRequestId         string `json:\"request_id\" gorm:\"default:''\"`\n\tElapsedTime       int64  `json:\"elapsed_time\" gorm:\"default:0\"` // unit is ms\n\tIsStream          bool   `json:\"is_stream\" gorm:\"default:false\"`\n\tSystemPromptReset bool   `json:\"system_prompt_reset\" gorm:\"default:false\"`\n}\n\nconst (\n\tLogTypeUnknown = iota\n\tLogTypeTopup\n\tLogTypeConsume\n\tLogTypeManage\n\tLogTypeSystem\n\tLogTypeTest\n)\n\nfunc recordLogHelper(ctx context.Context, log *Log) {\n\trequestId := helper.GetRequestID(ctx)\n\tlog.RequestId = requestId\n\terr := LOG_DB.Create(log).Error\n\tif err != nil {\n\t\tlogger.Error(ctx, \"failed to record log: \"+err.Error())\n\t\treturn\n\t}\n\tlogger.Infof(ctx, \"record log: %+v\", log)\n}\n\nfunc RecordLog(ctx context.Context, userId int, logType int, content string) {\n\tif logType == LogTypeConsume && !config.LogConsumeEnabled {\n\t\treturn\n\t}\n\tlog := &Log{\n\t\tUserId:    userId,\n\t\tUsername:  GetUsernameById(userId),\n\t\tCreatedAt: helper.GetTimestamp(),\n\t\tType:      logType,\n\t\tContent:   content,\n\t}\n\trecordLogHelper(ctx, log)\n}\n\nfunc RecordTopupLog(ctx context.Context, userId int, content string, quota int) {\n\tlog := &Log{\n\t\tUserId:    userId,\n\t\tUsername:  GetUsernameById(userId),\n\t\tCreatedAt: helper.GetTimestamp(),\n\t\tType:      LogTypeTopup,\n\t\tContent:   content,\n\t\tQuota:     quota,\n\t}\n\trecordLogHelper(ctx, log)\n}\n\nfunc RecordConsumeLog(ctx context.Context, log *Log) {\n\tif !config.LogConsumeEnabled {\n\t\treturn\n\t}\n\tlog.Username = GetUsernameById(log.UserId)\n\tlog.CreatedAt = helper.GetTimestamp()\n\tlog.Type = LogTypeConsume\n\trecordLogHelper(ctx, log)\n}\n\nfunc RecordTestLog(ctx context.Context, log *Log) {\n\tlog.CreatedAt = helper.GetTimestamp()\n\tlog.Type = LogTypeTest\n\trecordLogHelper(ctx, log)\n}\n\nfunc GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int) (logs []*Log, err error) {\n\tvar tx *gorm.DB\n\tif logType == LogTypeUnknown {\n\t\ttx = LOG_DB\n\t} else {\n\t\ttx = LOG_DB.Where(\"type = ?\", logType)\n\t}\n\tif modelName != \"\" {\n\t\ttx = tx.Where(\"model_name = ?\", modelName)\n\t}\n\tif username != \"\" {\n\t\ttx = tx.Where(\"username = ?\", username)\n\t}\n\tif tokenName != \"\" {\n\t\ttx = tx.Where(\"token_name = ?\", tokenName)\n\t}\n\tif startTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at >= ?\", startTimestamp)\n\t}\n\tif endTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at <= ?\", endTimestamp)\n\t}\n\tif channel != 0 {\n\t\ttx = tx.Where(\"channel_id = ?\", channel)\n\t}\n\terr = tx.Order(\"id desc\").Limit(num).Offset(startIdx).Find(&logs).Error\n\treturn logs, err\n}\n\nfunc GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int) (logs []*Log, err error) {\n\tvar tx *gorm.DB\n\tif logType == LogTypeUnknown {\n\t\ttx = LOG_DB.Where(\"user_id = ?\", userId)\n\t} else {\n\t\ttx = LOG_DB.Where(\"user_id = ? and type = ?\", userId, logType)\n\t}\n\tif modelName != \"\" {\n\t\ttx = tx.Where(\"model_name = ?\", modelName)\n\t}\n\tif tokenName != \"\" {\n\t\ttx = tx.Where(\"token_name = ?\", tokenName)\n\t}\n\tif startTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at >= ?\", startTimestamp)\n\t}\n\tif endTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at <= ?\", endTimestamp)\n\t}\n\terr = tx.Order(\"id desc\").Limit(num).Offset(startIdx).Omit(\"id\").Find(&logs).Error\n\treturn logs, err\n}\n\nfunc SearchAllLogs(keyword string) (logs []*Log, err error) {\n\terr = LOG_DB.Where(\"type = ? or content LIKE ?\", keyword, keyword+\"%\").Order(\"id desc\").Limit(config.MaxRecentItems).Find(&logs).Error\n\treturn logs, err\n}\n\nfunc SearchUserLogs(userId int, keyword string) (logs []*Log, err error) {\n\terr = LOG_DB.Where(\"user_id = ? and type = ?\", userId, keyword).Order(\"id desc\").Limit(config.MaxRecentItems).Omit(\"id\").Find(&logs).Error\n\treturn logs, err\n}\n\nfunc SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int) (quota int64) {\n\tifnull := \"ifnull\"\n\tif common.UsingPostgreSQL {\n\t\tifnull = \"COALESCE\"\n\t}\n\ttx := LOG_DB.Table(\"logs\").Select(fmt.Sprintf(\"%s(sum(quota),0)\", ifnull))\n\tif username != \"\" {\n\t\ttx = tx.Where(\"username = ?\", username)\n\t}\n\tif tokenName != \"\" {\n\t\ttx = tx.Where(\"token_name = ?\", tokenName)\n\t}\n\tif startTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at >= ?\", startTimestamp)\n\t}\n\tif endTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at <= ?\", endTimestamp)\n\t}\n\tif modelName != \"\" {\n\t\ttx = tx.Where(\"model_name = ?\", modelName)\n\t}\n\tif channel != 0 {\n\t\ttx = tx.Where(\"channel_id = ?\", channel)\n\t}\n\ttx.Where(\"type = ?\", LogTypeConsume).Scan(&quota)\n\treturn quota\n}\n\nfunc SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {\n\tifnull := \"ifnull\"\n\tif common.UsingPostgreSQL {\n\t\tifnull = \"COALESCE\"\n\t}\n\ttx := LOG_DB.Table(\"logs\").Select(fmt.Sprintf(\"%s(sum(prompt_tokens),0) + %s(sum(completion_tokens),0)\", ifnull, ifnull))\n\tif username != \"\" {\n\t\ttx = tx.Where(\"username = ?\", username)\n\t}\n\tif tokenName != \"\" {\n\t\ttx = tx.Where(\"token_name = ?\", tokenName)\n\t}\n\tif startTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at >= ?\", startTimestamp)\n\t}\n\tif endTimestamp != 0 {\n\t\ttx = tx.Where(\"created_at <= ?\", endTimestamp)\n\t}\n\tif modelName != \"\" {\n\t\ttx = tx.Where(\"model_name = ?\", modelName)\n\t}\n\ttx.Where(\"type = ?\", LogTypeConsume).Scan(&token)\n\treturn token\n}\n\nfunc DeleteOldLog(targetTimestamp int64) (int64, error) {\n\tresult := LOG_DB.Where(\"created_at < ?\", targetTimestamp).Delete(&Log{})\n\treturn result.RowsAffected, result.Error\n}\n\ntype LogStatistic struct {\n\tDay              string `gorm:\"column:day\"`\n\tModelName        string `gorm:\"column:model_name\"`\n\tRequestCount     int    `gorm:\"column:request_count\"`\n\tQuota            int    `gorm:\"column:quota\"`\n\tPromptTokens     int    `gorm:\"column:prompt_tokens\"`\n\tCompletionTokens int    `gorm:\"column:completion_tokens\"`\n}\n\nfunc SearchLogsByDayAndModel(userId, start, end int) (LogStatistics []*LogStatistic, err error) {\n\tgroupSelect := \"DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') as day\"\n\n\tif common.UsingPostgreSQL {\n\t\tgroupSelect = \"TO_CHAR(date_trunc('day', to_timestamp(created_at)), 'YYYY-MM-DD') as day\"\n\t}\n\n\tif common.UsingSQLite {\n\t\tgroupSelect = \"strftime('%Y-%m-%d', datetime(created_at, 'unixepoch')) as day\"\n\t}\n\n\terr = LOG_DB.Raw(`\n\t\tSELECT `+groupSelect+`,\n\t\tmodel_name, count(1) as request_count,\n\t\tsum(quota) as quota,\n\t\tsum(prompt_tokens) as prompt_tokens,\n\t\tsum(completion_tokens) as completion_tokens\n\t\tFROM logs\n\t\tWHERE type=2\n\t\tAND user_id= ?\n\t\tAND created_at BETWEEN ? AND ?\n\t\tGROUP BY day, model_name\n\t\tORDER BY day, model_name\n\t`, userId, start, end).Scan(&LogStatistics).Error\n\n\treturn LogStatistics, err\n}\n"
  },
  {
    "path": "model/main.go",
    "content": "package model\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/env\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"gorm.io/driver/mysql\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\nvar DB *gorm.DB\nvar LOG_DB *gorm.DB\n\nfunc CreateRootAccountIfNeed() error {\n\tvar user User\n\t//if user.Status != util.UserStatusEnabled {\n\tif err := DB.First(&user).Error; err != nil {\n\t\tlogger.SysLog(\"no user exists, creating a root user for you: username is root, password is 123456\")\n\t\thashedPassword, err := common.Password2Hash(\"123456\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\taccessToken := random.GetUUID()\n\t\tif config.InitialRootAccessToken != \"\" {\n\t\t\taccessToken = config.InitialRootAccessToken\n\t\t}\n\t\trootUser := User{\n\t\t\tUsername:    \"root\",\n\t\t\tPassword:    hashedPassword,\n\t\t\tRole:        RoleRootUser,\n\t\t\tStatus:      UserStatusEnabled,\n\t\t\tDisplayName: \"Root User\",\n\t\t\tAccessToken: accessToken,\n\t\t\tQuota:       500000000000000,\n\t\t}\n\t\tDB.Create(&rootUser)\n\t\tif config.InitialRootToken != \"\" {\n\t\t\tlogger.SysLog(\"creating initial root token as requested\")\n\t\t\ttoken := Token{\n\t\t\t\tId:             1,\n\t\t\t\tUserId:         rootUser.Id,\n\t\t\t\tKey:            config.InitialRootToken,\n\t\t\t\tStatus:         TokenStatusEnabled,\n\t\t\t\tName:           \"Initial Root Token\",\n\t\t\t\tCreatedTime:    helper.GetTimestamp(),\n\t\t\t\tAccessedTime:   helper.GetTimestamp(),\n\t\t\t\tExpiredTime:    -1,\n\t\t\t\tRemainQuota:    500000000000000,\n\t\t\t\tUnlimitedQuota: true,\n\t\t\t}\n\t\t\tDB.Create(&token)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc chooseDB(envName string) (*gorm.DB, error) {\n\tdsn := os.Getenv(envName)\n\n\tswitch {\n\tcase strings.HasPrefix(dsn, \"postgres://\"):\n\t\t// Use PostgreSQL\n\t\treturn openPostgreSQL(dsn)\n\tcase dsn != \"\":\n\t\t// Use MySQL\n\t\treturn openMySQL(dsn)\n\tdefault:\n\t\t// Use SQLite\n\t\treturn openSQLite()\n\t}\n}\n\nfunc openPostgreSQL(dsn string) (*gorm.DB, error) {\n\tlogger.SysLog(\"using PostgreSQL as database\")\n\tcommon.UsingPostgreSQL = true\n\treturn gorm.Open(postgres.New(postgres.Config{\n\t\tDSN:                  dsn,\n\t\tPreferSimpleProtocol: true, // disables implicit prepared statement usage\n\t}), &gorm.Config{\n\t\tPrepareStmt: true, // precompile SQL\n\t})\n}\n\nfunc openMySQL(dsn string) (*gorm.DB, error) {\n\tlogger.SysLog(\"using MySQL as database\")\n\tcommon.UsingMySQL = true\n\treturn gorm.Open(mysql.Open(dsn), &gorm.Config{\n\t\tPrepareStmt: true, // precompile SQL\n\t})\n}\n\nfunc openSQLite() (*gorm.DB, error) {\n\tlogger.SysLog(\"SQL_DSN not set, using SQLite as database\")\n\tcommon.UsingSQLite = true\n\tdsn := fmt.Sprintf(\"%s?_busy_timeout=%d\", common.SQLitePath, common.SQLiteBusyTimeout)\n\treturn gorm.Open(sqlite.Open(dsn), &gorm.Config{\n\t\tPrepareStmt: true, // precompile SQL\n\t})\n}\n\nfunc InitDB() {\n\tvar err error\n\tDB, err = chooseDB(\"SQL_DSN\")\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to initialize database: \" + err.Error())\n\t\treturn\n\t}\n\n\tsqlDB := setDBConns(DB)\n\n\tif !config.IsMasterNode {\n\t\treturn\n\t}\n\n\tif common.UsingMySQL {\n\t\t_, _ = sqlDB.Exec(\"DROP INDEX idx_channels_key ON channels;\") // TODO: delete this line when most users have upgraded\n\t}\n\n\tlogger.SysLog(\"database migration started\")\n\tif err = migrateDB(); err != nil {\n\t\tlogger.FatalLog(\"failed to migrate database: \" + err.Error())\n\t\treturn\n\t}\n\tlogger.SysLog(\"database migrated\")\n}\n\nfunc migrateDB() error {\n\tvar err error\n\tif err = DB.AutoMigrate(&Channel{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&Token{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&User{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&Option{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&Redemption{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&Ability{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&Log{}); err != nil {\n\t\treturn err\n\t}\n\tif err = DB.AutoMigrate(&Channel{}); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc InitLogDB() {\n\tif os.Getenv(\"LOG_SQL_DSN\") == \"\" {\n\t\tLOG_DB = DB\n\t\treturn\n\t}\n\n\tlogger.SysLog(\"using secondary database for table logs\")\n\tvar err error\n\tLOG_DB, err = chooseDB(\"LOG_SQL_DSN\")\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to initialize secondary database: \" + err.Error())\n\t\treturn\n\t}\n\n\tsetDBConns(LOG_DB)\n\n\tif !config.IsMasterNode {\n\t\treturn\n\t}\n\n\tlogger.SysLog(\"secondary database migration started\")\n\terr = migrateLOGDB()\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to migrate secondary database: \" + err.Error())\n\t\treturn\n\t}\n\tlogger.SysLog(\"secondary database migrated\")\n}\n\nfunc migrateLOGDB() error {\n\tvar err error\n\tif err = LOG_DB.AutoMigrate(&Log{}); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc setDBConns(db *gorm.DB) *sql.DB {\n\tif config.DebugSQLEnabled {\n\t\tdb = db.Debug()\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\tlogger.FatalLog(\"failed to connect database: \" + err.Error())\n\t\treturn nil\n\t}\n\n\tsqlDB.SetMaxIdleConns(env.Int(\"SQL_MAX_IDLE_CONNS\", 100))\n\tsqlDB.SetMaxOpenConns(env.Int(\"SQL_MAX_OPEN_CONNS\", 1000))\n\tsqlDB.SetConnMaxLifetime(time.Second * time.Duration(env.Int(\"SQL_MAX_LIFETIME\", 60)))\n\treturn sqlDB\n}\n\nfunc closeDB(db *gorm.DB) error {\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = sqlDB.Close()\n\treturn err\n}\n\nfunc CloseDB() error {\n\tif LOG_DB != DB {\n\t\terr := closeDB(LOG_DB)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn closeDB(DB)\n}\n"
  },
  {
    "path": "model/option.go",
    "content": "package model\n\nimport (\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype Option struct {\n\tKey   string `json:\"key\" gorm:\"primaryKey\"`\n\tValue string `json:\"value\"`\n}\n\nfunc AllOption() ([]*Option, error) {\n\tvar options []*Option\n\tvar err error\n\terr = DB.Find(&options).Error\n\treturn options, err\n}\n\nfunc InitOptionMap() {\n\tconfig.OptionMapRWMutex.Lock()\n\tconfig.OptionMap = make(map[string]string)\n\tconfig.OptionMap[\"PasswordLoginEnabled\"] = strconv.FormatBool(config.PasswordLoginEnabled)\n\tconfig.OptionMap[\"PasswordRegisterEnabled\"] = strconv.FormatBool(config.PasswordRegisterEnabled)\n\tconfig.OptionMap[\"EmailVerificationEnabled\"] = strconv.FormatBool(config.EmailVerificationEnabled)\n\tconfig.OptionMap[\"GitHubOAuthEnabled\"] = strconv.FormatBool(config.GitHubOAuthEnabled)\n\tconfig.OptionMap[\"OidcEnabled\"] = strconv.FormatBool(config.OidcEnabled)\n\tconfig.OptionMap[\"WeChatAuthEnabled\"] = strconv.FormatBool(config.WeChatAuthEnabled)\n\tconfig.OptionMap[\"TurnstileCheckEnabled\"] = strconv.FormatBool(config.TurnstileCheckEnabled)\n\tconfig.OptionMap[\"RegisterEnabled\"] = strconv.FormatBool(config.RegisterEnabled)\n\tconfig.OptionMap[\"AutomaticDisableChannelEnabled\"] = strconv.FormatBool(config.AutomaticDisableChannelEnabled)\n\tconfig.OptionMap[\"AutomaticEnableChannelEnabled\"] = strconv.FormatBool(config.AutomaticEnableChannelEnabled)\n\tconfig.OptionMap[\"ApproximateTokenEnabled\"] = strconv.FormatBool(config.ApproximateTokenEnabled)\n\tconfig.OptionMap[\"LogConsumeEnabled\"] = strconv.FormatBool(config.LogConsumeEnabled)\n\tconfig.OptionMap[\"DisplayInCurrencyEnabled\"] = strconv.FormatBool(config.DisplayInCurrencyEnabled)\n\tconfig.OptionMap[\"DisplayTokenStatEnabled\"] = strconv.FormatBool(config.DisplayTokenStatEnabled)\n\tconfig.OptionMap[\"ChannelDisableThreshold\"] = strconv.FormatFloat(config.ChannelDisableThreshold, 'f', -1, 64)\n\tconfig.OptionMap[\"EmailDomainRestrictionEnabled\"] = strconv.FormatBool(config.EmailDomainRestrictionEnabled)\n\tconfig.OptionMap[\"EmailDomainWhitelist\"] = strings.Join(config.EmailDomainWhitelist, \",\")\n\tconfig.OptionMap[\"SMTPServer\"] = \"\"\n\tconfig.OptionMap[\"SMTPFrom\"] = \"\"\n\tconfig.OptionMap[\"SMTPPort\"] = strconv.Itoa(config.SMTPPort)\n\tconfig.OptionMap[\"SMTPAccount\"] = \"\"\n\tconfig.OptionMap[\"SMTPToken\"] = \"\"\n\tconfig.OptionMap[\"Notice\"] = \"\"\n\tconfig.OptionMap[\"About\"] = \"\"\n\tconfig.OptionMap[\"HomePageContent\"] = \"\"\n\tconfig.OptionMap[\"Footer\"] = config.Footer\n\tconfig.OptionMap[\"SystemName\"] = config.SystemName\n\tconfig.OptionMap[\"Logo\"] = config.Logo\n\tconfig.OptionMap[\"ServerAddress\"] = \"\"\n\tconfig.OptionMap[\"GitHubClientId\"] = \"\"\n\tconfig.OptionMap[\"GitHubClientSecret\"] = \"\"\n\tconfig.OptionMap[\"WeChatServerAddress\"] = \"\"\n\tconfig.OptionMap[\"WeChatServerToken\"] = \"\"\n\tconfig.OptionMap[\"WeChatAccountQRCodeImageURL\"] = \"\"\n\tconfig.OptionMap[\"MessagePusherAddress\"] = \"\"\n\tconfig.OptionMap[\"MessagePusherToken\"] = \"\"\n\tconfig.OptionMap[\"TurnstileSiteKey\"] = \"\"\n\tconfig.OptionMap[\"TurnstileSecretKey\"] = \"\"\n\tconfig.OptionMap[\"QuotaForNewUser\"] = strconv.FormatInt(config.QuotaForNewUser, 10)\n\tconfig.OptionMap[\"QuotaForInviter\"] = strconv.FormatInt(config.QuotaForInviter, 10)\n\tconfig.OptionMap[\"QuotaForInvitee\"] = strconv.FormatInt(config.QuotaForInvitee, 10)\n\tconfig.OptionMap[\"QuotaRemindThreshold\"] = strconv.FormatInt(config.QuotaRemindThreshold, 10)\n\tconfig.OptionMap[\"PreConsumedQuota\"] = strconv.FormatInt(config.PreConsumedQuota, 10)\n\tconfig.OptionMap[\"ModelRatio\"] = billingratio.ModelRatio2JSONString()\n\tconfig.OptionMap[\"GroupRatio\"] = billingratio.GroupRatio2JSONString()\n\tconfig.OptionMap[\"CompletionRatio\"] = billingratio.CompletionRatio2JSONString()\n\tconfig.OptionMap[\"TopUpLink\"] = config.TopUpLink\n\tconfig.OptionMap[\"ChatLink\"] = config.ChatLink\n\tconfig.OptionMap[\"QuotaPerUnit\"] = strconv.FormatFloat(config.QuotaPerUnit, 'f', -1, 64)\n\tconfig.OptionMap[\"RetryTimes\"] = strconv.Itoa(config.RetryTimes)\n\tconfig.OptionMap[\"Theme\"] = config.Theme\n\tconfig.OptionMapRWMutex.Unlock()\n\tloadOptionsFromDatabase()\n}\n\nfunc loadOptionsFromDatabase() {\n\toptions, _ := AllOption()\n\tfor _, option := range options {\n\t\tif option.Key == \"ModelRatio\" {\n\t\t\toption.Value = billingratio.AddNewMissingRatio(option.Value)\n\t\t}\n\t\terr := updateOptionMap(option.Key, option.Value)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"failed to update option map: \" + err.Error())\n\t\t}\n\t}\n}\n\nfunc SyncOptions(frequency int) {\n\tfor {\n\t\ttime.Sleep(time.Duration(frequency) * time.Second)\n\t\tlogger.SysLog(\"syncing options from database\")\n\t\tloadOptionsFromDatabase()\n\t}\n}\n\nfunc UpdateOption(key string, value string) error {\n\t// Save to database first\n\toption := Option{\n\t\tKey: key,\n\t}\n\t// https://gorm.io/docs/update.html#Save-All-Fields\n\tDB.FirstOrCreate(&option, Option{Key: key})\n\toption.Value = value\n\t// Save is a combination function.\n\t// If save value does not contain primary key, it will execute Create,\n\t// otherwise it will execute Update (with all fields).\n\tDB.Save(&option)\n\t// Update OptionMap\n\treturn updateOptionMap(key, value)\n}\n\nfunc updateOptionMap(key string, value string) (err error) {\n\tconfig.OptionMapRWMutex.Lock()\n\tdefer config.OptionMapRWMutex.Unlock()\n\tconfig.OptionMap[key] = value\n\tif strings.HasSuffix(key, \"Enabled\") {\n\t\tboolValue := value == \"true\"\n\t\tswitch key {\n\t\tcase \"PasswordRegisterEnabled\":\n\t\t\tconfig.PasswordRegisterEnabled = boolValue\n\t\tcase \"PasswordLoginEnabled\":\n\t\t\tconfig.PasswordLoginEnabled = boolValue\n\t\tcase \"EmailVerificationEnabled\":\n\t\t\tconfig.EmailVerificationEnabled = boolValue\n\t\tcase \"GitHubOAuthEnabled\":\n\t\t\tconfig.GitHubOAuthEnabled = boolValue\n\t\tcase \"OidcEnabled\":\n\t\t\tconfig.OidcEnabled = boolValue\n\t\tcase \"WeChatAuthEnabled\":\n\t\t\tconfig.WeChatAuthEnabled = boolValue\n\t\tcase \"TurnstileCheckEnabled\":\n\t\t\tconfig.TurnstileCheckEnabled = boolValue\n\t\tcase \"RegisterEnabled\":\n\t\t\tconfig.RegisterEnabled = boolValue\n\t\tcase \"EmailDomainRestrictionEnabled\":\n\t\t\tconfig.EmailDomainRestrictionEnabled = boolValue\n\t\tcase \"AutomaticDisableChannelEnabled\":\n\t\t\tconfig.AutomaticDisableChannelEnabled = boolValue\n\t\tcase \"AutomaticEnableChannelEnabled\":\n\t\t\tconfig.AutomaticEnableChannelEnabled = boolValue\n\t\tcase \"ApproximateTokenEnabled\":\n\t\t\tconfig.ApproximateTokenEnabled = boolValue\n\t\tcase \"LogConsumeEnabled\":\n\t\t\tconfig.LogConsumeEnabled = boolValue\n\t\tcase \"DisplayInCurrencyEnabled\":\n\t\t\tconfig.DisplayInCurrencyEnabled = boolValue\n\t\tcase \"DisplayTokenStatEnabled\":\n\t\t\tconfig.DisplayTokenStatEnabled = boolValue\n\t\t}\n\t}\n\tswitch key {\n\tcase \"EmailDomainWhitelist\":\n\t\tconfig.EmailDomainWhitelist = strings.Split(value, \",\")\n\tcase \"SMTPServer\":\n\t\tconfig.SMTPServer = value\n\tcase \"SMTPPort\":\n\t\tintValue, _ := strconv.Atoi(value)\n\t\tconfig.SMTPPort = intValue\n\tcase \"SMTPAccount\":\n\t\tconfig.SMTPAccount = value\n\tcase \"SMTPFrom\":\n\t\tconfig.SMTPFrom = value\n\tcase \"SMTPToken\":\n\t\tconfig.SMTPToken = value\n\tcase \"ServerAddress\":\n\t\tconfig.ServerAddress = value\n\tcase \"GitHubClientId\":\n\t\tconfig.GitHubClientId = value\n\tcase \"GitHubClientSecret\":\n\t\tconfig.GitHubClientSecret = value\n\tcase \"LarkClientId\":\n\t\tconfig.LarkClientId = value\n\tcase \"LarkClientSecret\":\n\t\tconfig.LarkClientSecret = value\n\tcase \"OidcClientId\":\n\t\tconfig.OidcClientId = value\n\tcase \"OidcClientSecret\":\n\t\tconfig.OidcClientSecret = value\n\tcase \"OidcWellKnown\":\n\t\tconfig.OidcWellKnown = value\n\tcase \"OidcAuthorizationEndpoint\":\n\t\tconfig.OidcAuthorizationEndpoint = value\n\tcase \"OidcTokenEndpoint\":\n\t\tconfig.OidcTokenEndpoint = value\n\tcase \"OidcUserinfoEndpoint\":\n\t\tconfig.OidcUserinfoEndpoint = value\n\tcase \"Footer\":\n\t\tconfig.Footer = value\n\tcase \"SystemName\":\n\t\tconfig.SystemName = value\n\tcase \"Logo\":\n\t\tconfig.Logo = value\n\tcase \"WeChatServerAddress\":\n\t\tconfig.WeChatServerAddress = value\n\tcase \"WeChatServerToken\":\n\t\tconfig.WeChatServerToken = value\n\tcase \"WeChatAccountQRCodeImageURL\":\n\t\tconfig.WeChatAccountQRCodeImageURL = value\n\tcase \"MessagePusherAddress\":\n\t\tconfig.MessagePusherAddress = value\n\tcase \"MessagePusherToken\":\n\t\tconfig.MessagePusherToken = value\n\tcase \"TurnstileSiteKey\":\n\t\tconfig.TurnstileSiteKey = value\n\tcase \"TurnstileSecretKey\":\n\t\tconfig.TurnstileSecretKey = value\n\tcase \"QuotaForNewUser\":\n\t\tconfig.QuotaForNewUser, _ = strconv.ParseInt(value, 10, 64)\n\tcase \"QuotaForInviter\":\n\t\tconfig.QuotaForInviter, _ = strconv.ParseInt(value, 10, 64)\n\tcase \"QuotaForInvitee\":\n\t\tconfig.QuotaForInvitee, _ = strconv.ParseInt(value, 10, 64)\n\tcase \"QuotaRemindThreshold\":\n\t\tconfig.QuotaRemindThreshold, _ = strconv.ParseInt(value, 10, 64)\n\tcase \"PreConsumedQuota\":\n\t\tconfig.PreConsumedQuota, _ = strconv.ParseInt(value, 10, 64)\n\tcase \"RetryTimes\":\n\t\tconfig.RetryTimes, _ = strconv.Atoi(value)\n\tcase \"ModelRatio\":\n\t\terr = billingratio.UpdateModelRatioByJSONString(value)\n\tcase \"GroupRatio\":\n\t\terr = billingratio.UpdateGroupRatioByJSONString(value)\n\tcase \"CompletionRatio\":\n\t\terr = billingratio.UpdateCompletionRatioByJSONString(value)\n\tcase \"TopUpLink\":\n\t\tconfig.TopUpLink = value\n\tcase \"ChatLink\":\n\t\tconfig.ChatLink = value\n\tcase \"ChannelDisableThreshold\":\n\t\tconfig.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64)\n\tcase \"QuotaPerUnit\":\n\t\tconfig.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)\n\tcase \"Theme\":\n\t\tconfig.Theme = value\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "model/redemption.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n)\n\nconst (\n\tRedemptionCodeStatusEnabled  = 1 // don't use 0, 0 is the default value!\n\tRedemptionCodeStatusDisabled = 2 // also don't use 0\n\tRedemptionCodeStatusUsed     = 3 // also don't use 0\n)\n\ntype Redemption struct {\n\tId           int    `json:\"id\"`\n\tUserId       int    `json:\"user_id\"`\n\tKey          string `json:\"key\" gorm:\"type:char(32);uniqueIndex\"`\n\tStatus       int    `json:\"status\" gorm:\"default:1\"`\n\tName         string `json:\"name\" gorm:\"index\"`\n\tQuota        int64  `json:\"quota\" gorm:\"bigint;default:100\"`\n\tCreatedTime  int64  `json:\"created_time\" gorm:\"bigint\"`\n\tRedeemedTime int64  `json:\"redeemed_time\" gorm:\"bigint\"`\n\tCount        int    `json:\"count\" gorm:\"-:all\"` // only for api request\n}\n\nfunc GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) {\n\tvar redemptions []*Redemption\n\tvar err error\n\terr = DB.Order(\"id desc\").Limit(num).Offset(startIdx).Find(&redemptions).Error\n\treturn redemptions, err\n}\n\nfunc SearchRedemptions(keyword string) (redemptions []*Redemption, err error) {\n\terr = DB.Where(\"id = ? or name LIKE ?\", keyword, keyword+\"%\").Find(&redemptions).Error\n\treturn redemptions, err\n}\n\nfunc GetRedemptionById(id int) (*Redemption, error) {\n\tif id == 0 {\n\t\treturn nil, errors.New(\"id 为空！\")\n\t}\n\tredemption := Redemption{Id: id}\n\tvar err error = nil\n\terr = DB.First(&redemption, \"id = ?\", id).Error\n\treturn &redemption, err\n}\n\nfunc Redeem(ctx context.Context, key string, userId int) (quota int64, err error) {\n\tif key == \"\" {\n\t\treturn 0, errors.New(\"未提供兑换码\")\n\t}\n\tif userId == 0 {\n\t\treturn 0, errors.New(\"无效的 user id\")\n\t}\n\tredemption := &Redemption{}\n\n\tkeyCol := \"`key`\"\n\tif common.UsingPostgreSQL {\n\t\tkeyCol = `\"key\"`\n\t}\n\n\terr = DB.Transaction(func(tx *gorm.DB) error {\n\t\terr := tx.Set(\"gorm:query_option\", \"FOR UPDATE\").Where(keyCol+\" = ?\", key).First(redemption).Error\n\t\tif err != nil {\n\t\t\treturn errors.New(\"无效的兑换码\")\n\t\t}\n\t\tif redemption.Status != RedemptionCodeStatusEnabled {\n\t\t\treturn errors.New(\"该兑换码已被使用\")\n\t\t}\n\t\terr = tx.Model(&User{}).Where(\"id = ?\", userId).Update(\"quota\", gorm.Expr(\"quota + ?\", redemption.Quota)).Error\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tredemption.RedeemedTime = helper.GetTimestamp()\n\t\tredemption.Status = RedemptionCodeStatusUsed\n\t\terr = tx.Save(redemption).Error\n\t\treturn err\n\t})\n\tif err != nil {\n\t\treturn 0, errors.New(\"兑换失败，\" + err.Error())\n\t}\n\tRecordLog(ctx, userId, LogTypeTopup, fmt.Sprintf(\"通过兑换码充值 %s\", common.LogQuota(redemption.Quota)))\n\treturn redemption.Quota, nil\n}\n\nfunc (redemption *Redemption) Insert() error {\n\tvar err error\n\terr = DB.Create(redemption).Error\n\treturn err\n}\n\nfunc (redemption *Redemption) SelectUpdate() error {\n\t// This can update zero values\n\treturn DB.Model(redemption).Select(\"redeemed_time\", \"status\").Updates(redemption).Error\n}\n\n// Update Make sure your token's fields is completed, because this will update non-zero values\nfunc (redemption *Redemption) Update() error {\n\tvar err error\n\terr = DB.Model(redemption).Select(\"name\", \"status\", \"quota\", \"redeemed_time\").Updates(redemption).Error\n\treturn err\n}\n\nfunc (redemption *Redemption) Delete() error {\n\tvar err error\n\terr = DB.Delete(redemption).Error\n\treturn err\n}\n\nfunc DeleteRedemptionById(id int) (err error) {\n\tif id == 0 {\n\t\treturn errors.New(\"id 为空！\")\n\t}\n\tredemption := Redemption{Id: id}\n\terr = DB.Where(redemption).First(&redemption).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn redemption.Delete()\n}\n"
  },
  {
    "path": "model/token.go",
    "content": "package model\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/message\"\n)\n\nconst (\n\tTokenStatusEnabled   = 1 // don't use 0, 0 is the default value!\n\tTokenStatusDisabled  = 2 // also don't use 0\n\tTokenStatusExpired   = 3\n\tTokenStatusExhausted = 4\n)\n\ntype Token struct {\n\tId             int     `json:\"id\"`\n\tUserId         int     `json:\"user_id\"`\n\tKey            string  `json:\"key\" gorm:\"type:char(48);uniqueIndex\"`\n\tStatus         int     `json:\"status\" gorm:\"default:1\"`\n\tName           string  `json:\"name\" gorm:\"index\" `\n\tCreatedTime    int64   `json:\"created_time\" gorm:\"bigint\"`\n\tAccessedTime   int64   `json:\"accessed_time\" gorm:\"bigint\"`\n\tExpiredTime    int64   `json:\"expired_time\" gorm:\"bigint;default:-1\"` // -1 means never expired\n\tRemainQuota    int64   `json:\"remain_quota\" gorm:\"bigint;default:0\"`\n\tUnlimitedQuota bool    `json:\"unlimited_quota\" gorm:\"default:false\"`\n\tUsedQuota      int64   `json:\"used_quota\" gorm:\"bigint;default:0\"` // used quota\n\tModels         *string `json:\"models\" gorm:\"type:text\"`            // allowed models\n\tSubnet         *string `json:\"subnet\" gorm:\"default:''\"`           // allowed subnet\n}\n\nfunc GetAllUserTokens(userId int, startIdx int, num int, order string) ([]*Token, error) {\n\tvar tokens []*Token\n\tvar err error\n\tquery := DB.Where(\"user_id = ?\", userId)\n\n\tswitch order {\n\tcase \"remain_quota\":\n\t\tquery = query.Order(\"unlimited_quota desc, remain_quota desc\")\n\tcase \"used_quota\":\n\t\tquery = query.Order(\"used_quota desc\")\n\tdefault:\n\t\tquery = query.Order(\"id desc\")\n\t}\n\n\terr = query.Limit(num).Offset(startIdx).Find(&tokens).Error\n\treturn tokens, err\n}\n\nfunc SearchUserTokens(userId int, keyword string) (tokens []*Token, err error) {\n\terr = DB.Where(\"user_id = ?\", userId).Where(\"name LIKE ?\", keyword+\"%\").Find(&tokens).Error\n\treturn tokens, err\n}\n\nfunc ValidateUserToken(key string) (token *Token, err error) {\n\tif key == \"\" {\n\t\treturn nil, errors.New(\"未提供令牌\")\n\t}\n\ttoken, err = CacheGetTokenByKey(key)\n\tif err != nil {\n\t\tlogger.SysError(\"CacheGetTokenByKey failed: \" + err.Error())\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, errors.New(\"无效的令牌\")\n\t\t}\n\t\treturn nil, errors.New(\"令牌验证失败\")\n\t}\n\tif token.Status == TokenStatusExhausted {\n\t\treturn nil, fmt.Errorf(\"令牌 %s（#%d）额度已用尽\", token.Name, token.Id)\n\t} else if token.Status == TokenStatusExpired {\n\t\treturn nil, errors.New(\"该令牌已过期\")\n\t}\n\tif token.Status != TokenStatusEnabled {\n\t\treturn nil, errors.New(\"该令牌状态不可用\")\n\t}\n\tif token.ExpiredTime != -1 && token.ExpiredTime < helper.GetTimestamp() {\n\t\tif !common.RedisEnabled {\n\t\t\ttoken.Status = TokenStatusExpired\n\t\t\terr := token.SelectUpdate()\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"failed to update token status\" + err.Error())\n\t\t\t}\n\t\t}\n\t\treturn nil, errors.New(\"该令牌已过期\")\n\t}\n\tif !token.UnlimitedQuota && token.RemainQuota <= 0 {\n\t\tif !common.RedisEnabled {\n\t\t\t// in this case, we can make sure the token is exhausted\n\t\t\ttoken.Status = TokenStatusExhausted\n\t\t\terr := token.SelectUpdate()\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"failed to update token status\" + err.Error())\n\t\t\t}\n\t\t}\n\t\treturn nil, errors.New(\"该令牌额度已用尽\")\n\t}\n\treturn token, nil\n}\n\nfunc GetTokenByIds(id int, userId int) (*Token, error) {\n\tif id == 0 || userId == 0 {\n\t\treturn nil, errors.New(\"id 或 userId 为空！\")\n\t}\n\ttoken := Token{Id: id, UserId: userId}\n\tvar err error = nil\n\terr = DB.First(&token, \"id = ? and user_id = ?\", id, userId).Error\n\treturn &token, err\n}\n\nfunc GetTokenById(id int) (*Token, error) {\n\tif id == 0 {\n\t\treturn nil, errors.New(\"id 为空！\")\n\t}\n\ttoken := Token{Id: id}\n\tvar err error = nil\n\terr = DB.First(&token, \"id = ?\", id).Error\n\treturn &token, err\n}\n\nfunc (t *Token) Insert() error {\n\tvar err error\n\terr = DB.Create(t).Error\n\treturn err\n}\n\n// Update Make sure your token's fields is completed, because this will update non-zero values\nfunc (t *Token) Update() error {\n\tvar err error\n\terr = DB.Model(t).Select(\"name\", \"status\", \"expired_time\", \"remain_quota\", \"unlimited_quota\", \"models\", \"subnet\").Updates(t).Error\n\treturn err\n}\n\nfunc (t *Token) SelectUpdate() error {\n\t// This can update zero values\n\treturn DB.Model(t).Select(\"accessed_time\", \"status\").Updates(t).Error\n}\n\nfunc (t *Token) Delete() error {\n\tvar err error\n\terr = DB.Delete(t).Error\n\treturn err\n}\n\nfunc (t *Token) GetModels() string {\n\tif t == nil {\n\t\treturn \"\"\n\t}\n\tif t.Models == nil {\n\t\treturn \"\"\n\t}\n\treturn *t.Models\n}\n\nfunc DeleteTokenById(id int, userId int) (err error) {\n\t// Why we need userId here? In case user want to delete other's token.\n\tif id == 0 || userId == 0 {\n\t\treturn errors.New(\"id 或 userId 为空！\")\n\t}\n\ttoken := Token{Id: id, UserId: userId}\n\terr = DB.Where(token).First(&token).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn token.Delete()\n}\n\nfunc IncreaseTokenQuota(id int, quota int64) (err error) {\n\tif quota < 0 {\n\t\treturn errors.New(\"quota 不能为负数！\")\n\t}\n\tif config.BatchUpdateEnabled {\n\t\taddNewRecord(BatchUpdateTypeTokenQuota, id, quota)\n\t\treturn nil\n\t}\n\treturn increaseTokenQuota(id, quota)\n}\n\nfunc increaseTokenQuota(id int, quota int64) (err error) {\n\terr = DB.Model(&Token{}).Where(\"id = ?\", id).Updates(\n\t\tmap[string]interface{}{\n\t\t\t\"remain_quota\":  gorm.Expr(\"remain_quota + ?\", quota),\n\t\t\t\"used_quota\":    gorm.Expr(\"used_quota - ?\", quota),\n\t\t\t\"accessed_time\": helper.GetTimestamp(),\n\t\t},\n\t).Error\n\treturn err\n}\n\nfunc DecreaseTokenQuota(id int, quota int64) (err error) {\n\tif quota < 0 {\n\t\treturn errors.New(\"quota 不能为负数！\")\n\t}\n\tif config.BatchUpdateEnabled {\n\t\taddNewRecord(BatchUpdateTypeTokenQuota, id, -quota)\n\t\treturn nil\n\t}\n\treturn decreaseTokenQuota(id, quota)\n}\n\nfunc decreaseTokenQuota(id int, quota int64) (err error) {\n\terr = DB.Model(&Token{}).Where(\"id = ?\", id).Updates(\n\t\tmap[string]interface{}{\n\t\t\t\"remain_quota\":  gorm.Expr(\"remain_quota - ?\", quota),\n\t\t\t\"used_quota\":    gorm.Expr(\"used_quota + ?\", quota),\n\t\t\t\"accessed_time\": helper.GetTimestamp(),\n\t\t},\n\t).Error\n\treturn err\n}\n\nfunc PreConsumeTokenQuota(tokenId int, quota int64) (err error) {\n\tif quota < 0 {\n\t\treturn errors.New(\"quota 不能为负数！\")\n\t}\n\ttoken, err := GetTokenById(tokenId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !token.UnlimitedQuota && token.RemainQuota < quota {\n\t\treturn errors.New(\"令牌额度不足\")\n\t}\n\tuserQuota, err := GetUserQuota(token.UserId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif userQuota < quota {\n\t\treturn errors.New(\"用户额度不足\")\n\t}\n\tquotaTooLow := userQuota >= config.QuotaRemindThreshold && userQuota-quota < config.QuotaRemindThreshold\n\tnoMoreQuota := userQuota-quota <= 0\n\tif quotaTooLow || noMoreQuota {\n\t\tgo func() {\n\t\t\temail, err := GetUserEmail(token.UserId)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"failed to fetch user email: \" + err.Error())\n\t\t\t}\n\t\t\tprompt := \"额度提醒\"\n\t\t\tvar contentText string\n\t\t\tif noMoreQuota {\n\t\t\t\tcontentText = \"您的额度已用尽\"\n\t\t\t} else {\n\t\t\t\tcontentText = \"您的额度即将用尽\"\n\t\t\t}\n\t\t\tif email != \"\" {\n\t\t\t\ttopUpLink := fmt.Sprintf(\"%s/topup\", config.ServerAddress)\n\t\t\t\tcontent := message.EmailTemplate(\n\t\t\t\t\tprompt,\n\t\t\t\t\tfmt.Sprintf(`\n\t\t\t\t\t\t<p>您好！</p>\n\t\t\t\t\t\t<p>%s，当前剩余额度为 <strong>%d</strong>。</p>\n\t\t\t\t\t\t<p>为了不影响您的使用，请及时充值。</p>\n\t\t\t\t\t\t<p style=\"text-align: center; margin: 30px 0;\">\n\t\t\t\t\t\t\t<a href=\"%s\" style=\"background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;\">立即充值</a>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<p style=\"color: #666;\">如果按钮无法点击，请复制以下链接到浏览器中打开：</p>\n\t\t\t\t\t\t<p style=\"background-color: #f8f8f8; padding: 10px; border-radius: 4px; word-break: break-all;\">%s</p>\n\t\t\t\t\t`, contentText, userQuota, topUpLink, topUpLink),\n\t\t\t\t)\n\t\t\t\terr = message.SendEmail(prompt, email, content)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"failed to send email: \" + err.Error())\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\tif !token.UnlimitedQuota {\n\t\terr = DecreaseTokenQuota(tokenId, quota)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\terr = DecreaseUserQuota(token.UserId, quota)\n\treturn err\n}\n\nfunc PostConsumeTokenQuota(tokenId int, quota int64) (err error) {\n\ttoken, err := GetTokenById(tokenId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif quota > 0 {\n\t\terr = DecreaseUserQuota(token.UserId, quota)\n\t} else {\n\t\terr = IncreaseUserQuota(token.UserId, -quota)\n\t}\n\tif !token.UnlimitedQuota {\n\t\tif quota > 0 {\n\t\t\terr = DecreaseTokenQuota(tokenId, quota)\n\t\t} else {\n\t\t\terr = IncreaseTokenQuota(tokenId, -quota)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "model/user.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/blacklist\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n)\n\nconst (\n\tRoleGuestUser  = 0\n\tRoleCommonUser = 1\n\tRoleAdminUser  = 10\n\tRoleRootUser   = 100\n)\n\nconst (\n\tUserStatusEnabled  = 1 // don't use 0, 0 is the default value!\n\tUserStatusDisabled = 2 // also don't use 0\n\tUserStatusDeleted  = 3\n)\n\n// User if you add sensitive fields, don't forget to clean them in setupLogin function.\n// Otherwise, the sensitive information will be saved on local storage in plain text!\ntype User struct {\n\tId               int    `json:\"id\"`\n\tUsername         string `json:\"username\" gorm:\"unique;index\" validate:\"max=12\"`\n\tPassword         string `json:\"password\" gorm:\"not null;\" validate:\"min=8,max=20\"`\n\tDisplayName      string `json:\"display_name\" gorm:\"index\" validate:\"max=20\"`\n\tRole             int    `json:\"role\" gorm:\"type:int;default:1\"`   // admin, util\n\tStatus           int    `json:\"status\" gorm:\"type:int;default:1\"` // enabled, disabled\n\tEmail            string `json:\"email\" gorm:\"index\" validate:\"max=50\"`\n\tGitHubId         string `json:\"github_id\" gorm:\"column:github_id;index\"`\n\tWeChatId         string `json:\"wechat_id\" gorm:\"column:wechat_id;index\"`\n\tLarkId           string `json:\"lark_id\" gorm:\"column:lark_id;index\"`\n\tOidcId           string `json:\"oidc_id\" gorm:\"column:oidc_id;index\"`\n\tVerificationCode string `json:\"verification_code\" gorm:\"-:all\"`                                    // this field is only for Email verification, don't save it to database!\n\tAccessToken      string `json:\"access_token\" gorm:\"type:char(32);column:access_token;uniqueIndex\"` // this token is for system management\n\tQuota            int64  `json:\"quota\" gorm:\"bigint;default:0\"`\n\tUsedQuota        int64  `json:\"used_quota\" gorm:\"bigint;default:0;column:used_quota\"` // used quota\n\tRequestCount     int    `json:\"request_count\" gorm:\"type:int;default:0;\"`             // request number\n\tGroup            string `json:\"group\" gorm:\"type:varchar(32);default:'default'\"`\n\tAffCode          string `json:\"aff_code\" gorm:\"type:varchar(32);column:aff_code;uniqueIndex\"`\n\tInviterId        int    `json:\"inviter_id\" gorm:\"type:int;column:inviter_id;index\"`\n}\n\nfunc GetMaxUserId() int {\n\tvar user User\n\tDB.Last(&user)\n\treturn user.Id\n}\n\nfunc GetAllUsers(startIdx int, num int, order string) (users []*User, err error) {\n\tquery := DB.Limit(num).Offset(startIdx).Omit(\"password\").Where(\"status != ?\", UserStatusDeleted)\n\n\tswitch order {\n\tcase \"quota\":\n\t\tquery = query.Order(\"quota desc\")\n\tcase \"used_quota\":\n\t\tquery = query.Order(\"used_quota desc\")\n\tcase \"request_count\":\n\t\tquery = query.Order(\"request_count desc\")\n\tdefault:\n\t\tquery = query.Order(\"id desc\")\n\t}\n\n\terr = query.Find(&users).Error\n\treturn users, err\n}\n\nfunc SearchUsers(keyword string) (users []*User, err error) {\n\tif !common.UsingPostgreSQL {\n\t\terr = DB.Omit(\"password\").Where(\"id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?\", keyword, keyword+\"%\", keyword+\"%\", keyword+\"%\").Find(&users).Error\n\t} else {\n\t\terr = DB.Omit(\"password\").Where(\"username LIKE ? or email LIKE ? or display_name LIKE ?\", keyword+\"%\", keyword+\"%\", keyword+\"%\").Find(&users).Error\n\t}\n\treturn users, err\n}\n\nfunc GetUserById(id int, selectAll bool) (*User, error) {\n\tif id == 0 {\n\t\treturn nil, errors.New(\"id 为空！\")\n\t}\n\tuser := User{Id: id}\n\tvar err error = nil\n\tif selectAll {\n\t\terr = DB.First(&user, \"id = ?\", id).Error\n\t} else {\n\t\terr = DB.Omit(\"password\", \"access_token\").First(&user, \"id = ?\", id).Error\n\t}\n\treturn &user, err\n}\n\nfunc GetUserIdByAffCode(affCode string) (int, error) {\n\tif affCode == \"\" {\n\t\treturn 0, errors.New(\"affCode 为空！\")\n\t}\n\tvar user User\n\terr := DB.Select(\"id\").First(&user, \"aff_code = ?\", affCode).Error\n\treturn user.Id, err\n}\n\nfunc DeleteUserById(id int) (err error) {\n\tif id == 0 {\n\t\treturn errors.New(\"id 为空！\")\n\t}\n\tuser := User{Id: id}\n\treturn user.Delete()\n}\n\nfunc (user *User) Insert(ctx context.Context, inviterId int) error {\n\tvar err error\n\tif user.Password != \"\" {\n\t\tuser.Password, err = common.Password2Hash(user.Password)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tuser.Quota = config.QuotaForNewUser\n\tuser.AccessToken = random.GetUUID()\n\tuser.AffCode = random.GetRandomString(4)\n\tresult := DB.Create(user)\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif config.QuotaForNewUser > 0 {\n\t\tRecordLog(ctx, user.Id, LogTypeSystem, fmt.Sprintf(\"新用户注册赠送 %s\", common.LogQuota(config.QuotaForNewUser)))\n\t}\n\tif inviterId != 0 {\n\t\tif config.QuotaForInvitee > 0 {\n\t\t\t_ = IncreaseUserQuota(user.Id, config.QuotaForInvitee)\n\t\t\tRecordLog(ctx, user.Id, LogTypeSystem, fmt.Sprintf(\"使用邀请码赠送 %s\", common.LogQuota(config.QuotaForInvitee)))\n\t\t}\n\t\tif config.QuotaForInviter > 0 {\n\t\t\t_ = IncreaseUserQuota(inviterId, config.QuotaForInviter)\n\t\t\tRecordLog(ctx, inviterId, LogTypeSystem, fmt.Sprintf(\"邀请用户赠送 %s\", common.LogQuota(config.QuotaForInviter)))\n\t\t}\n\t}\n\t// create default token\n\tcleanToken := Token{\n\t\tUserId:         user.Id,\n\t\tName:           \"default\",\n\t\tKey:            random.GenerateKey(),\n\t\tCreatedTime:    helper.GetTimestamp(),\n\t\tAccessedTime:   helper.GetTimestamp(),\n\t\tExpiredTime:    -1,\n\t\tRemainQuota:    -1,\n\t\tUnlimitedQuota: true,\n\t}\n\tresult.Error = cleanToken.Insert()\n\tif result.Error != nil {\n\t\t// do not block\n\t\tlogger.SysError(fmt.Sprintf(\"create default token for user %d failed: %s\", user.Id, result.Error.Error()))\n\t}\n\treturn nil\n}\n\nfunc (user *User) Update(updatePassword bool) error {\n\tvar err error\n\tif updatePassword {\n\t\tuser.Password, err = common.Password2Hash(user.Password)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif user.Status == UserStatusDisabled {\n\t\tblacklist.BanUser(user.Id)\n\t} else if user.Status == UserStatusEnabled {\n\t\tblacklist.UnbanUser(user.Id)\n\t}\n\terr = DB.Model(user).Updates(user).Error\n\treturn err\n}\n\nfunc (user *User) Delete() error {\n\tif user.Id == 0 {\n\t\treturn errors.New(\"id 为空！\")\n\t}\n\tblacklist.BanUser(user.Id)\n\tuser.Username = fmt.Sprintf(\"deleted_%s\", random.GetUUID())\n\tuser.Status = UserStatusDeleted\n\terr := DB.Model(user).Updates(user).Error\n\treturn err\n}\n\n// ValidateAndFill check password & user status\nfunc (user *User) ValidateAndFill() (err error) {\n\t// When querying with struct, GORM will only query with non-zero fields,\n\t// that means if your field’s value is 0, '', false or other zero values,\n\t// it won’t be used to build query conditions\n\tpassword := user.Password\n\tif user.Username == \"\" || password == \"\" {\n\t\treturn errors.New(\"用户名或密码为空\")\n\t}\n\terr = DB.Where(\"username = ?\", user.Username).First(user).Error\n\tif err != nil {\n\t\t// we must make sure check username firstly\n\t\t// consider this case: a malicious user set his username as other's email\n\t\terr := DB.Where(\"email = ?\", user.Username).First(user).Error\n\t\tif err != nil {\n\t\t\treturn errors.New(\"用户名或密码错误，或用户已被封禁\")\n\t\t}\n\t}\n\tokay := common.ValidatePasswordAndHash(password, user.Password)\n\tif !okay || user.Status != UserStatusEnabled {\n\t\treturn errors.New(\"用户名或密码错误，或用户已被封禁\")\n\t}\n\treturn nil\n}\n\nfunc (user *User) FillUserById() error {\n\tif user.Id == 0 {\n\t\treturn errors.New(\"id 为空！\")\n\t}\n\tDB.Where(User{Id: user.Id}).First(user)\n\treturn nil\n}\n\nfunc (user *User) FillUserByEmail() error {\n\tif user.Email == \"\" {\n\t\treturn errors.New(\"email 为空！\")\n\t}\n\tDB.Where(User{Email: user.Email}).First(user)\n\treturn nil\n}\n\nfunc (user *User) FillUserByGitHubId() error {\n\tif user.GitHubId == \"\" {\n\t\treturn errors.New(\"GitHub id 为空！\")\n\t}\n\tDB.Where(User{GitHubId: user.GitHubId}).First(user)\n\treturn nil\n}\n\nfunc (user *User) FillUserByLarkId() error {\n\tif user.LarkId == \"\" {\n\t\treturn errors.New(\"lark id 为空！\")\n\t}\n\tDB.Where(User{LarkId: user.LarkId}).First(user)\n\treturn nil\n}\n\nfunc (user *User) FillUserByOidcId() error {\n\tif user.OidcId == \"\" {\n\t\treturn errors.New(\"oidc id 为空！\")\n\t}\n\tDB.Where(User{OidcId: user.OidcId}).First(user)\n\treturn nil\n}\n\nfunc (user *User) FillUserByWeChatId() error {\n\tif user.WeChatId == \"\" {\n\t\treturn errors.New(\"WeChat id 为空！\")\n\t}\n\tDB.Where(User{WeChatId: user.WeChatId}).First(user)\n\treturn nil\n}\n\nfunc (user *User) FillUserByUsername() error {\n\tif user.Username == \"\" {\n\t\treturn errors.New(\"username 为空！\")\n\t}\n\tDB.Where(User{Username: user.Username}).First(user)\n\treturn nil\n}\n\nfunc IsEmailAlreadyTaken(email string) bool {\n\treturn DB.Where(\"email = ?\", email).Find(&User{}).RowsAffected == 1\n}\n\nfunc IsWeChatIdAlreadyTaken(wechatId string) bool {\n\treturn DB.Where(\"wechat_id = ?\", wechatId).Find(&User{}).RowsAffected == 1\n}\n\nfunc IsGitHubIdAlreadyTaken(githubId string) bool {\n\treturn DB.Where(\"github_id = ?\", githubId).Find(&User{}).RowsAffected == 1\n}\n\nfunc IsLarkIdAlreadyTaken(githubId string) bool {\n\treturn DB.Where(\"lark_id = ?\", githubId).Find(&User{}).RowsAffected == 1\n}\n\nfunc IsOidcIdAlreadyTaken(oidcId string) bool {\n\treturn DB.Where(\"oidc_id = ?\", oidcId).Find(&User{}).RowsAffected == 1\n}\n\nfunc IsUsernameAlreadyTaken(username string) bool {\n\treturn DB.Where(\"username = ?\", username).Find(&User{}).RowsAffected == 1\n}\n\nfunc ResetUserPasswordByEmail(email string, password string) error {\n\tif email == \"\" || password == \"\" {\n\t\treturn errors.New(\"邮箱地址或密码为空！\")\n\t}\n\thashedPassword, err := common.Password2Hash(password)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = DB.Model(&User{}).Where(\"email = ?\", email).Update(\"password\", hashedPassword).Error\n\treturn err\n}\n\nfunc IsAdmin(userId int) bool {\n\tif userId == 0 {\n\t\treturn false\n\t}\n\tvar user User\n\terr := DB.Where(\"id = ?\", userId).Select(\"role\").Find(&user).Error\n\tif err != nil {\n\t\tlogger.SysError(\"no such user \" + err.Error())\n\t\treturn false\n\t}\n\treturn user.Role >= RoleAdminUser\n}\n\nfunc IsUserEnabled(userId int) (bool, error) {\n\tif userId == 0 {\n\t\treturn false, errors.New(\"user id is empty\")\n\t}\n\tvar user User\n\terr := DB.Where(\"id = ?\", userId).Select(\"status\").Find(&user).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn user.Status == UserStatusEnabled, nil\n}\n\nfunc ValidateAccessToken(token string) (user *User) {\n\tif token == \"\" {\n\t\treturn nil\n\t}\n\ttoken = strings.Replace(token, \"Bearer \", \"\", 1)\n\tuser = &User{}\n\tif DB.Where(\"access_token = ?\", token).First(user).RowsAffected == 1 {\n\t\treturn user\n\t}\n\treturn nil\n}\n\nfunc GetUserQuota(id int) (quota int64, err error) {\n\terr = DB.Model(&User{}).Where(\"id = ?\", id).Select(\"quota\").Find(&quota).Error\n\treturn quota, err\n}\n\nfunc GetUserUsedQuota(id int) (quota int64, err error) {\n\terr = DB.Model(&User{}).Where(\"id = ?\", id).Select(\"used_quota\").Find(&quota).Error\n\treturn quota, err\n}\n\nfunc GetUserEmail(id int) (email string, err error) {\n\terr = DB.Model(&User{}).Where(\"id = ?\", id).Select(\"email\").Find(&email).Error\n\treturn email, err\n}\n\nfunc GetUserGroup(id int) (group string, err error) {\n\tgroupCol := \"`group`\"\n\tif common.UsingPostgreSQL {\n\t\tgroupCol = `\"group\"`\n\t}\n\n\terr = DB.Model(&User{}).Where(\"id = ?\", id).Select(groupCol).Find(&group).Error\n\treturn group, err\n}\n\nfunc IncreaseUserQuota(id int, quota int64) (err error) {\n\tif quota < 0 {\n\t\treturn errors.New(\"quota 不能为负数！\")\n\t}\n\tif config.BatchUpdateEnabled {\n\t\taddNewRecord(BatchUpdateTypeUserQuota, id, quota)\n\t\treturn nil\n\t}\n\treturn increaseUserQuota(id, quota)\n}\n\nfunc increaseUserQuota(id int, quota int64) (err error) {\n\terr = DB.Model(&User{}).Where(\"id = ?\", id).Update(\"quota\", gorm.Expr(\"quota + ?\", quota)).Error\n\treturn err\n}\n\nfunc DecreaseUserQuota(id int, quota int64) (err error) {\n\tif quota < 0 {\n\t\treturn errors.New(\"quota 不能为负数！\")\n\t}\n\tif config.BatchUpdateEnabled {\n\t\taddNewRecord(BatchUpdateTypeUserQuota, id, -quota)\n\t\treturn nil\n\t}\n\treturn decreaseUserQuota(id, quota)\n}\n\nfunc decreaseUserQuota(id int, quota int64) (err error) {\n\terr = DB.Model(&User{}).Where(\"id = ?\", id).Update(\"quota\", gorm.Expr(\"quota - ?\", quota)).Error\n\treturn err\n}\n\nfunc GetRootUserEmail() (email string) {\n\tDB.Model(&User{}).Where(\"role = ?\", RoleRootUser).Select(\"email\").Find(&email)\n\treturn email\n}\n\nfunc UpdateUserUsedQuotaAndRequestCount(id int, quota int64) {\n\tif config.BatchUpdateEnabled {\n\t\taddNewRecord(BatchUpdateTypeUsedQuota, id, quota)\n\t\taddNewRecord(BatchUpdateTypeRequestCount, id, 1)\n\t\treturn\n\t}\n\tupdateUserUsedQuotaAndRequestCount(id, quota, 1)\n}\n\nfunc updateUserUsedQuotaAndRequestCount(id int, quota int64, count int) {\n\terr := DB.Model(&User{}).Where(\"id = ?\", id).Updates(\n\t\tmap[string]interface{}{\n\t\t\t\"used_quota\":    gorm.Expr(\"used_quota + ?\", quota),\n\t\t\t\"request_count\": gorm.Expr(\"request_count + ?\", count),\n\t\t},\n\t).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update user used quota and request count: \" + err.Error())\n\t}\n}\n\nfunc updateUserUsedQuota(id int, quota int64) {\n\terr := DB.Model(&User{}).Where(\"id = ?\", id).Updates(\n\t\tmap[string]interface{}{\n\t\t\t\"used_quota\": gorm.Expr(\"used_quota + ?\", quota),\n\t\t},\n\t).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update user used quota: \" + err.Error())\n\t}\n}\n\nfunc updateUserRequestCount(id int, count int) {\n\terr := DB.Model(&User{}).Where(\"id = ?\", id).Update(\"request_count\", gorm.Expr(\"request_count + ?\", count)).Error\n\tif err != nil {\n\t\tlogger.SysError(\"failed to update user request count: \" + err.Error())\n\t}\n}\n\nfunc GetUsernameById(id int) (username string) {\n\tDB.Model(&User{}).Where(\"id = ?\", id).Select(\"username\").Find(&username)\n\treturn username\n}\n"
  },
  {
    "path": "model/utils.go",
    "content": "package model\n\nimport (\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"sync\"\n\t\"time\"\n)\n\nconst (\n\tBatchUpdateTypeUserQuota = iota\n\tBatchUpdateTypeTokenQuota\n\tBatchUpdateTypeUsedQuota\n\tBatchUpdateTypeChannelUsedQuota\n\tBatchUpdateTypeRequestCount\n\tBatchUpdateTypeCount // if you add a new type, you need to add a new map and a new lock\n)\n\nvar batchUpdateStores []map[int]int64\nvar batchUpdateLocks []sync.Mutex\n\nfunc init() {\n\tfor i := 0; i < BatchUpdateTypeCount; i++ {\n\t\tbatchUpdateStores = append(batchUpdateStores, make(map[int]int64))\n\t\tbatchUpdateLocks = append(batchUpdateLocks, sync.Mutex{})\n\t}\n}\n\nfunc InitBatchUpdater() {\n\tgo func() {\n\t\tfor {\n\t\t\ttime.Sleep(time.Duration(config.BatchUpdateInterval) * time.Second)\n\t\t\tbatchUpdate()\n\t\t}\n\t}()\n}\n\nfunc addNewRecord(type_ int, id int, value int64) {\n\tbatchUpdateLocks[type_].Lock()\n\tdefer batchUpdateLocks[type_].Unlock()\n\tif _, ok := batchUpdateStores[type_][id]; !ok {\n\t\tbatchUpdateStores[type_][id] = value\n\t} else {\n\t\tbatchUpdateStores[type_][id] += value\n\t}\n}\n\nfunc batchUpdate() {\n\tlogger.SysLog(\"batch update started\")\n\tfor i := 0; i < BatchUpdateTypeCount; i++ {\n\t\tbatchUpdateLocks[i].Lock()\n\t\tstore := batchUpdateStores[i]\n\t\tbatchUpdateStores[i] = make(map[int]int64)\n\t\tbatchUpdateLocks[i].Unlock()\n\t\t// TODO: maybe we can combine updates with same key?\n\t\tfor key, value := range store {\n\t\t\tswitch i {\n\t\t\tcase BatchUpdateTypeUserQuota:\n\t\t\t\terr := increaseUserQuota(key, value)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"failed to batch update user quota: \" + err.Error())\n\t\t\t\t}\n\t\t\tcase BatchUpdateTypeTokenQuota:\n\t\t\t\terr := increaseTokenQuota(key, value)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"failed to batch update token quota: \" + err.Error())\n\t\t\t\t}\n\t\t\tcase BatchUpdateTypeUsedQuota:\n\t\t\t\tupdateUserUsedQuota(key, value)\n\t\t\tcase BatchUpdateTypeRequestCount:\n\t\t\t\tupdateUserRequestCount(key, int(value))\n\t\t\tcase BatchUpdateTypeChannelUsedQuota:\n\t\t\t\tupdateChannelUsedQuota(key, value)\n\t\t\t}\n\t\t}\n\t}\n\tlogger.SysLog(\"batch update finished\")\n}\n"
  },
  {
    "path": "monitor/channel.go",
    "content": "package monitor\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/message\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\nfunc notifyRootUser(subject string, content string) {\n\tif config.MessagePusherAddress != \"\" {\n\t\terr := message.SendMessage(subject, content, content)\n\t\tif err != nil {\n\t\t\tlogger.SysError(fmt.Sprintf(\"failed to send message: %s\", err.Error()))\n\t\t} else {\n\t\t\treturn\n\t\t}\n\t}\n\tif config.RootUserEmail == \"\" {\n\t\tconfig.RootUserEmail = model.GetRootUserEmail()\n\t}\n\terr := message.SendEmail(subject, config.RootUserEmail, content)\n\tif err != nil {\n\t\tlogger.SysError(fmt.Sprintf(\"failed to send email: %s\", err.Error()))\n\t}\n}\n\n// DisableChannel disable & notify\nfunc DisableChannel(channelId int, channelName string, reason string) {\n\tmodel.UpdateChannelStatusById(channelId, model.ChannelStatusAutoDisabled)\n\tlogger.SysLog(fmt.Sprintf(\"channel #%d has been disabled: %s\", channelId, reason))\n\tsubject := fmt.Sprintf(\"渠道状态变更提醒\")\n\tcontent := message.EmailTemplate(\n\t\tsubject,\n\t\tfmt.Sprintf(`\n\t\t\t<p>您好！</p>\n\t\t\t<p>渠道「<strong>%s</strong>」（#%d）已被禁用。</p>\n\t\t\t<p>禁用原因：</p>\n\t\t\t<p style=\"background-color: #f8f8f8; padding: 10px; border-radius: 4px;\">%s</p>\n\t\t`, channelName, channelId, reason),\n\t)\n\tnotifyRootUser(subject, content)\n}\n\nfunc MetricDisableChannel(channelId int, successRate float64) {\n\tmodel.UpdateChannelStatusById(channelId, model.ChannelStatusAutoDisabled)\n\tlogger.SysLog(fmt.Sprintf(\"channel #%d has been disabled due to low success rate: %.2f\", channelId, successRate*100))\n\tsubject := fmt.Sprintf(\"渠道状态变更提醒\")\n\tcontent := message.EmailTemplate(\n\t\tsubject,\n\t\tfmt.Sprintf(`\n\t\t\t<p>您好！</p>\n\t\t\t<p>渠道 #%d 已被系统自动禁用。</p>\n\t\t\t<p>禁用原因：</p>\n\t\t\t<p style=\"background-color: #f8f8f8; padding: 10px; border-radius: 4px;\">该渠道在最近 %d 次调用中成功率为 <strong>%.2f%%</strong>，低于系统阈值 <strong>%.2f%%</strong>。</p>\n\t\t`, channelId, config.MetricQueueSize, successRate*100, config.MetricSuccessRateThreshold*100),\n\t)\n\tnotifyRootUser(subject, content)\n}\n\n// EnableChannel enable & notify\nfunc EnableChannel(channelId int, channelName string) {\n\tmodel.UpdateChannelStatusById(channelId, model.ChannelStatusEnabled)\n\tlogger.SysLog(fmt.Sprintf(\"channel #%d has been enabled\", channelId))\n\tsubject := fmt.Sprintf(\"渠道状态变更提醒\")\n\tcontent := message.EmailTemplate(\n\t\tsubject,\n\t\tfmt.Sprintf(`\n\t\t\t<p>您好！</p>\n\t\t\t<p>渠道「<strong>%s</strong>」（#%d）已被重新启用。</p>\n\t\t\t<p>您现在可以继续使用该渠道了。</p>\n\t\t`, channelName, channelId),\n\t)\n\tnotifyRootUser(subject, content)\n}\n"
  },
  {
    "path": "monitor/manage.go",
    "content": "package monitor\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ShouldDisableChannel(err *model.Error, statusCode int) bool {\n\tif !config.AutomaticDisableChannelEnabled {\n\t\treturn false\n\t}\n\tif err == nil {\n\t\treturn false\n\t}\n\tif statusCode == http.StatusUnauthorized {\n\t\treturn true\n\t}\n\tswitch err.Type {\n\tcase \"insufficient_quota\", \"authentication_error\", \"permission_error\", \"forbidden\":\n\t\treturn true\n\t}\n\tif err.Code == \"invalid_api_key\" || err.Code == \"account_deactivated\" {\n\t\treturn true\n\t}\n\n\tlowerMessage := strings.ToLower(err.Message)\n\tif strings.Contains(lowerMessage, \"your access was terminated\") ||\n\t\tstrings.Contains(lowerMessage, \"violation of our policies\") ||\n\t\tstrings.Contains(lowerMessage, \"your credit balance is too low\") ||\n\t\tstrings.Contains(lowerMessage, \"organization has been disabled\") ||\n\t\tstrings.Contains(lowerMessage, \"credit\") ||\n\t\tstrings.Contains(lowerMessage, \"balance\") ||\n\t\tstrings.Contains(lowerMessage, \"permission denied\") ||\n\t\tstrings.Contains(lowerMessage, \"organization has been restricted\") || // groq\n\t\tstrings.Contains(lowerMessage, \"api key not valid\") || // gemini\n\t\tstrings.Contains(lowerMessage, \"api key expired\") || // gemini\n\t\tstrings.Contains(lowerMessage, \"已欠费\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc ShouldEnableChannel(err error, openAIErr *model.Error) bool {\n\tif !config.AutomaticEnableChannelEnabled {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn false\n\t}\n\tif openAIErr != nil {\n\t\treturn false\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "monitor/metric.go",
    "content": "package monitor\n\nimport (\n\t\"github.com/songquanpeng/one-api/common/config\"\n)\n\nvar store = make(map[int][]bool)\nvar metricSuccessChan = make(chan int, config.MetricSuccessChanSize)\nvar metricFailChan = make(chan int, config.MetricFailChanSize)\n\nfunc consumeSuccess(channelId int) {\n\tif len(store[channelId]) > config.MetricQueueSize {\n\t\tstore[channelId] = store[channelId][1:]\n\t}\n\tstore[channelId] = append(store[channelId], true)\n}\n\nfunc consumeFail(channelId int) (bool, float64) {\n\tif len(store[channelId]) > config.MetricQueueSize {\n\t\tstore[channelId] = store[channelId][1:]\n\t}\n\tstore[channelId] = append(store[channelId], false)\n\tsuccessCount := 0\n\tfor _, success := range store[channelId] {\n\t\tif success {\n\t\t\tsuccessCount++\n\t\t}\n\t}\n\tsuccessRate := float64(successCount) / float64(len(store[channelId]))\n\tif len(store[channelId]) < config.MetricQueueSize {\n\t\treturn false, successRate\n\t}\n\tif successRate < config.MetricSuccessRateThreshold {\n\t\tstore[channelId] = make([]bool, 0)\n\t\treturn true, successRate\n\t}\n\treturn false, successRate\n}\n\nfunc metricSuccessConsumer() {\n\tfor {\n\t\tselect {\n\t\tcase channelId := <-metricSuccessChan:\n\t\t\tconsumeSuccess(channelId)\n\t\t}\n\t}\n}\n\nfunc metricFailConsumer() {\n\tfor {\n\t\tselect {\n\t\tcase channelId := <-metricFailChan:\n\t\t\tdisable, successRate := consumeFail(channelId)\n\t\t\tif disable {\n\t\t\t\tgo MetricDisableChannel(channelId, successRate)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc init() {\n\tif config.EnableMetric {\n\t\tgo metricSuccessConsumer()\n\t\tgo metricFailConsumer()\n\t}\n}\n\nfunc Emit(channelId int, success bool) {\n\tif !config.EnableMetric {\n\t\treturn\n\t}\n\tgo func() {\n\t\tif success {\n\t\t\tmetricSuccessChan <- channelId\n\t\t} else {\n\t\t\tmetricFailChan <- channelId\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "one-api.service",
    "content": "# File path: /etc/systemd/system/one-api.service\n# sudo systemctl daemon-reload\n# sudo systemctl start one-api\n# sudo systemctl enable one-api\n# sudo systemctl status one-api\n[Unit]\nDescription=One API Service\nAfter=network.target\n\n[Service]\nUser=ubuntu  # 注意修改用户名\nWorkingDirectory=/path/to/one-api  # 注意修改路径\nExecStart=/path/to/one-api/one-api --port 3000 --log-dir /path/to/one-api/logs  # 注意修改路径和端口号\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "pull_request_template.md",
    "content": "[//]: # (请按照以下格式关联 issue)\n[//]: # (请在提交 PR 前确认所提交的功能可用，需要附上截图，谢谢)\n[//]: # (项目维护者一般仅在周末处理 PR，因此如若未能及时回复希望能理解)\n[//]: # (开发者交流群：910657413)\n[//]: # (请在提交 PR 之前删除上面的注释)\n\nclose #issue_number\n\n我已确认该 PR 已自测通过，相关截图如下：\n（此处放上测试通过的截图，如果不涉及前端改动或从 UI 上无法看出，请放终端启动成功的截图）\n"
  },
  {
    "path": "relay/adaptor/ai360/constants.go",
    "content": "package ai360\n\nvar ModelList = []string{\n\t\"360GPT_S2_V9\",\n\t\"embedding-bert-512-v1\",\n\t\"embedding_s1_v1\",\n\t\"semantic_similarity_s1_v1\",\n}\n"
  },
  {
    "path": "relay/adaptor/aiproxy/adaptor.go",
    "content": "package aiproxy\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Adaptor struct {\n\tmeta *meta.Meta\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn fmt.Sprintf(\"%s/api/library/ask\", meta.BaseURL), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\taiProxyLibraryRequest := ConvertRequest(*request)\n\taiProxyLibraryRequest.LibraryId = a.meta.Config.LibraryID\n\treturn aiProxyLibraryRequest, nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\terr, usage = Handler(c, resp)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"aiproxy\"\n}\n"
  },
  {
    "path": "relay/adaptor/aiproxy/constants.go",
    "content": "package aiproxy\n\nimport \"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\nvar ModelList = []string{\"\"}\n\nfunc init() {\n\tModelList = openai.ModelList\n}\n"
  },
  {
    "path": "relay/adaptor/aiproxy/main.go",
    "content": "package aiproxy\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://docs.aiproxy.io/dev/library#使用已经定制好的知识库进行对话问答\n\nfunc ConvertRequest(request model.GeneralOpenAIRequest) *LibraryRequest {\n\tquery := \"\"\n\tif len(request.Messages) != 0 {\n\t\tquery = request.Messages[len(request.Messages)-1].StringContent()\n\t}\n\treturn &LibraryRequest{\n\t\tModel:  request.Model,\n\t\tStream: request.Stream,\n\t\tQuery:  query,\n\t}\n}\n\nfunc aiProxyDocuments2Markdown(documents []LibraryDocument) string {\n\tif len(documents) == 0 {\n\t\treturn \"\"\n\t}\n\tcontent := \"\\n\\n参考文档：\\n\"\n\tfor i, document := range documents {\n\t\tcontent += fmt.Sprintf(\"%d. [%s](%s)\\n\", i+1, document.Title, document.URL)\n\t}\n\treturn content\n}\n\nfunc responseAIProxyLibrary2OpenAI(response *LibraryResponse) *openai.TextResponse {\n\tcontent := response.Answer + aiProxyDocuments2Markdown(response.Documents)\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: content,\n\t\t},\n\t\tFinishReason: \"stop\",\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc documentsAIProxyLibrary(documents []LibraryDocument) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = aiProxyDocuments2Markdown(documents)\n\tchoice.FinishReason = &constant.StopFinishReason\n\treturn &openai.ChatCompletionsStreamResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   \"\",\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n}\n\nfunc streamResponseAIProxyLibrary2OpenAI(response *LibraryStreamResponse) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = response.Content\n\treturn &openai.ChatCompletionsStreamResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   response.Model,\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar usage model.Usage\n\tvar documents []LibraryDocument\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {\n\t\tif atEOF && len(data) == 0 {\n\t\t\treturn 0, nil, nil\n\t\t}\n\t\tif i := strings.Index(string(data), \"\\n\"); i >= 0 {\n\t\t\treturn i + 1, data[0:i], nil\n\t\t}\n\t\tif atEOF {\n\t\t\treturn len(data), data, nil\n\t\t}\n\t\treturn 0, nil, nil\n\t})\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < 5 || data[:5] != \"data:\" {\n\t\t\tcontinue\n\t\t}\n\t\tdata = data[5:]\n\n\t\tvar AIProxyLibraryResponse LibraryStreamResponse\n\t\terr := json.Unmarshal([]byte(data), &AIProxyLibraryResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tif len(AIProxyLibraryResponse.Documents) != 0 {\n\t\t\tdocuments = AIProxyLibraryResponse.Documents\n\t\t}\n\t\tresponse := streamResponseAIProxyLibrary2OpenAI(&AIProxyLibraryResponse)\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\tresponse := documentsAIProxyLibrary(documents)\n\terr := render.ObjectData(c, response)\n\tif err != nil {\n\t\tlogger.SysError(err.Error())\n\t}\n\trender.Done(c)\n\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\treturn nil, &usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar AIProxyLibraryResponse LibraryResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &AIProxyLibraryResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif AIProxyLibraryResponse.ErrCode != 0 {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: AIProxyLibraryResponse.Message,\n\t\t\t\tType:    strconv.Itoa(AIProxyLibraryResponse.ErrCode),\n\t\t\t\tCode:    AIProxyLibraryResponse.ErrCode,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseAIProxyLibrary2OpenAI(&AIProxyLibraryResponse)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"write_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\treturn nil, &fullTextResponse.Usage\n}\n"
  },
  {
    "path": "relay/adaptor/aiproxy/model.go",
    "content": "package aiproxy\n\ntype LibraryRequest struct {\n\tModel     string `json:\"model\"`\n\tQuery     string `json:\"query\"`\n\tLibraryId string `json:\"libraryId\"`\n\tStream    bool   `json:\"stream\"`\n}\n\ntype LibraryError struct {\n\tErrCode int    `json:\"errCode\"`\n\tMessage string `json:\"message\"`\n}\n\ntype LibraryDocument struct {\n\tTitle string `json:\"title\"`\n\tURL   string `json:\"url\"`\n}\n\ntype LibraryResponse struct {\n\tSuccess   bool              `json:\"success\"`\n\tAnswer    string            `json:\"answer\"`\n\tDocuments []LibraryDocument `json:\"documents\"`\n\tLibraryError\n}\n\ntype LibraryStreamResponse struct {\n\tContent   string            `json:\"content\"`\n\tFinish    bool              `json:\"finish\"`\n\tModel     string            `json:\"model\"`\n\tDocuments []LibraryDocument `json:\"documents\"`\n}\n"
  },
  {
    "path": "relay/adaptor/ali/adaptor.go",
    "content": "package ali\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n\t\"io\"\n\t\"net/http\"\n)\n\n// https://help.aliyun.com/zh/dashscope/developer-reference/api-details\n\ntype Adaptor struct {\n\tmeta *meta.Meta\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tfullRequestURL := \"\"\n\tswitch meta.Mode {\n\tcase relaymode.Embeddings:\n\t\tfullRequestURL = fmt.Sprintf(\"%s/api/v1/services/embeddings/text-embedding/text-embedding\", meta.BaseURL)\n\tcase relaymode.ImagesGenerations:\n\t\tfullRequestURL = fmt.Sprintf(\"%s/api/v1/services/aigc/text2image/image-synthesis\", meta.BaseURL)\n\tdefault:\n\t\tfullRequestURL = fmt.Sprintf(\"%s/api/v1/services/aigc/text-generation/generation\", meta.BaseURL)\n\t}\n\n\treturn fullRequestURL, nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\tif meta.IsStream {\n\t\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treq.Header.Set(\"X-DashScope-SSE\", \"enable\")\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\n\tif meta.Mode == relaymode.ImagesGenerations {\n\t\treq.Header.Set(\"X-DashScope-Async\", \"enable\")\n\t}\n\tif a.meta.Config.Plugin != \"\" {\n\t\treq.Header.Set(\"X-DashScope-Plugin\", a.meta.Config.Plugin)\n\t}\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Embeddings:\n\t\taliEmbeddingRequest := ConvertEmbeddingRequest(*request)\n\t\treturn aliEmbeddingRequest, nil\n\tdefault:\n\t\taliRequest := ConvertRequest(*request)\n\t\treturn aliRequest, nil\n\t}\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\taliRequest := ConvertImageRequest(*request)\n\treturn aliRequest, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.Embeddings:\n\t\t\terr, usage = EmbeddingHandler(c, resp)\n\t\tcase relaymode.ImagesGenerations:\n\t\t\terr, usage = ImageHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = Handler(c, resp)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"ali\"\n}\n"
  },
  {
    "path": "relay/adaptor/ali/constants.go",
    "content": "package ali\n\nvar ModelList = []string{\n\t\"qwen-turbo\", \"qwen-turbo-latest\",\n\t\"qwen-plus\", \"qwen-plus-latest\",\n\t\"qwen-max\", \"qwen-max-latest\",\n\t\"qwen-max-longcontext\",\n\t\"qwen-vl-max\", \"qwen-vl-max-latest\", \"qwen-vl-plus\", \"qwen-vl-plus-latest\",\n\t\"qwen-vl-ocr\", \"qwen-vl-ocr-latest\",\n\t\"qwen-audio-turbo\",\n\t\"qwen-math-plus\", \"qwen-math-plus-latest\", \"qwen-math-turbo\", \"qwen-math-turbo-latest\",\n\t\"qwen-coder-plus\", \"qwen-coder-plus-latest\", \"qwen-coder-turbo\", \"qwen-coder-turbo-latest\",\n\t\"qwq-32b-preview\", \"qwen2.5-72b-instruct\", \"qwen2.5-32b-instruct\", \"qwen2.5-14b-instruct\", \"qwen2.5-7b-instruct\", \"qwen2.5-3b-instruct\", \"qwen2.5-1.5b-instruct\", \"qwen2.5-0.5b-instruct\",\n\t\"qwen2-72b-instruct\", \"qwen2-57b-a14b-instruct\", \"qwen2-7b-instruct\", \"qwen2-1.5b-instruct\", \"qwen2-0.5b-instruct\",\n\t\"qwen1.5-110b-chat\", \"qwen1.5-72b-chat\", \"qwen1.5-32b-chat\", \"qwen1.5-14b-chat\", \"qwen1.5-7b-chat\", \"qwen1.5-1.8b-chat\", \"qwen1.5-0.5b-chat\",\n\t\"qwen-72b-chat\", \"qwen-14b-chat\", \"qwen-7b-chat\", \"qwen-1.8b-chat\", \"qwen-1.8b-longcontext-chat\",\n\t\"qvq-72b-preview\",\n\t\"qwen2.5-vl-72b-instruct\", \"qwen2.5-vl-7b-instruct\", \"qwen2.5-vl-2b-instruct\", \"qwen2.5-vl-1b-instruct\", \"qwen2.5-vl-0.5b-instruct\",\n\t\"qwen2-vl-7b-instruct\", \"qwen2-vl-2b-instruct\", \"qwen-vl-v1\", \"qwen-vl-chat-v1\",\n\t\"qwen2-audio-instruct\", \"qwen-audio-chat\",\n\t\"qwen2.5-math-72b-instruct\", \"qwen2.5-math-7b-instruct\", \"qwen2.5-math-1.5b-instruct\", \"qwen2-math-72b-instruct\", \"qwen2-math-7b-instruct\", \"qwen2-math-1.5b-instruct\",\n\t\"qwen2.5-coder-32b-instruct\", \"qwen2.5-coder-14b-instruct\", \"qwen2.5-coder-7b-instruct\", \"qwen2.5-coder-3b-instruct\", \"qwen2.5-coder-1.5b-instruct\", \"qwen2.5-coder-0.5b-instruct\",\n\t\"text-embedding-v1\", \"text-embedding-v3\", \"text-embedding-v2\", \"text-embedding-async-v2\", \"text-embedding-async-v1\",\n\t\"ali-stable-diffusion-xl\", \"ali-stable-diffusion-v1.5\", \"wanx-v1\",\n\t\"qwen-mt-plus\", \"qwen-mt-turbo\",\n\t\"deepseek-r1\", \"deepseek-v3\", \"deepseek-r1-distill-qwen-1.5b\", \"deepseek-r1-distill-qwen-7b\", \"deepseek-r1-distill-qwen-14b\", \"deepseek-r1-distill-qwen-32b\", \"deepseek-r1-distill-llama-8b\", \"deepseek-r1-distill-llama-70b\",\n}\n"
  },
  {
    "path": "relay/adaptor/ali/image.go",
    "content": "package ali\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc ImageHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tapiKey := c.Request.Header.Get(\"Authorization\")\n\tapiKey = strings.TrimPrefix(apiKey, \"Bearer \")\n\tresponseFormat := c.GetString(\"response_format\")\n\n\tvar aliTaskResponse TaskResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &aliTaskResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tif aliTaskResponse.Message != \"\" {\n\t\tlogger.SysError(\"aliAsyncTask err: \" + string(responseBody))\n\t\treturn openai.ErrorWrapper(errors.New(aliTaskResponse.Message), \"ali_async_task_failed\", http.StatusInternalServerError), nil\n\t}\n\n\taliResponse, _, err := asyncTaskWait(aliTaskResponse.Output.TaskId, apiKey)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"ali_async_task_wait_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tif aliResponse.Output.TaskStatus != \"SUCCEEDED\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: aliResponse.Output.Message,\n\t\t\t\tType:    \"ali_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    aliResponse.Output.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\n\tfullTextResponse := responseAli2OpenAIImage(aliResponse, responseFormat)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, nil\n}\n\nfunc asyncTask(taskID string, key string) (*TaskResponse, error, []byte) {\n\turl := fmt.Sprintf(\"https://dashscope.aliyuncs.com/api/v1/tasks/%s\", taskID)\n\n\tvar aliResponse TaskResponse\n\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn &aliResponse, err, nil\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Bearer \"+key)\n\n\tclient := &http.Client{}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tlogger.SysError(\"aliAsyncTask client.Do err: \" + err.Error())\n\t\treturn &aliResponse, err, nil\n\t}\n\tdefer resp.Body.Close()\n\n\tresponseBody, err := io.ReadAll(resp.Body)\n\n\tvar response TaskResponse\n\terr = json.Unmarshal(responseBody, &response)\n\tif err != nil {\n\t\tlogger.SysError(\"aliAsyncTask NewDecoder err: \" + err.Error())\n\t\treturn &aliResponse, err, nil\n\t}\n\n\treturn &response, nil, responseBody\n}\n\nfunc asyncTaskWait(taskID string, key string) (*TaskResponse, []byte, error) {\n\twaitSeconds := 2\n\tstep := 0\n\tmaxStep := 20\n\n\tvar taskResponse TaskResponse\n\tvar responseBody []byte\n\n\tfor {\n\t\tstep++\n\t\trsp, err, body := asyncTask(taskID, key)\n\t\tresponseBody = body\n\t\tif err != nil {\n\t\t\treturn &taskResponse, responseBody, err\n\t\t}\n\n\t\tif rsp.Output.TaskStatus == \"\" {\n\t\t\treturn &taskResponse, responseBody, nil\n\t\t}\n\n\t\tswitch rsp.Output.TaskStatus {\n\t\tcase \"FAILED\":\n\t\t\tfallthrough\n\t\tcase \"CANCELED\":\n\t\t\tfallthrough\n\t\tcase \"SUCCEEDED\":\n\t\t\tfallthrough\n\t\tcase \"UNKNOWN\":\n\t\t\treturn rsp, responseBody, nil\n\t\t}\n\t\tif step >= maxStep {\n\t\t\tbreak\n\t\t}\n\t\ttime.Sleep(time.Duration(waitSeconds) * time.Second)\n\t}\n\n\treturn nil, nil, fmt.Errorf(\"aliAsyncTaskWait timeout\")\n}\n\nfunc responseAli2OpenAIImage(response *TaskResponse, responseFormat string) *openai.ImageResponse {\n\timageResponse := openai.ImageResponse{\n\t\tCreated: helper.GetTimestamp(),\n\t}\n\n\tfor _, data := range response.Output.Results {\n\t\tvar b64Json string\n\t\tif responseFormat == \"b64_json\" {\n\t\t\t// 读取 data.Url 的图片数据并转存到 b64Json\n\t\t\timageData, err := getImageData(data.Url)\n\t\t\tif err != nil {\n\t\t\t\t// 处理获取图片数据失败的情况\n\t\t\t\tlogger.SysError(\"getImageData Error getting image data: \" + err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 将图片数据转为 Base64 编码的字符串\n\t\t\tb64Json = Base64Encode(imageData)\n\t\t} else {\n\t\t\t// 如果 responseFormat 不是 \"b64_json\"，则直接使用 data.B64Image\n\t\t\tb64Json = data.B64Image\n\t\t}\n\n\t\timageResponse.Data = append(imageResponse.Data, openai.ImageData{\n\t\t\tUrl:           data.Url,\n\t\t\tB64Json:       b64Json,\n\t\t\tRevisedPrompt: \"\",\n\t\t})\n\t}\n\treturn &imageResponse\n}\n\nfunc getImageData(url string) ([]byte, error) {\n\tresponse, err := http.Get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer response.Body.Close()\n\n\timageData, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn imageData, nil\n}\n\nfunc Base64Encode(data []byte) string {\n\tb64Json := base64.StdEncoding.EncodeToString(data)\n\treturn b64Json\n}\n"
  },
  {
    "path": "relay/adaptor/ali/main.go",
    "content": "package ali\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r\n\nconst EnableSearchModelSuffix = \"-internet\"\n\nfunc ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest {\n\tmessages := make([]Message, 0, len(request.Messages))\n\tfor i := 0; i < len(request.Messages); i++ {\n\t\tmessage := request.Messages[i]\n\t\tmessages = append(messages, Message{\n\t\t\tContent: message.StringContent(),\n\t\t\tRole:    strings.ToLower(message.Role),\n\t\t})\n\t}\n\tenableSearch := false\n\taliModel := request.Model\n\tif strings.HasSuffix(aliModel, EnableSearchModelSuffix) {\n\t\tenableSearch = true\n\t\taliModel = strings.TrimSuffix(aliModel, EnableSearchModelSuffix)\n\t}\n\trequest.TopP = helper.Float64PtrMax(request.TopP, 0.9999)\n\treturn &ChatRequest{\n\t\tModel: aliModel,\n\t\tInput: Input{\n\t\t\tMessages: messages,\n\t\t},\n\t\tParameters: Parameters{\n\t\t\tEnableSearch:      enableSearch,\n\t\t\tIncrementalOutput: request.Stream,\n\t\t\tSeed:              uint64(request.Seed),\n\t\t\tMaxTokens:         request.MaxTokens,\n\t\t\tTemperature:       request.Temperature,\n\t\t\tTopP:              request.TopP,\n\t\t\tTopK:              request.TopK,\n\t\t\tResultFormat:      \"message\",\n\t\t\tTools:             request.Tools,\n\t\t},\n\t}\n}\n\nfunc ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *EmbeddingRequest {\n\treturn &EmbeddingRequest{\n\t\tModel: request.Model,\n\t\tInput: struct {\n\t\t\tTexts []string `json:\"texts\"`\n\t\t}{\n\t\t\tTexts: request.ParseInput(),\n\t\t},\n\t}\n}\n\nfunc ConvertImageRequest(request model.ImageRequest) *ImageRequest {\n\tvar imageRequest ImageRequest\n\timageRequest.Input.Prompt = request.Prompt\n\timageRequest.Model = request.Model\n\timageRequest.Parameters.Size = strings.Replace(request.Size, \"x\", \"*\", -1)\n\timageRequest.Parameters.N = request.N\n\timageRequest.ResponseFormat = request.ResponseFormat\n\n\treturn &imageRequest\n}\n\nfunc EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar aliResponse EmbeddingResponse\n\terr := json.NewDecoder(resp.Body).Decode(&aliResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tif aliResponse.Code != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: aliResponse.Message,\n\t\t\t\tType:    aliResponse.Code,\n\t\t\t\tParam:   aliResponse.RequestId,\n\t\t\t\tCode:    aliResponse.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\trequestModel := c.GetString(ctxkey.RequestModel)\n\tfullTextResponse := embeddingResponseAli2OpenAI(&aliResponse)\n\tfullTextResponse.Model = requestModel\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc embeddingResponseAli2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {\n\topenAIEmbeddingResponse := openai.EmbeddingResponse{\n\t\tObject: \"list\",\n\t\tData:   make([]openai.EmbeddingResponseItem, 0, len(response.Output.Embeddings)),\n\t\tModel:  \"text-embedding-v1\",\n\t\tUsage:  model.Usage{TotalTokens: response.Usage.TotalTokens},\n\t}\n\n\tfor _, item := range response.Output.Embeddings {\n\t\topenAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{\n\t\t\tObject:    `embedding`,\n\t\t\tIndex:     item.TextIndex,\n\t\t\tEmbedding: item.Embedding,\n\t\t})\n\t}\n\treturn &openAIEmbeddingResponse\n}\n\nfunc responseAli2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      response.RequestId,\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: response.Output.Choices,\n\t\tUsage: model.Usage{\n\t\t\tPromptTokens:     response.Usage.InputTokens,\n\t\t\tCompletionTokens: response.Usage.OutputTokens,\n\t\t\tTotalTokens:      response.Usage.InputTokens + response.Usage.OutputTokens,\n\t\t},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseAli2OpenAI(aliResponse *ChatResponse) *openai.ChatCompletionsStreamResponse {\n\tif len(aliResponse.Output.Choices) == 0 {\n\t\treturn nil\n\t}\n\taliChoice := aliResponse.Output.Choices[0]\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta = aliChoice.Message\n\tif aliChoice.FinishReason != \"null\" {\n\t\tfinishReason := aliChoice.FinishReason\n\t\tchoice.FinishReason = &finishReason\n\t}\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tId:      aliResponse.RequestId,\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   \"qwen\",\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &response\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar usage model.Usage\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {\n\t\tif atEOF && len(data) == 0 {\n\t\t\treturn 0, nil, nil\n\t\t}\n\t\tif i := strings.Index(string(data), \"\\n\"); i >= 0 {\n\t\t\treturn i + 1, data[0:i], nil\n\t\t}\n\t\tif atEOF {\n\t\t\treturn len(data), data, nil\n\t\t}\n\t\treturn 0, nil, nil\n\t})\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < 5 || data[:5] != \"data:\" {\n\t\t\tcontinue\n\t\t}\n\t\tdata = data[5:]\n\n\t\tvar aliResponse ChatResponse\n\t\terr := json.Unmarshal([]byte(data), &aliResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tif aliResponse.Usage.OutputTokens != 0 {\n\t\t\tusage.PromptTokens = aliResponse.Usage.InputTokens\n\t\t\tusage.CompletionTokens = aliResponse.Usage.OutputTokens\n\t\t\tusage.TotalTokens = aliResponse.Usage.InputTokens + aliResponse.Usage.OutputTokens\n\t\t}\n\t\tresponse := streamResponseAli2OpenAI(&aliResponse)\n\t\tif response == nil {\n\t\t\tcontinue\n\t\t}\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\treturn nil, &usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tctx := c.Request.Context()\n\tvar aliResponse ChatResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tlogger.Debugf(ctx, \"response body: %s\\n\", responseBody)\n\terr = json.Unmarshal(responseBody, &aliResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif aliResponse.Code != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: aliResponse.Message,\n\t\t\t\tType:    aliResponse.Code,\n\t\t\t\tParam:   aliResponse.RequestId,\n\t\t\t\tCode:    aliResponse.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseAli2OpenAI(&aliResponse)\n\tfullTextResponse.Model = \"qwen\"\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n"
  },
  {
    "path": "relay/adaptor/ali/model.go",
    "content": "package ali\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype Message struct {\n\tContent string `json:\"content\"`\n\tRole    string `json:\"role\"`\n}\n\ntype Input struct {\n\t//Prompt   string       `json:\"prompt\"`\n\tMessages []Message `json:\"messages\"`\n}\n\ntype Parameters struct {\n\tTopP              *float64     `json:\"top_p,omitempty\"`\n\tTopK              int          `json:\"top_k,omitempty\"`\n\tSeed              uint64       `json:\"seed,omitempty\"`\n\tEnableSearch      bool         `json:\"enable_search,omitempty\"`\n\tIncrementalOutput bool         `json:\"incremental_output,omitempty\"`\n\tMaxTokens         int          `json:\"max_tokens,omitempty\"`\n\tTemperature       *float64     `json:\"temperature,omitempty\"`\n\tResultFormat      string       `json:\"result_format,omitempty\"`\n\tTools             []model.Tool `json:\"tools,omitempty\"`\n}\n\ntype ChatRequest struct {\n\tModel      string     `json:\"model\"`\n\tInput      Input      `json:\"input\"`\n\tParameters Parameters `json:\"parameters,omitempty\"`\n}\n\ntype ImageRequest struct {\n\tModel string `json:\"model\"`\n\tInput struct {\n\t\tPrompt         string `json:\"prompt\"`\n\t\tNegativePrompt string `json:\"negative_prompt,omitempty\"`\n\t} `json:\"input\"`\n\tParameters struct {\n\t\tSize  string `json:\"size,omitempty\"`\n\t\tN     int    `json:\"n,omitempty\"`\n\t\tSteps string `json:\"steps,omitempty\"`\n\t\tScale string `json:\"scale,omitempty\"`\n\t} `json:\"parameters,omitempty\"`\n\tResponseFormat string `json:\"response_format,omitempty\"`\n}\n\ntype TaskResponse struct {\n\tStatusCode int    `json:\"status_code,omitempty\"`\n\tRequestId  string `json:\"request_id,omitempty\"`\n\tCode       string `json:\"code,omitempty\"`\n\tMessage    string `json:\"message,omitempty\"`\n\tOutput     struct {\n\t\tTaskId     string `json:\"task_id,omitempty\"`\n\t\tTaskStatus string `json:\"task_status,omitempty\"`\n\t\tCode       string `json:\"code,omitempty\"`\n\t\tMessage    string `json:\"message,omitempty\"`\n\t\tResults    []struct {\n\t\t\tB64Image string `json:\"b64_image,omitempty\"`\n\t\t\tUrl      string `json:\"url,omitempty\"`\n\t\t\tCode     string `json:\"code,omitempty\"`\n\t\t\tMessage  string `json:\"message,omitempty\"`\n\t\t} `json:\"results,omitempty\"`\n\t\tTaskMetrics struct {\n\t\t\tTotal     int `json:\"TOTAL,omitempty\"`\n\t\t\tSucceeded int `json:\"SUCCEEDED,omitempty\"`\n\t\t\tFailed    int `json:\"FAILED,omitempty\"`\n\t\t} `json:\"task_metrics,omitempty\"`\n\t} `json:\"output,omitempty\"`\n\tUsage Usage `json:\"usage\"`\n}\n\ntype Header struct {\n\tAction       string `json:\"action,omitempty\"`\n\tStreaming    string `json:\"streaming,omitempty\"`\n\tTaskID       string `json:\"task_id,omitempty\"`\n\tEvent        string `json:\"event,omitempty\"`\n\tErrorCode    string `json:\"error_code,omitempty\"`\n\tErrorMessage string `json:\"error_message,omitempty\"`\n\tAttributes   any    `json:\"attributes,omitempty\"`\n}\n\ntype Payload struct {\n\tModel      string `json:\"model,omitempty\"`\n\tTask       string `json:\"task,omitempty\"`\n\tTaskGroup  string `json:\"task_group,omitempty\"`\n\tFunction   string `json:\"function,omitempty\"`\n\tParameters struct {\n\t\tSampleRate int     `json:\"sample_rate,omitempty\"`\n\t\tRate       float64 `json:\"rate,omitempty\"`\n\t\tFormat     string  `json:\"format,omitempty\"`\n\t} `json:\"parameters,omitempty\"`\n\tInput struct {\n\t\tText string `json:\"text,omitempty\"`\n\t} `json:\"input,omitempty\"`\n\tUsage struct {\n\t\tCharacters int `json:\"characters,omitempty\"`\n\t} `json:\"usage,omitempty\"`\n}\n\ntype WSSMessage struct {\n\tHeader  Header  `json:\"header,omitempty\"`\n\tPayload Payload `json:\"payload,omitempty\"`\n}\n\ntype EmbeddingRequest struct {\n\tModel string `json:\"model\"`\n\tInput struct {\n\t\tTexts []string `json:\"texts\"`\n\t} `json:\"input\"`\n\tParameters *struct {\n\t\tTextType string `json:\"text_type,omitempty\"`\n\t} `json:\"parameters,omitempty\"`\n}\n\ntype Embedding struct {\n\tEmbedding []float64 `json:\"embedding\"`\n\tTextIndex int       `json:\"text_index\"`\n}\n\ntype EmbeddingResponse struct {\n\tOutput struct {\n\t\tEmbeddings []Embedding `json:\"embeddings\"`\n\t} `json:\"output\"`\n\tUsage Usage `json:\"usage\"`\n\tError\n}\n\ntype Error struct {\n\tCode      string `json:\"code\"`\n\tMessage   string `json:\"message\"`\n\tRequestId string `json:\"request_id\"`\n}\n\ntype Usage struct {\n\tInputTokens  int `json:\"input_tokens\"`\n\tOutputTokens int `json:\"output_tokens\"`\n\tTotalTokens  int `json:\"total_tokens\"`\n}\n\ntype Output struct {\n\t//Text         string                      `json:\"text\"`\n\t//FinishReason string                      `json:\"finish_reason\"`\n\tChoices []openai.TextResponseChoice `json:\"choices\"`\n}\n\ntype ChatResponse struct {\n\tOutput Output `json:\"output\"`\n\tUsage  Usage  `json:\"usage\"`\n\tError\n}\n"
  },
  {
    "path": "relay/adaptor/alibailian/constants.go",
    "content": "package alibailian\n\n// https://help.aliyun.com/zh/model-studio/getting-started/models\n\nvar ModelList = []string{\n\t\"qwen-turbo\",\n\t\"qwen-plus\",\n\t\"qwen-long\",\n\t\"qwen-max\",\n\t\"qwen-coder-plus\",\n\t\"qwen-coder-plus-latest\",\n\t\"qwen-coder-turbo\",\n\t\"qwen-coder-turbo-latest\",\n\t\"qwen-mt-plus\",\n\t\"qwen-mt-turbo\",\n\t\"qwq-32b-preview\",\n\n\t\"deepseek-r1\",\n\t\"deepseek-v3\",\n}\n"
  },
  {
    "path": "relay/adaptor/alibailian/main.go",
    "content": "package alibailian\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc GetRequestURL(meta *meta.Meta) (string, error) {\n\tswitch meta.Mode {\n\tcase relaymode.ChatCompletions:\n\t\treturn fmt.Sprintf(\"%s/compatible-mode/v1/chat/completions\", meta.BaseURL), nil\n\tcase relaymode.Embeddings:\n\t\treturn fmt.Sprintf(\"%s/compatible-mode/v1/embeddings\", meta.BaseURL), nil\n\tdefault:\n\t}\n\treturn \"\", fmt.Errorf(\"unsupported relay mode %d for ali bailian\", meta.Mode)\n}\n"
  },
  {
    "path": "relay/adaptor/anthropic/adaptor.go",
    "content": "package anthropic\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn fmt.Sprintf(\"%s/v1/messages\", meta.BaseURL), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"x-api-key\", meta.APIKey)\n\tanthropicVersion := c.Request.Header.Get(\"anthropic-version\")\n\tif anthropicVersion == \"\" {\n\t\tanthropicVersion = \"2023-06-01\"\n\t}\n\treq.Header.Set(\"anthropic-version\", anthropicVersion)\n\treq.Header.Set(\"anthropic-beta\", \"messages-2023-12-15\")\n\n\t// https://x.com/alexalbert__/status/1812921642143900036\n\t// claude-3-5-sonnet can support 8k context\n\tif strings.HasPrefix(meta.ActualModelName, \"claude-3-5-sonnet\") {\n\t\treq.Header.Set(\"anthropic-beta\", \"max-tokens-3-5-sonnet-2024-07-15\")\n\t}\n\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn ConvertRequest(*request), nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\terr, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"anthropic\"\n}\n"
  },
  {
    "path": "relay/adaptor/anthropic/constants.go",
    "content": "package anthropic\n\nvar ModelList = []string{\n\t\"claude-instant-1.2\", \"claude-2.0\", \"claude-2.1\",\n\t\"claude-3-haiku-20240307\",\n\t\"claude-3-5-haiku-20241022\",\n\t\"claude-3-5-haiku-latest\",\n\t\"claude-3-sonnet-20240229\",\n\t\"claude-3-opus-20240229\",\n\t\"claude-3-5-sonnet-20240620\",\n\t\"claude-3-5-sonnet-20241022\",\n\t\"claude-3-5-sonnet-latest\",\n}\n"
  },
  {
    "path": "relay/adaptor/anthropic/main.go",
    "content": "package anthropic\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/image\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc stopReasonClaude2OpenAI(reason *string) string {\n\tif reason == nil {\n\t\treturn \"\"\n\t}\n\tswitch *reason {\n\tcase \"end_turn\":\n\t\treturn \"stop\"\n\tcase \"stop_sequence\":\n\t\treturn \"stop\"\n\tcase \"max_tokens\":\n\t\treturn \"length\"\n\tcase \"tool_use\":\n\t\treturn \"tool_calls\"\n\tdefault:\n\t\treturn *reason\n\t}\n}\n\nfunc ConvertRequest(textRequest model.GeneralOpenAIRequest) *Request {\n\tclaudeTools := make([]Tool, 0, len(textRequest.Tools))\n\n\tfor _, tool := range textRequest.Tools {\n\t\tif params, ok := tool.Function.Parameters.(map[string]any); ok {\n\t\t\tclaudeTools = append(claudeTools, Tool{\n\t\t\t\tName:        tool.Function.Name,\n\t\t\t\tDescription: tool.Function.Description,\n\t\t\t\tInputSchema: InputSchema{\n\t\t\t\t\tType:       params[\"type\"].(string),\n\t\t\t\t\tProperties: params[\"properties\"],\n\t\t\t\t\tRequired:   params[\"required\"],\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\tclaudeRequest := Request{\n\t\tModel:       textRequest.Model,\n\t\tMaxTokens:   textRequest.MaxTokens,\n\t\tTemperature: textRequest.Temperature,\n\t\tTopP:        textRequest.TopP,\n\t\tTopK:        textRequest.TopK,\n\t\tStream:      textRequest.Stream,\n\t\tTools:       claudeTools,\n\t}\n\tif len(claudeTools) > 0 {\n\t\tclaudeToolChoice := struct {\n\t\t\tType string `json:\"type\"`\n\t\t\tName string `json:\"name,omitempty\"`\n\t\t}{Type: \"auto\"} // default value https://docs.anthropic.com/en/docs/build-with-claude/tool-use#controlling-claudes-output\n\t\tif choice, ok := textRequest.ToolChoice.(map[string]any); ok {\n\t\t\tif function, ok := choice[\"function\"].(map[string]any); ok {\n\t\t\t\tclaudeToolChoice.Type = \"tool\"\n\t\t\t\tclaudeToolChoice.Name = function[\"name\"].(string)\n\t\t\t}\n\t\t} else if toolChoiceType, ok := textRequest.ToolChoice.(string); ok {\n\t\t\tif toolChoiceType == \"any\" {\n\t\t\t\tclaudeToolChoice.Type = toolChoiceType\n\t\t\t}\n\t\t}\n\t\tclaudeRequest.ToolChoice = claudeToolChoice\n\t}\n\tif claudeRequest.MaxTokens == 0 {\n\t\tclaudeRequest.MaxTokens = 4096\n\t}\n\t// legacy model name mapping\n\tif claudeRequest.Model == \"claude-instant-1\" {\n\t\tclaudeRequest.Model = \"claude-instant-1.1\"\n\t} else if claudeRequest.Model == \"claude-2\" {\n\t\tclaudeRequest.Model = \"claude-2.1\"\n\t}\n\tfor _, message := range textRequest.Messages {\n\t\tif message.Role == \"system\" && claudeRequest.System == \"\" {\n\t\t\tclaudeRequest.System = message.StringContent()\n\t\t\tcontinue\n\t\t}\n\t\tclaudeMessage := Message{\n\t\t\tRole: message.Role,\n\t\t}\n\t\tvar content Content\n\t\tif message.IsStringContent() {\n\t\t\tcontent.Type = \"text\"\n\t\t\tcontent.Text = message.StringContent()\n\t\t\tif message.Role == \"tool\" {\n\t\t\t\tclaudeMessage.Role = \"user\"\n\t\t\t\tcontent.Type = \"tool_result\"\n\t\t\t\tcontent.Content = content.Text\n\t\t\t\tcontent.Text = \"\"\n\t\t\t\tcontent.ToolUseId = message.ToolCallId\n\t\t\t}\n\t\t\tclaudeMessage.Content = append(claudeMessage.Content, content)\n\t\t\tfor i := range message.ToolCalls {\n\t\t\t\tinputParam := make(map[string]any)\n\t\t\t\t_ = json.Unmarshal([]byte(message.ToolCalls[i].Function.Arguments.(string)), &inputParam)\n\t\t\t\tclaudeMessage.Content = append(claudeMessage.Content, Content{\n\t\t\t\t\tType:  \"tool_use\",\n\t\t\t\t\tId:    message.ToolCalls[i].Id,\n\t\t\t\t\tName:  message.ToolCalls[i].Function.Name,\n\t\t\t\t\tInput: inputParam,\n\t\t\t\t})\n\t\t\t}\n\t\t\tclaudeRequest.Messages = append(claudeRequest.Messages, claudeMessage)\n\t\t\tcontinue\n\t\t}\n\t\tvar contents []Content\n\t\topenaiContent := message.ParseContent()\n\t\tfor _, part := range openaiContent {\n\t\t\tvar content Content\n\t\t\tif part.Type == model.ContentTypeText {\n\t\t\t\tcontent.Type = \"text\"\n\t\t\t\tcontent.Text = part.Text\n\t\t\t} else if part.Type == model.ContentTypeImageURL {\n\t\t\t\tcontent.Type = \"image\"\n\t\t\t\tcontent.Source = &ImageSource{\n\t\t\t\t\tType: \"base64\",\n\t\t\t\t}\n\t\t\t\tmimeType, data, _ := image.GetImageFromUrl(part.ImageURL.Url)\n\t\t\t\tcontent.Source.MediaType = mimeType\n\t\t\t\tcontent.Source.Data = data\n\t\t\t}\n\t\t\tcontents = append(contents, content)\n\t\t}\n\t\tclaudeMessage.Content = contents\n\t\tclaudeRequest.Messages = append(claudeRequest.Messages, claudeMessage)\n\t}\n\treturn &claudeRequest\n}\n\n// https://docs.anthropic.com/claude/reference/messages-streaming\nfunc StreamResponseClaude2OpenAI(claudeResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) {\n\tvar response *Response\n\tvar responseText string\n\tvar stopReason string\n\ttools := make([]model.Tool, 0)\n\n\tswitch claudeResponse.Type {\n\tcase \"message_start\":\n\t\treturn nil, claudeResponse.Message\n\tcase \"content_block_start\":\n\t\tif claudeResponse.ContentBlock != nil {\n\t\t\tresponseText = claudeResponse.ContentBlock.Text\n\t\t\tif claudeResponse.ContentBlock.Type == \"tool_use\" {\n\t\t\t\ttools = append(tools, model.Tool{\n\t\t\t\t\tId:   claudeResponse.ContentBlock.Id,\n\t\t\t\t\tType: \"function\",\n\t\t\t\t\tFunction: model.Function{\n\t\t\t\t\t\tName:      claudeResponse.ContentBlock.Name,\n\t\t\t\t\t\tArguments: \"\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase \"content_block_delta\":\n\t\tif claudeResponse.Delta != nil {\n\t\t\tresponseText = claudeResponse.Delta.Text\n\t\t\tif claudeResponse.Delta.Type == \"input_json_delta\" {\n\t\t\t\ttools = append(tools, model.Tool{\n\t\t\t\t\tFunction: model.Function{\n\t\t\t\t\t\tArguments: claudeResponse.Delta.PartialJson,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase \"message_delta\":\n\t\tif claudeResponse.Usage != nil {\n\t\t\tresponse = &Response{\n\t\t\t\tUsage: *claudeResponse.Usage,\n\t\t\t}\n\t\t}\n\t\tif claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {\n\t\t\tstopReason = *claudeResponse.Delta.StopReason\n\t\t}\n\t}\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = responseText\n\tif len(tools) > 0 {\n\t\tchoice.Delta.Content = nil // compatible with other OpenAI derivative applications, like LobeOpenAICompatibleFactory ...\n\t\tchoice.Delta.ToolCalls = tools\n\t}\n\tchoice.Delta.Role = \"assistant\"\n\tfinishReason := stopReasonClaude2OpenAI(&stopReason)\n\tif finishReason != \"null\" {\n\t\tchoice.FinishReason = &finishReason\n\t}\n\tvar openaiResponse openai.ChatCompletionsStreamResponse\n\topenaiResponse.Object = \"chat.completion.chunk\"\n\topenaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice}\n\treturn &openaiResponse, response\n}\n\nfunc ResponseClaude2OpenAI(claudeResponse *Response) *openai.TextResponse {\n\tvar responseText string\n\tif len(claudeResponse.Content) > 0 {\n\t\tresponseText = claudeResponse.Content[0].Text\n\t}\n\ttools := make([]model.Tool, 0)\n\tfor _, v := range claudeResponse.Content {\n\t\tif v.Type == \"tool_use\" {\n\t\t\targs, _ := json.Marshal(v.Input)\n\t\t\ttools = append(tools, model.Tool{\n\t\t\t\tId:   v.Id,\n\t\t\t\tType: \"function\", // compatible with other OpenAI derivative applications\n\t\t\t\tFunction: model.Function{\n\t\t\t\t\tName:      v.Name,\n\t\t\t\t\tArguments: string(args),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:      \"assistant\",\n\t\t\tContent:   responseText,\n\t\t\tName:      nil,\n\t\t\tToolCalls: tools,\n\t\t},\n\t\tFinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason),\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", claudeResponse.Id),\n\t\tModel:   claudeResponse.Model,\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tcreatedTime := helper.GetTimestamp()\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {\n\t\tif atEOF && len(data) == 0 {\n\t\t\treturn 0, nil, nil\n\t\t}\n\t\tif i := strings.Index(string(data), \"\\n\"); i >= 0 {\n\t\t\treturn i + 1, data[0:i], nil\n\t\t}\n\t\tif atEOF {\n\t\t\treturn len(data), data, nil\n\t\t}\n\t\treturn 0, nil, nil\n\t})\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tvar usage model.Usage\n\tvar modelName string\n\tvar id string\n\tvar lastToolCallChoice openai.ChatCompletionsStreamResponseChoice\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < 6 || !strings.HasPrefix(data, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata = strings.TrimPrefix(data, \"data:\")\n\t\tdata = strings.TrimSpace(data)\n\n\t\tvar claudeResponse StreamResponse\n\t\terr := json.Unmarshal([]byte(data), &claudeResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse, meta := StreamResponseClaude2OpenAI(&claudeResponse)\n\t\tif meta != nil {\n\t\t\tusage.PromptTokens += meta.Usage.InputTokens\n\t\t\tusage.CompletionTokens += meta.Usage.OutputTokens\n\t\t\tif len(meta.Id) > 0 { // only message_start has an id, otherwise it's a finish_reason event.\n\t\t\t\tmodelName = meta.Model\n\t\t\t\tid = fmt.Sprintf(\"chatcmpl-%s\", meta.Id)\n\t\t\t\tcontinue\n\t\t\t} else { // finish_reason case\n\t\t\t\tif len(lastToolCallChoice.Delta.ToolCalls) > 0 {\n\t\t\t\t\tlastArgs := &lastToolCallChoice.Delta.ToolCalls[len(lastToolCallChoice.Delta.ToolCalls)-1].Function\n\t\t\t\t\tif len(lastArgs.Arguments.(string)) == 0 { // compatible with OpenAI sending an empty object `{}` when no arguments.\n\t\t\t\t\t\tlastArgs.Arguments = \"{}\"\n\t\t\t\t\t\tresponse.Choices[len(response.Choices)-1].Delta.Content = nil\n\t\t\t\t\t\tresponse.Choices[len(response.Choices)-1].Delta.ToolCalls = lastToolCallChoice.Delta.ToolCalls\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif response == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse.Id = id\n\t\tresponse.Model = modelName\n\t\tresponse.Created = createdTime\n\n\t\tfor _, choice := range response.Choices {\n\t\t\tif len(choice.Delta.ToolCalls) > 0 {\n\t\t\t\tlastToolCallChoice = choice\n\t\t\t}\n\t\t}\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\treturn nil, &usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar claudeResponse Response\n\terr = json.Unmarshal(responseBody, &claudeResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif claudeResponse.Error.Type != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: claudeResponse.Error.Message,\n\t\t\t\tType:    claudeResponse.Error.Type,\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    claudeResponse.Error.Type,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := ResponseClaude2OpenAI(&claudeResponse)\n\tfullTextResponse.Model = modelName\n\tusage := model.Usage{\n\t\tPromptTokens:     claudeResponse.Usage.InputTokens,\n\t\tCompletionTokens: claudeResponse.Usage.OutputTokens,\n\t\tTotalTokens:      claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens,\n\t}\n\tfullTextResponse.Usage = usage\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &usage\n}\n"
  },
  {
    "path": "relay/adaptor/anthropic/model.go",
    "content": "package anthropic\n\n// https://docs.anthropic.com/claude/reference/messages_post\n\ntype Metadata struct {\n\tUserId string `json:\"user_id\"`\n}\n\ntype ImageSource struct {\n\tType      string `json:\"type\"`\n\tMediaType string `json:\"media_type\"`\n\tData      string `json:\"data\"`\n}\n\ntype Content struct {\n\tType   string       `json:\"type\"`\n\tText   string       `json:\"text,omitempty\"`\n\tSource *ImageSource `json:\"source,omitempty\"`\n\t// tool_calls\n\tId        string `json:\"id,omitempty\"`\n\tName      string `json:\"name,omitempty\"`\n\tInput     any    `json:\"input,omitempty\"`\n\tContent   string `json:\"content,omitempty\"`\n\tToolUseId string `json:\"tool_use_id,omitempty\"`\n}\n\ntype Message struct {\n\tRole    string    `json:\"role\"`\n\tContent []Content `json:\"content\"`\n}\n\ntype Tool struct {\n\tName        string      `json:\"name\"`\n\tDescription string      `json:\"description,omitempty\"`\n\tInputSchema InputSchema `json:\"input_schema\"`\n}\n\ntype InputSchema struct {\n\tType       string `json:\"type\"`\n\tProperties any    `json:\"properties,omitempty\"`\n\tRequired   any    `json:\"required,omitempty\"`\n}\n\ntype Request struct {\n\tModel         string    `json:\"model\"`\n\tMessages      []Message `json:\"messages\"`\n\tSystem        string    `json:\"system,omitempty\"`\n\tMaxTokens     int       `json:\"max_tokens,omitempty\"`\n\tStopSequences []string  `json:\"stop_sequences,omitempty\"`\n\tStream        bool      `json:\"stream,omitempty\"`\n\tTemperature   *float64  `json:\"temperature,omitempty\"`\n\tTopP          *float64  `json:\"top_p,omitempty\"`\n\tTopK          int       `json:\"top_k,omitempty\"`\n\tTools         []Tool    `json:\"tools,omitempty\"`\n\tToolChoice    any       `json:\"tool_choice,omitempty\"`\n\t//Metadata    `json:\"metadata,omitempty\"`\n}\n\ntype Usage struct {\n\tInputTokens  int `json:\"input_tokens\"`\n\tOutputTokens int `json:\"output_tokens\"`\n}\n\ntype Error struct {\n\tType    string `json:\"type\"`\n\tMessage string `json:\"message\"`\n}\n\ntype Response struct {\n\tId           string    `json:\"id\"`\n\tType         string    `json:\"type\"`\n\tRole         string    `json:\"role\"`\n\tContent      []Content `json:\"content\"`\n\tModel        string    `json:\"model\"`\n\tStopReason   *string   `json:\"stop_reason\"`\n\tStopSequence *string   `json:\"stop_sequence\"`\n\tUsage        Usage     `json:\"usage\"`\n\tError        Error     `json:\"error\"`\n}\n\ntype Delta struct {\n\tType         string  `json:\"type\"`\n\tText         string  `json:\"text\"`\n\tPartialJson  string  `json:\"partial_json,omitempty\"`\n\tStopReason   *string `json:\"stop_reason\"`\n\tStopSequence *string `json:\"stop_sequence\"`\n}\n\ntype StreamResponse struct {\n\tType         string    `json:\"type\"`\n\tMessage      *Response `json:\"message\"`\n\tIndex        int       `json:\"index\"`\n\tContentBlock *Content  `json:\"content_block\"`\n\tDelta        *Delta    `json:\"delta\"`\n\tUsage        *Usage    `json:\"usage\"`\n}\n"
  },
  {
    "path": "relay/adaptor/aws/adaptor.go",
    "content": "package aws\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/credentials\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws/utils\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar _ adaptor.Adaptor = new(Adaptor)\n\ntype Adaptor struct {\n\tawsAdapter utils.AwsAdapter\n\n\tMeta      *meta.Meta\n\tAwsClient *bedrockruntime.Client\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.Meta = meta\n\ta.AwsClient = bedrockruntime.New(bedrockruntime.Options{\n\t\tRegion:      meta.Config.Region,\n\t\tCredentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(meta.Config.AK, meta.Config.SK, \"\")),\n\t})\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\tadaptor := GetAdaptor(request.Model)\n\tif adaptor == nil {\n\t\treturn nil, errors.New(\"adaptor not found\")\n\t}\n\n\ta.awsAdapter = adaptor\n\treturn adaptor.ConvertRequest(c, relayMode, request)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif a.awsAdapter == nil {\n\t\treturn nil, utils.WrapErr(errors.New(\"awsAdapter is nil\"))\n\t}\n\treturn a.awsAdapter.DoResponse(c, a.AwsClient, meta)\n}\n\nfunc (a *Adaptor) GetModelList() (models []string) {\n\tfor model := range adaptors {\n\t\tmodels = append(models, model)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"aws\"\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn \"\", nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn nil, nil\n}\n"
  },
  {
    "path": "relay/adaptor/aws/claude/adapter.go",
    "content": "package aws\n\nimport (\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/anthropic\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws/utils\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar _ utils.AwsAdapter = new(Adaptor)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\tclaudeReq := anthropic.ConvertRequest(*request)\n\tc.Set(ctxkey.RequestModel, request.Model)\n\tc.Set(ctxkey.ConvertedRequest, claudeReq)\n\treturn claudeReq, nil\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, awsCli *bedrockruntime.Client, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, awsCli)\n\t} else {\n\t\terr, usage = Handler(c, awsCli, meta.ActualModelName)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "relay/adaptor/aws/claude/main.go",
    "content": "// Package aws provides the AWS adaptor for the relay service.\npackage aws\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/jinzhu/copier\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/anthropic\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws/utils\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html\nvar AwsModelIDMap = map[string]string{\n\t\"claude-instant-1.2\":         \"anthropic.claude-instant-v1\",\n\t\"claude-2.0\":                 \"anthropic.claude-v2\",\n\t\"claude-2.1\":                 \"anthropic.claude-v2:1\",\n\t\"claude-3-haiku-20240307\":    \"anthropic.claude-3-haiku-20240307-v1:0\",\n\t\"claude-3-sonnet-20240229\":   \"anthropic.claude-3-sonnet-20240229-v1:0\",\n\t\"claude-3-opus-20240229\":     \"anthropic.claude-3-opus-20240229-v1:0\",\n\t\"claude-3-5-sonnet-20240620\": \"anthropic.claude-3-5-sonnet-20240620-v1:0\",\n\t\"claude-3-5-sonnet-20241022\": \"anthropic.claude-3-5-sonnet-20241022-v2:0\",\n\t\"claude-3-5-sonnet-latest\":   \"anthropic.claude-3-5-sonnet-20241022-v2:0\",\n\t\"claude-3-5-haiku-20241022\":  \"anthropic.claude-3-5-haiku-20241022-v1:0\",\n}\n\nfunc awsModelID(requestModel string) (string, error) {\n\tif awsModelID, ok := AwsModelIDMap[requestModel]; ok {\n\t\treturn awsModelID, nil\n\t}\n\n\treturn \"\", errors.Errorf(\"model %s not found\", requestModel)\n}\n\nfunc Handler(c *gin.Context, awsCli *bedrockruntime.Client, modelName string) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) {\n\tawsModelId, err := awsModelID(c.GetString(ctxkey.RequestModel))\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"awsModelID\")), nil\n\t}\n\n\tawsReq := &bedrockruntime.InvokeModelInput{\n\t\tModelId:     aws.String(awsModelId),\n\t\tAccept:      aws.String(\"application/json\"),\n\t\tContentType: aws.String(\"application/json\"),\n\t}\n\n\tclaudeReq_, ok := c.Get(ctxkey.ConvertedRequest)\n\tif !ok {\n\t\treturn utils.WrapErr(errors.New(\"request not found\")), nil\n\t}\n\tclaudeReq := claudeReq_.(*anthropic.Request)\n\tawsClaudeReq := &Request{\n\t\tAnthropicVersion: \"bedrock-2023-05-31\",\n\t}\n\tif err = copier.Copy(awsClaudeReq, claudeReq); err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"copy request\")), nil\n\t}\n\n\tawsReq.Body, err = json.Marshal(awsClaudeReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"marshal request\")), nil\n\t}\n\n\tawsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"InvokeModel\")), nil\n\t}\n\n\tclaudeResponse := new(anthropic.Response)\n\terr = json.Unmarshal(awsResp.Body, claudeResponse)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"unmarshal response\")), nil\n\t}\n\n\topenaiResp := anthropic.ResponseClaude2OpenAI(claudeResponse)\n\topenaiResp.Model = modelName\n\tusage := relaymodel.Usage{\n\t\tPromptTokens:     claudeResponse.Usage.InputTokens,\n\t\tCompletionTokens: claudeResponse.Usage.OutputTokens,\n\t\tTotalTokens:      claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens,\n\t}\n\topenaiResp.Usage = usage\n\n\tc.JSON(http.StatusOK, openaiResp)\n\treturn nil, &usage\n}\n\nfunc StreamHandler(c *gin.Context, awsCli *bedrockruntime.Client) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) {\n\tcreatedTime := helper.GetTimestamp()\n\tawsModelId, err := awsModelID(c.GetString(ctxkey.RequestModel))\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"awsModelID\")), nil\n\t}\n\n\tawsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{\n\t\tModelId:     aws.String(awsModelId),\n\t\tAccept:      aws.String(\"application/json\"),\n\t\tContentType: aws.String(\"application/json\"),\n\t}\n\n\tclaudeReq_, ok := c.Get(ctxkey.ConvertedRequest)\n\tif !ok {\n\t\treturn utils.WrapErr(errors.New(\"request not found\")), nil\n\t}\n\tclaudeReq := claudeReq_.(*anthropic.Request)\n\n\tawsClaudeReq := &Request{\n\t\tAnthropicVersion: \"bedrock-2023-05-31\",\n\t}\n\tif err = copier.Copy(awsClaudeReq, claudeReq); err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"copy request\")), nil\n\t}\n\tawsReq.Body, err = json.Marshal(awsClaudeReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"marshal request\")), nil\n\t}\n\n\tawsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"InvokeModelWithResponseStream\")), nil\n\t}\n\tstream := awsResp.GetStream()\n\tdefer stream.Close()\n\n\tc.Writer.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tvar usage relaymodel.Usage\n\tvar id string\n\tvar lastToolCallChoice openai.ChatCompletionsStreamResponseChoice\n\n\tc.Stream(func(w io.Writer) bool {\n\t\tevent, ok := <-stream.Events()\n\t\tif !ok {\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: [DONE]\"})\n\t\t\treturn false\n\t\t}\n\n\t\tswitch v := event.(type) {\n\t\tcase *types.ResponseStreamMemberChunk:\n\t\t\tclaudeResp := new(anthropic.StreamResponse)\n\t\t\terr := json.NewDecoder(bytes.NewReader(v.Value.Bytes)).Decode(claudeResp)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\tresponse, meta := anthropic.StreamResponseClaude2OpenAI(claudeResp)\n\t\t\tif meta != nil {\n\t\t\t\tusage.PromptTokens += meta.Usage.InputTokens\n\t\t\t\tusage.CompletionTokens += meta.Usage.OutputTokens\n\t\t\t\tif len(meta.Id) > 0 { // only message_start has an id, otherwise it's a finish_reason event.\n\t\t\t\t\tid = fmt.Sprintf(\"chatcmpl-%s\", meta.Id)\n\t\t\t\t\treturn true\n\t\t\t\t} else { // finish_reason case\n\t\t\t\t\tif len(lastToolCallChoice.Delta.ToolCalls) > 0 {\n\t\t\t\t\t\tlastArgs := &lastToolCallChoice.Delta.ToolCalls[len(lastToolCallChoice.Delta.ToolCalls)-1].Function\n\t\t\t\t\t\tif len(lastArgs.Arguments.(string)) == 0 { // compatible with OpenAI sending an empty object `{}` when no arguments.\n\t\t\t\t\t\t\tlastArgs.Arguments = \"{}\"\n\t\t\t\t\t\t\tresponse.Choices[len(response.Choices)-1].Delta.Content = nil\n\t\t\t\t\t\t\tresponse.Choices[len(response.Choices)-1].Delta.ToolCalls = lastToolCallChoice.Delta.ToolCalls\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif response == nil {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tresponse.Id = id\n\t\t\tresponse.Model = c.GetString(ctxkey.OriginalModel)\n\t\t\tresponse.Created = createdTime\n\n\t\t\tfor _, choice := range response.Choices {\n\t\t\t\tif len(choice.Delta.ToolCalls) > 0 {\n\t\t\t\t\tlastToolCallChoice = choice\n\t\t\t\t}\n\t\t\t}\n\t\t\tjsonStr, err := json.Marshal(response)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error marshalling stream response: \" + err.Error())\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: \" + string(jsonStr)})\n\t\t\treturn true\n\t\tcase *types.UnknownUnionMember:\n\t\t\tfmt.Println(\"unknown tag:\", v.Tag)\n\t\t\treturn false\n\t\tdefault:\n\t\t\tfmt.Println(\"union is nil or unknown type\")\n\t\t\treturn false\n\t\t}\n\t})\n\n\treturn nil, &usage\n}\n"
  },
  {
    "path": "relay/adaptor/aws/claude/model.go",
    "content": "package aws\n\nimport \"github.com/songquanpeng/one-api/relay/adaptor/anthropic\"\n\n// Request is the request to AWS Claude\n//\n// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html\ntype Request struct {\n\t// AnthropicVersion should be \"bedrock-2023-05-31\"\n\tAnthropicVersion string              `json:\"anthropic_version\"`\n\tMessages         []anthropic.Message `json:\"messages\"`\n\tSystem           string              `json:\"system,omitempty\"`\n\tMaxTokens        int                 `json:\"max_tokens,omitempty\"`\n\tTemperature      *float64            `json:\"temperature,omitempty\"`\n\tTopP             *float64            `json:\"top_p,omitempty\"`\n\tTopK             int                 `json:\"top_k,omitempty\"`\n\tStopSequences    []string            `json:\"stop_sequences,omitempty\"`\n\tTools            []anthropic.Tool    `json:\"tools,omitempty\"`\n\tToolChoice       any                 `json:\"tool_choice,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/aws/llama3/adapter.go",
    "content": "package aws\n\nimport (\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws/utils\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar _ utils.AwsAdapter = new(Adaptor)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\tllamaReq := ConvertRequest(*request)\n\tc.Set(ctxkey.RequestModel, request.Model)\n\tc.Set(ctxkey.ConvertedRequest, llamaReq)\n\treturn llamaReq, nil\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, awsCli *bedrockruntime.Client, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, awsCli)\n\t} else {\n\t\terr, usage = Handler(c, awsCli, meta.ActualModelName)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "relay/adaptor/aws/llama3/main.go",
    "content": "// Package aws provides the AWS adaptor for the relay service.\npackage aws\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"text/template\"\n\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws/utils\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// Only support llama-3-8b and llama-3-70b instruction models\n// https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html\nvar AwsModelIDMap = map[string]string{\n\t\"llama3-8b-8192\":  \"meta.llama3-8b-instruct-v1:0\",\n\t\"llama3-70b-8192\": \"meta.llama3-70b-instruct-v1:0\",\n}\n\nfunc awsModelID(requestModel string) (string, error) {\n\tif awsModelID, ok := AwsModelIDMap[requestModel]; ok {\n\t\treturn awsModelID, nil\n\t}\n\n\treturn \"\", errors.Errorf(\"model %s not found\", requestModel)\n}\n\n// promptTemplate with range\nconst promptTemplate = `<|begin_of_text|>{{range .Messages}}<|start_header_id|>{{.Role}}<|end_header_id|>{{.StringContent}}<|eot_id|>{{end}}<|start_header_id|>assistant<|end_header_id|>\n`\n\nvar promptTpl = template.Must(template.New(\"llama3-chat\").Parse(promptTemplate))\n\nfunc RenderPrompt(messages []relaymodel.Message) string {\n\tvar buf bytes.Buffer\n\terr := promptTpl.Execute(&buf, struct{ Messages []relaymodel.Message }{messages})\n\tif err != nil {\n\t\tlogger.SysError(\"error rendering prompt messages: \" + err.Error())\n\t}\n\treturn buf.String()\n}\n\nfunc ConvertRequest(textRequest relaymodel.GeneralOpenAIRequest) *Request {\n\tllamaRequest := Request{\n\t\tMaxGenLen:   textRequest.MaxTokens,\n\t\tTemperature: textRequest.Temperature,\n\t\tTopP:        textRequest.TopP,\n\t}\n\tif llamaRequest.MaxGenLen == 0 {\n\t\tllamaRequest.MaxGenLen = 2048\n\t}\n\tprompt := RenderPrompt(textRequest.Messages)\n\tllamaRequest.Prompt = prompt\n\treturn &llamaRequest\n}\n\nfunc Handler(c *gin.Context, awsCli *bedrockruntime.Client, modelName string) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) {\n\tawsModelId, err := awsModelID(c.GetString(ctxkey.RequestModel))\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"awsModelID\")), nil\n\t}\n\n\tawsReq := &bedrockruntime.InvokeModelInput{\n\t\tModelId:     aws.String(awsModelId),\n\t\tAccept:      aws.String(\"application/json\"),\n\t\tContentType: aws.String(\"application/json\"),\n\t}\n\n\tllamaReq, ok := c.Get(ctxkey.ConvertedRequest)\n\tif !ok {\n\t\treturn utils.WrapErr(errors.New(\"request not found\")), nil\n\t}\n\n\tawsReq.Body, err = json.Marshal(llamaReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"marshal request\")), nil\n\t}\n\n\tawsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"InvokeModel\")), nil\n\t}\n\n\tvar llamaResponse Response\n\terr = json.Unmarshal(awsResp.Body, &llamaResponse)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"unmarshal response\")), nil\n\t}\n\n\topenaiResp := ResponseLlama2OpenAI(&llamaResponse)\n\topenaiResp.Model = modelName\n\tusage := relaymodel.Usage{\n\t\tPromptTokens:     llamaResponse.PromptTokenCount,\n\t\tCompletionTokens: llamaResponse.GenerationTokenCount,\n\t\tTotalTokens:      llamaResponse.PromptTokenCount + llamaResponse.GenerationTokenCount,\n\t}\n\topenaiResp.Usage = usage\n\n\tc.JSON(http.StatusOK, openaiResp)\n\treturn nil, &usage\n}\n\nfunc ResponseLlama2OpenAI(llamaResponse *Response) *openai.TextResponse {\n\tvar responseText string\n\tif len(llamaResponse.Generation) > 0 {\n\t\tresponseText = llamaResponse.Generation\n\t}\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: relaymodel.Message{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: responseText,\n\t\t\tName:    nil,\n\t\t},\n\t\tFinishReason: llamaResponse.StopReason,\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc StreamHandler(c *gin.Context, awsCli *bedrockruntime.Client) (*relaymodel.ErrorWithStatusCode, *relaymodel.Usage) {\n\tcreatedTime := helper.GetTimestamp()\n\tawsModelId, err := awsModelID(c.GetString(ctxkey.RequestModel))\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"awsModelID\")), nil\n\t}\n\n\tawsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{\n\t\tModelId:     aws.String(awsModelId),\n\t\tAccept:      aws.String(\"application/json\"),\n\t\tContentType: aws.String(\"application/json\"),\n\t}\n\n\tllamaReq, ok := c.Get(ctxkey.ConvertedRequest)\n\tif !ok {\n\t\treturn utils.WrapErr(errors.New(\"request not found\")), nil\n\t}\n\n\tawsReq.Body, err = json.Marshal(llamaReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"marshal request\")), nil\n\t}\n\n\tawsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq)\n\tif err != nil {\n\t\treturn utils.WrapErr(errors.Wrap(err, \"InvokeModelWithResponseStream\")), nil\n\t}\n\tstream := awsResp.GetStream()\n\tdefer stream.Close()\n\n\tc.Writer.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tvar usage relaymodel.Usage\n\tc.Stream(func(w io.Writer) bool {\n\t\tevent, ok := <-stream.Events()\n\t\tif !ok {\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: [DONE]\"})\n\t\t\treturn false\n\t\t}\n\n\t\tswitch v := event.(type) {\n\t\tcase *types.ResponseStreamMemberChunk:\n\t\t\tvar llamaResp StreamResponse\n\t\t\terr := json.NewDecoder(bytes.NewReader(v.Value.Bytes)).Decode(&llamaResp)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\tif llamaResp.PromptTokenCount > 0 {\n\t\t\t\tusage.PromptTokens = llamaResp.PromptTokenCount\n\t\t\t}\n\t\t\tif llamaResp.StopReason == \"stop\" {\n\t\t\t\tusage.CompletionTokens = llamaResp.GenerationTokenCount\n\t\t\t\tusage.TotalTokens = usage.PromptTokens + usage.CompletionTokens\n\t\t\t}\n\t\t\tresponse := StreamResponseLlama2OpenAI(&llamaResp)\n\t\t\tresponse.Id = fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID())\n\t\t\tresponse.Model = c.GetString(ctxkey.OriginalModel)\n\t\t\tresponse.Created = createdTime\n\t\t\tjsonStr, err := json.Marshal(response)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error marshalling stream response: \" + err.Error())\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: \" + string(jsonStr)})\n\t\t\treturn true\n\t\tcase *types.UnknownUnionMember:\n\t\t\tfmt.Println(\"unknown tag:\", v.Tag)\n\t\t\treturn false\n\t\tdefault:\n\t\t\tfmt.Println(\"union is nil or unknown type\")\n\t\t\treturn false\n\t\t}\n\t})\n\n\treturn nil, &usage\n}\n\nfunc StreamResponseLlama2OpenAI(llamaResponse *StreamResponse) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = llamaResponse.Generation\n\tchoice.Delta.Role = \"assistant\"\n\tfinishReason := llamaResponse.StopReason\n\tif finishReason != \"null\" {\n\t\tchoice.FinishReason = &finishReason\n\t}\n\tvar openaiResponse openai.ChatCompletionsStreamResponse\n\topenaiResponse.Object = \"chat.completion.chunk\"\n\topenaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice}\n\treturn &openaiResponse\n}\n"
  },
  {
    "path": "relay/adaptor/aws/llama3/main_test.go",
    "content": "package aws_test\n\nimport (\n\t\"testing\"\n\n\taws \"github.com/songquanpeng/one-api/relay/adaptor/aws/llama3\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestRenderPrompt(t *testing.T) {\n\tmessages := []relaymodel.Message{\n\t\t{\n\t\t\tRole:    \"user\",\n\t\t\tContent: \"What's your name?\",\n\t\t},\n\t}\n\tprompt := aws.RenderPrompt(messages)\n\texpected := `<|begin_of_text|><|start_header_id|>user<|end_header_id|>What's your name?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n`\n\tassert.Equal(t, expected, prompt)\n\n\tmessages = []relaymodel.Message{\n\t\t{\n\t\t\tRole:    \"system\",\n\t\t\tContent: \"Your name is Kat. You are a detective.\",\n\t\t},\n\t\t{\n\t\t\tRole:    \"user\",\n\t\t\tContent: \"What's your name?\",\n\t\t},\n\t\t{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: \"Kat\",\n\t\t},\n\t\t{\n\t\t\tRole:    \"user\",\n\t\t\tContent: \"What's your job?\",\n\t\t},\n\t}\n\tprompt = aws.RenderPrompt(messages)\n\texpected = `<|begin_of_text|><|start_header_id|>system<|end_header_id|>Your name is Kat. You are a detective.<|eot_id|><|start_header_id|>user<|end_header_id|>What's your name?<|eot_id|><|start_header_id|>assistant<|end_header_id|>Kat<|eot_id|><|start_header_id|>user<|end_header_id|>What's your job?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n`\n\tassert.Equal(t, expected, prompt)\n}\n"
  },
  {
    "path": "relay/adaptor/aws/llama3/model.go",
    "content": "package aws\n\n// Request is the request to AWS Llama3\n//\n// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html\ntype Request struct {\n\tPrompt      string   `json:\"prompt\"`\n\tMaxGenLen   int      `json:\"max_gen_len,omitempty\"`\n\tTemperature *float64 `json:\"temperature,omitempty\"`\n\tTopP        *float64 `json:\"top_p,omitempty\"`\n}\n\n// Response is the response from AWS Llama3\n//\n// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-meta.html\ntype Response struct {\n\tGeneration           string `json:\"generation\"`\n\tPromptTokenCount     int    `json:\"prompt_token_count\"`\n\tGenerationTokenCount int    `json:\"generation_token_count\"`\n\tStopReason           string `json:\"stop_reason\"`\n}\n\n// {'generation': 'Hi', 'prompt_token_count': 15, 'generation_token_count': 1, 'stop_reason': None}\ntype StreamResponse struct {\n\tGeneration           string `json:\"generation\"`\n\tPromptTokenCount     int    `json:\"prompt_token_count\"`\n\tGenerationTokenCount int    `json:\"generation_token_count\"`\n\tStopReason           string `json:\"stop_reason\"`\n}\n"
  },
  {
    "path": "relay/adaptor/aws/registry.go",
    "content": "package aws\n\nimport (\n\tclaude \"github.com/songquanpeng/one-api/relay/adaptor/aws/claude\"\n\tllama3 \"github.com/songquanpeng/one-api/relay/adaptor/aws/llama3\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws/utils\"\n)\n\ntype AwsModelType int\n\nconst (\n\tAwsClaude AwsModelType = iota + 1\n\tAwsLlama3\n)\n\nvar (\n\tadaptors = map[string]AwsModelType{}\n)\n\nfunc init() {\n\tfor model := range claude.AwsModelIDMap {\n\t\tadaptors[model] = AwsClaude\n\t}\n\tfor model := range llama3.AwsModelIDMap {\n\t\tadaptors[model] = AwsLlama3\n\t}\n}\n\nfunc GetAdaptor(model string) utils.AwsAdapter {\n\tadaptorType := adaptors[model]\n\tswitch adaptorType {\n\tcase AwsClaude:\n\t\treturn &claude.Adaptor{}\n\tcase AwsLlama3:\n\t\treturn &llama3.Adaptor{}\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "relay/adaptor/aws/utils/adaptor.go",
    "content": "package utils\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/credentials\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype AwsAdapter interface {\n\tConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error)\n\tDoResponse(c *gin.Context, awsCli *bedrockruntime.Client, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode)\n}\n\ntype Adaptor struct {\n\tMeta      *meta.Meta\n\tAwsClient *bedrockruntime.Client\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.Meta = meta\n\ta.AwsClient = bedrockruntime.New(bedrockruntime.Options{\n\t\tRegion:      meta.Config.Region,\n\t\tCredentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(meta.Config.AK, meta.Config.SK, \"\")),\n\t})\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn \"\", nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn nil, nil\n}\n"
  },
  {
    "path": "relay/adaptor/aws/utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"net/http\"\n\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc WrapErr(err error) *relaymodel.ErrorWithStatusCode {\n\treturn &relaymodel.ErrorWithStatusCode{\n\t\tStatusCode: http.StatusInternalServerError,\n\t\tError: relaymodel.Error{\n\t\t\tMessage: err.Error(),\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "relay/adaptor/baichuan/constants.go",
    "content": "package baichuan\n\nvar ModelList = []string{\n\t\"Baichuan2-Turbo\",\n\t\"Baichuan2-Turbo-192k\",\n\t\"Baichuan-Text-Embedding\",\n}\n"
  },
  {
    "path": "relay/adaptor/baidu/adaptor.go",
    "content": "package baidu\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\t// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/clntwmv7t\n\tsuffix := \"chat/\"\n\tif strings.HasPrefix(meta.ActualModelName, \"Embedding\") {\n\t\tsuffix = \"embeddings/\"\n\t}\n\tif strings.HasPrefix(meta.ActualModelName, \"bge-large\") {\n\t\tsuffix = \"embeddings/\"\n\t}\n\tif strings.HasPrefix(meta.ActualModelName, \"tao-8k\") {\n\t\tsuffix = \"embeddings/\"\n\t}\n\tswitch meta.ActualModelName {\n\tcase \"ERNIE-4.0\":\n\t\tsuffix += \"completions_pro\"\n\tcase \"ERNIE-Bot-4\":\n\t\tsuffix += \"completions_pro\"\n\tcase \"ERNIE-Bot\":\n\t\tsuffix += \"completions\"\n\tcase \"ERNIE-Bot-turbo\":\n\t\tsuffix += \"eb-instant\"\n\tcase \"ERNIE-Speed\":\n\t\tsuffix += \"ernie_speed\"\n\tcase \"ERNIE-4.0-8K\":\n\t\tsuffix += \"completions_pro\"\n\tcase \"ERNIE-3.5-8K\":\n\t\tsuffix += \"completions\"\n\tcase \"ERNIE-3.5-8K-0205\":\n\t\tsuffix += \"ernie-3.5-8k-0205\"\n\tcase \"ERNIE-3.5-8K-1222\":\n\t\tsuffix += \"ernie-3.5-8k-1222\"\n\tcase \"ERNIE-Bot-8K\":\n\t\tsuffix += \"ernie_bot_8k\"\n\tcase \"ERNIE-3.5-4K-0205\":\n\t\tsuffix += \"ernie-3.5-4k-0205\"\n\tcase \"ERNIE-Speed-8K\":\n\t\tsuffix += \"ernie_speed\"\n\tcase \"ERNIE-Speed-128K\":\n\t\tsuffix += \"ernie-speed-128k\"\n\tcase \"ERNIE-Lite-8K-0922\":\n\t\tsuffix += \"eb-instant\"\n\tcase \"ERNIE-Lite-8K-0308\":\n\t\tsuffix += \"ernie-lite-8k\"\n\tcase \"ERNIE-Tiny-8K\":\n\t\tsuffix += \"ernie-tiny-8k\"\n\tcase \"BLOOMZ-7B\":\n\t\tsuffix += \"bloomz_7b1\"\n\tcase \"Embedding-V1\":\n\t\tsuffix += \"embedding-v1\"\n\tcase \"bge-large-zh\":\n\t\tsuffix += \"bge_large_zh\"\n\tcase \"bge-large-en\":\n\t\tsuffix += \"bge_large_en\"\n\tcase \"tao-8k\":\n\t\tsuffix += \"tao_8k\"\n\tdefault:\n\t\tsuffix += strings.ToLower(meta.ActualModelName)\n\t}\n\tfullRequestURL := fmt.Sprintf(\"%s/rpc/2.0/ai_custom/v1/wenxinworkshop/%s\", meta.BaseURL, suffix)\n\tvar accessToken string\n\tvar err error\n\tif accessToken, err = GetAccessToken(meta.APIKey); err != nil {\n\t\treturn \"\", err\n\t}\n\tfullRequestURL += \"?access_token=\" + accessToken\n\treturn fullRequestURL, nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Embeddings:\n\t\tbaiduEmbeddingRequest := ConvertEmbeddingRequest(*request)\n\t\treturn baiduEmbeddingRequest, nil\n\tdefault:\n\t\tbaiduRequest := ConvertRequest(*request)\n\t\treturn baiduRequest, nil\n\t}\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.Embeddings:\n\t\t\terr, usage = EmbeddingHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = Handler(c, resp)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"baidu\"\n}\n"
  },
  {
    "path": "relay/adaptor/baidu/constants.go",
    "content": "package baidu\n\nvar ModelList = []string{\n\t\"ERNIE-4.0-8K\",\n\t\"ERNIE-3.5-8K\",\n\t\"ERNIE-3.5-8K-0205\",\n\t\"ERNIE-3.5-8K-1222\",\n\t\"ERNIE-Bot-8K\",\n\t\"ERNIE-3.5-4K-0205\",\n\t\"ERNIE-Speed-8K\",\n\t\"ERNIE-Speed-128K\",\n\t\"ERNIE-Lite-8K-0922\",\n\t\"ERNIE-Lite-8K-0308\",\n\t\"ERNIE-Tiny-8K\",\n\t\"BLOOMZ-7B\",\n\t\"Embedding-V1\",\n\t\"bge-large-zh\",\n\t\"bge-large-en\",\n\t\"tao-8k\",\n}\n"
  },
  {
    "path": "relay/adaptor/baidu/main.go",
    "content": "package baidu\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2\n\ntype TokenResponse struct {\n\tExpiresIn   int    `json:\"expires_in\"`\n\tAccessToken string `json:\"access_token\"`\n}\n\ntype Message struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype ChatRequest struct {\n\tMessages        []Message `json:\"messages\"`\n\tTemperature     *float64  `json:\"temperature,omitempty\"`\n\tTopP            *float64  `json:\"top_p,omitempty\"`\n\tPenaltyScore    *float64  `json:\"penalty_score,omitempty\"`\n\tStream          bool      `json:\"stream,omitempty\"`\n\tSystem          string    `json:\"system,omitempty\"`\n\tDisableSearch   bool      `json:\"disable_search,omitempty\"`\n\tEnableCitation  bool      `json:\"enable_citation,omitempty\"`\n\tMaxOutputTokens int       `json:\"max_output_tokens,omitempty\"`\n\tUserId          string    `json:\"user_id,omitempty\"`\n}\n\ntype Error struct {\n\tErrorCode int    `json:\"error_code\"`\n\tErrorMsg  string `json:\"error_msg\"`\n}\n\nvar baiduTokenStore sync.Map\n\nfunc ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest {\n\tbaiduRequest := ChatRequest{\n\t\tMessages:        make([]Message, 0, len(request.Messages)),\n\t\tTemperature:     request.Temperature,\n\t\tTopP:            request.TopP,\n\t\tPenaltyScore:    request.FrequencyPenalty,\n\t\tStream:          request.Stream,\n\t\tDisableSearch:   false,\n\t\tEnableCitation:  false,\n\t\tMaxOutputTokens: request.MaxTokens,\n\t\tUserId:          request.User,\n\t}\n\tfor _, message := range request.Messages {\n\t\tif message.Role == \"system\" {\n\t\t\tbaiduRequest.System = message.StringContent()\n\t\t} else {\n\t\t\tbaiduRequest.Messages = append(baiduRequest.Messages, Message{\n\t\t\t\tRole:    message.Role,\n\t\t\t\tContent: message.StringContent(),\n\t\t\t})\n\t\t}\n\t}\n\treturn &baiduRequest\n}\n\nfunc responseBaidu2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: response.Result,\n\t\t},\n\t\tFinishReason: \"stop\",\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      response.Id,\n\t\tObject:  \"chat.completion\",\n\t\tCreated: response.Created,\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t\tUsage:   response.Usage,\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseBaidu2OpenAI(baiduResponse *ChatStreamResponse) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = baiduResponse.Result\n\tif baiduResponse.IsEnd {\n\t\tchoice.FinishReason = &constant.StopFinishReason\n\t}\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tId:      baiduResponse.Id,\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: baiduResponse.Created,\n\t\tModel:   \"ernie-bot\",\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &response\n}\n\nfunc ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *EmbeddingRequest {\n\treturn &EmbeddingRequest{\n\t\tInput: request.ParseInput(),\n\t}\n}\n\nfunc embeddingResponseBaidu2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {\n\topenAIEmbeddingResponse := openai.EmbeddingResponse{\n\t\tObject: \"list\",\n\t\tData:   make([]openai.EmbeddingResponseItem, 0, len(response.Data)),\n\t\tModel:  \"baidu-embedding\",\n\t\tUsage:  response.Usage,\n\t}\n\tfor _, item := range response.Data {\n\t\topenAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{\n\t\t\tObject:    item.Object,\n\t\t\tIndex:     item.Index,\n\t\t\tEmbedding: item.Embedding,\n\t\t})\n\t}\n\treturn &openAIEmbeddingResponse\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar usage model.Usage\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < 6 {\n\t\t\tcontinue\n\t\t}\n\t\tdata = data[6:]\n\n\t\tvar baiduResponse ChatStreamResponse\n\t\terr := json.Unmarshal([]byte(data), &baiduResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tif baiduResponse.Usage.TotalTokens != 0 {\n\t\t\tusage.TotalTokens = baiduResponse.Usage.TotalTokens\n\t\t\tusage.PromptTokens = baiduResponse.Usage.PromptTokens\n\t\t\tusage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens\n\t\t}\n\t\tresponse := streamResponseBaidu2OpenAI(&baiduResponse)\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\treturn nil, &usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar baiduResponse ChatResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &baiduResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif baiduResponse.ErrorMsg != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: baiduResponse.ErrorMsg,\n\t\t\t\tType:    \"baidu_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    baiduResponse.ErrorCode,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseBaidu2OpenAI(&baiduResponse)\n\tfullTextResponse.Model = \"ernie-bot\"\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar baiduResponse EmbeddingResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &baiduResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif baiduResponse.ErrorMsg != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: baiduResponse.ErrorMsg,\n\t\t\t\tType:    \"baidu_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    baiduResponse.ErrorCode,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc GetAccessToken(apiKey string) (string, error) {\n\tif val, ok := baiduTokenStore.Load(apiKey); ok {\n\t\tvar accessToken AccessToken\n\t\tif accessToken, ok = val.(AccessToken); ok {\n\t\t\t// soon this will expire\n\t\t\tif time.Now().Add(time.Hour).After(accessToken.ExpiresAt) {\n\t\t\t\tgo func() {\n\t\t\t\t\t_, _ = getBaiduAccessTokenHelper(apiKey)\n\t\t\t\t}()\n\t\t\t}\n\t\t\treturn accessToken.AccessToken, nil\n\t\t}\n\t}\n\taccessToken, err := getBaiduAccessTokenHelper(apiKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif accessToken == nil {\n\t\treturn \"\", errors.New(\"GetAccessToken return a nil token\")\n\t}\n\treturn (*accessToken).AccessToken, nil\n}\n\nfunc getBaiduAccessTokenHelper(apiKey string) (*AccessToken, error) {\n\tparts := strings.Split(apiKey, \"|\")\n\tif len(parts) != 2 {\n\t\treturn nil, errors.New(\"invalid baidu apikey\")\n\t}\n\treq, err := http.NewRequest(\"POST\", fmt.Sprintf(\"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s\",\n\t\tparts[0], parts[1]), nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\treq.Header.Add(\"Accept\", \"application/json\")\n\tres, err := client.ImpatientHTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer res.Body.Close()\n\n\tvar accessToken AccessToken\n\terr = json.NewDecoder(res.Body).Decode(&accessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif accessToken.Error != \"\" {\n\t\treturn nil, errors.New(accessToken.Error + \": \" + accessToken.ErrorDescription)\n\t}\n\tif accessToken.AccessToken == \"\" {\n\t\treturn nil, errors.New(\"getBaiduAccessTokenHelper get empty access token\")\n\t}\n\taccessToken.ExpiresAt = time.Now().Add(time.Duration(accessToken.ExpiresIn) * time.Second)\n\tbaiduTokenStore.Store(apiKey, accessToken)\n\treturn &accessToken, nil\n}\n"
  },
  {
    "path": "relay/adaptor/baidu/model.go",
    "content": "package baidu\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"time\"\n)\n\ntype ChatResponse struct {\n\tId               string      `json:\"id\"`\n\tObject           string      `json:\"object\"`\n\tCreated          int64       `json:\"created\"`\n\tResult           string      `json:\"result\"`\n\tIsTruncated      bool        `json:\"is_truncated\"`\n\tNeedClearHistory bool        `json:\"need_clear_history\"`\n\tUsage            model.Usage `json:\"usage\"`\n\tError\n}\n\ntype ChatStreamResponse struct {\n\tChatResponse\n\tSentenceId int  `json:\"sentence_id\"`\n\tIsEnd      bool `json:\"is_end\"`\n}\n\ntype EmbeddingRequest struct {\n\tInput []string `json:\"input\"`\n}\n\ntype EmbeddingData struct {\n\tObject    string    `json:\"object\"`\n\tEmbedding []float64 `json:\"embedding\"`\n\tIndex     int       `json:\"index\"`\n}\n\ntype EmbeddingResponse struct {\n\tId      string          `json:\"id\"`\n\tObject  string          `json:\"object\"`\n\tCreated int64           `json:\"created\"`\n\tData    []EmbeddingData `json:\"data\"`\n\tUsage   model.Usage     `json:\"usage\"`\n\tError\n}\n\ntype AccessToken struct {\n\tAccessToken      string    `json:\"access_token\"`\n\tError            string    `json:\"error,omitempty\"`\n\tErrorDescription string    `json:\"error_description,omitempty\"`\n\tExpiresIn        int64     `json:\"expires_in,omitempty\"`\n\tExpiresAt        time.Time `json:\"-\"`\n}\n"
  },
  {
    "path": "relay/adaptor/baiduv2/constants.go",
    "content": "package baiduv2\n\n// https://console.bce.baidu.com/support/?_=1692863460488&timestamp=1739074632076#/api?product=QIANFAN&project=%E5%8D%83%E5%B8%86ModelBuilder&parent=%E5%AF%B9%E8%AF%9DChat%20V2&api=v2%2Fchat%2Fcompletions&method=post\n// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu#%E6%94%AF%E6%8C%81%E6%A8%A1%E5%9E%8B%E5%88%97%E8%A1%A8\n\nvar ModelList = []string{\n\t\"ernie-4.0-8k-latest\",\n\t\"ernie-4.0-8k-preview\",\n\t\"ernie-4.0-8k\",\n\t\"ernie-4.0-turbo-8k-latest\",\n\t\"ernie-4.0-turbo-8k-preview\",\n\t\"ernie-4.0-turbo-8k\",\n\t\"ernie-4.0-turbo-128k\",\n\t\"ernie-3.5-8k-preview\",\n\t\"ernie-3.5-8k\",\n\t\"ernie-3.5-128k\",\n\t\"ernie-speed-8k\",\n\t\"ernie-speed-128k\",\n\t\"ernie-speed-pro-128k\",\n\t\"ernie-lite-8k\",\n\t\"ernie-lite-pro-128k\",\n\t\"ernie-tiny-8k\",\n\t\"ernie-char-8k\",\n\t\"ernie-char-fiction-8k\",\n\t\"ernie-novel-8k\",\n\t\"deepseek-v3\",\n\t\"deepseek-r1\",\n\t\"deepseek-r1-distill-qwen-32b\",\n\t\"deepseek-r1-distill-qwen-14b\",\n}\n"
  },
  {
    "path": "relay/adaptor/baiduv2/main.go",
    "content": "package baiduv2\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc GetRequestURL(meta *meta.Meta) (string, error) {\n\tswitch meta.Mode {\n\tcase relaymode.ChatCompletions:\n\t\treturn fmt.Sprintf(\"%s/v2/chat/completions\", meta.BaseURL), nil\n\tdefault:\n\t}\n\treturn \"\", fmt.Errorf(\"unsupported relay mode %d for baidu v2\", meta.Mode)\n}\n"
  },
  {
    "path": "relay/adaptor/cloudflare/adaptor.go",
    "content": "package cloudflare\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\ntype Adaptor struct {\n\tmeta *meta.Meta\n}\n\n// ConvertImageRequest implements adaptor.Adaptor.\nfunc (*Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\treturn nil, errors.New(\"not implemented\")\n}\n\n// ConvertImageRequest implements adaptor.Adaptor.\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\n// WorkerAI cannot be used across accounts with AIGateWay\n// https://developers.cloudflare.com/ai-gateway/providers/workersai/#openai-compatible-endpoints\n// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/workers-ai\nfunc (a *Adaptor) isAIGateWay(baseURL string) bool {\n\treturn strings.HasPrefix(baseURL, \"https://gateway.ai.cloudflare.com\") && strings.HasSuffix(baseURL, \"/workers-ai\")\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tisAIGateWay := a.isAIGateWay(meta.BaseURL)\n\tvar urlPrefix string\n\tif isAIGateWay {\n\t\turlPrefix = meta.BaseURL\n\t} else {\n\t\turlPrefix = fmt.Sprintf(\"%s/client/v4/accounts/%s/ai\", meta.BaseURL, meta.Config.UserID)\n\t}\n\n\tswitch meta.Mode {\n\tcase relaymode.ChatCompletions:\n\t\treturn fmt.Sprintf(\"%s/v1/chat/completions\", urlPrefix), nil\n\tcase relaymode.Embeddings:\n\t\treturn fmt.Sprintf(\"%s/v1/embeddings\", urlPrefix), nil\n\tdefault:\n\t\tif isAIGateWay {\n\t\t\treturn fmt.Sprintf(\"%s/%s\", urlPrefix, meta.ActualModelName), nil\n\t\t}\n\t\treturn fmt.Sprintf(\"%s/run/%s\", urlPrefix, meta.ActualModelName), nil\n\t}\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Completions:\n\t\treturn ConvertCompletionsRequest(*request), nil\n\tcase relaymode.ChatCompletions, relaymode.Embeddings:\n\t\treturn request, nil\n\tdefault:\n\t\treturn nil, errors.New(\"not implemented\")\n\t}\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t} else {\n\t\terr, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"cloudflare\"\n}\n"
  },
  {
    "path": "relay/adaptor/cloudflare/constant.go",
    "content": "package cloudflare\n\nvar ModelList = []string{\n\t\"@cf/meta/llama-3.1-8b-instruct\",\n\t\"@cf/meta/llama-2-7b-chat-fp16\",\n\t\"@cf/meta/llama-2-7b-chat-int8\",\n\t\"@cf/mistral/mistral-7b-instruct-v0.1\",\n\t\"@hf/thebloke/deepseek-coder-6.7b-base-awq\",\n\t\"@hf/thebloke/deepseek-coder-6.7b-instruct-awq\",\n\t\"@cf/deepseek-ai/deepseek-math-7b-base\",\n\t\"@cf/deepseek-ai/deepseek-math-7b-instruct\",\n\t\"@cf/thebloke/discolm-german-7b-v1-awq\",\n\t\"@cf/tiiuae/falcon-7b-instruct\",\n\t\"@cf/google/gemma-2b-it-lora\",\n\t\"@hf/google/gemma-7b-it\",\n\t\"@cf/google/gemma-7b-it-lora\",\n\t\"@hf/nousresearch/hermes-2-pro-mistral-7b\",\n\t\"@hf/thebloke/llama-2-13b-chat-awq\",\n\t\"@cf/meta-llama/llama-2-7b-chat-hf-lora\",\n\t\"@cf/meta/llama-3-8b-instruct\",\n\t\"@hf/thebloke/llamaguard-7b-awq\",\n\t\"@hf/thebloke/mistral-7b-instruct-v0.1-awq\",\n\t\"@hf/mistralai/mistral-7b-instruct-v0.2\",\n\t\"@cf/mistral/mistral-7b-instruct-v0.2-lora\",\n\t\"@hf/thebloke/neural-chat-7b-v3-1-awq\",\n\t\"@cf/openchat/openchat-3.5-0106\",\n\t\"@hf/thebloke/openhermes-2.5-mistral-7b-awq\",\n\t\"@cf/microsoft/phi-2\",\n\t\"@cf/qwen/qwen1.5-0.5b-chat\",\n\t\"@cf/qwen/qwen1.5-1.8b-chat\",\n\t\"@cf/qwen/qwen1.5-14b-chat-awq\",\n\t\"@cf/qwen/qwen1.5-7b-chat-awq\",\n\t\"@cf/defog/sqlcoder-7b-2\",\n\t\"@hf/nexusflow/starling-lm-7b-beta\",\n\t\"@cf/tinyllama/tinyllama-1.1b-chat-v1.0\",\n\t\"@hf/thebloke/zephyr-7b-beta-awq\",\n}\n"
  },
  {
    "path": "relay/adaptor/cloudflare/main.go",
    "content": "package cloudflare\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ConvertCompletionsRequest(textRequest model.GeneralOpenAIRequest) *Request {\n\tp, _ := textRequest.Prompt.(string)\n\treturn &Request{\n\t\tPrompt:      p,\n\t\tMaxTokens:   textRequest.MaxTokens,\n\t\tStream:      textRequest.Stream,\n\t\tTemperature: textRequest.Temperature,\n\t}\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\tid := helper.GetResponseID(c)\n\tresponseModel := c.GetString(ctxkey.OriginalModel)\n\tvar responseText string\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < len(\"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tdata = strings.TrimPrefix(data, \"data: \")\n\t\tdata = strings.TrimSuffix(data, \"\\r\")\n\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar response openai.ChatCompletionsStreamResponse\n\t\terr := json.Unmarshal([]byte(data), &response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tfor _, v := range response.Choices {\n\t\t\tv.Delta.Role = \"assistant\"\n\t\t\tresponseText += v.Delta.StringContent()\n\t\t}\n\t\tresponse.Id = id\n\t\tresponse.Model = modelName\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tusage := openai.ResponseText2Usage(responseText, responseModel, promptTokens)\n\treturn nil, usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar response openai.TextResponse\n\terr = json.Unmarshal(responseBody, &response)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tresponse.Model = modelName\n\tvar responseText string\n\tfor _, v := range response.Choices {\n\t\tresponseText += v.Message.Content.(string)\n\t}\n\tusage := openai.ResponseText2Usage(responseText, modelName, promptTokens)\n\tresponse.Usage = *usage\n\tresponse.Id = helper.GetResponseID(c)\n\tjsonResponse, err := json.Marshal(response)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, _ = c.Writer.Write(jsonResponse)\n\treturn nil, usage\n}\n"
  },
  {
    "path": "relay/adaptor/cloudflare/model.go",
    "content": "package cloudflare\n\nimport \"github.com/songquanpeng/one-api/relay/model\"\n\ntype Request struct {\n\tMessages    []model.Message `json:\"messages,omitempty\"`\n\tLora        string          `json:\"lora,omitempty\"`\n\tMaxTokens   int             `json:\"max_tokens,omitempty\"`\n\tPrompt      string          `json:\"prompt,omitempty\"`\n\tRaw         bool            `json:\"raw,omitempty\"`\n\tStream      bool            `json:\"stream,omitempty\"`\n\tTemperature *float64        `json:\"temperature,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/cohere/adaptor.go",
    "content": "package cohere\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype Adaptor struct{}\n\n// ConvertImageRequest implements adaptor.Adaptor.\nfunc (*Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\treturn nil, errors.New(\"not implemented\")\n}\n\n// ConvertImageRequest implements adaptor.Adaptor.\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn fmt.Sprintf(\"%s/v1/chat\", meta.BaseURL), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn ConvertRequest(*request), nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\terr, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"Cohere\"\n}\n"
  },
  {
    "path": "relay/adaptor/cohere/constant.go",
    "content": "package cohere\n\nvar ModelList = []string{\n\t\"command\", \"command-nightly\",\n\t\"command-light\", \"command-light-nightly\",\n\t\"command-r\", \"command-r-plus\",\n}\n\nfunc init() {\n\tnum := len(ModelList)\n\tfor i := 0; i < num; i++ {\n\t\tModelList = append(ModelList, ModelList[i]+\"-internet\")\n\t}\n}\n"
  },
  {
    "path": "relay/adaptor/cohere/main.go",
    "content": "package cohere\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar (\n\tWebSearchConnector = Connector{ID: \"web-search\"}\n)\n\nfunc stopReasonCohere2OpenAI(reason *string) string {\n\tif reason == nil {\n\t\treturn \"\"\n\t}\n\tswitch *reason {\n\tcase \"COMPLETE\":\n\t\treturn \"stop\"\n\tdefault:\n\t\treturn *reason\n\t}\n}\n\nfunc ConvertRequest(textRequest model.GeneralOpenAIRequest) *Request {\n\tcohereRequest := Request{\n\t\tModel:            textRequest.Model,\n\t\tMessage:          \"\",\n\t\tMaxTokens:        textRequest.MaxTokens,\n\t\tTemperature:      textRequest.Temperature,\n\t\tP:                textRequest.TopP,\n\t\tK:                textRequest.TopK,\n\t\tStream:           textRequest.Stream,\n\t\tFrequencyPenalty: textRequest.FrequencyPenalty,\n\t\tPresencePenalty:  textRequest.PresencePenalty,\n\t\tSeed:             int(textRequest.Seed),\n\t}\n\tif cohereRequest.Model == \"\" {\n\t\tcohereRequest.Model = \"command-r\"\n\t}\n\tif strings.HasSuffix(cohereRequest.Model, \"-internet\") {\n\t\tcohereRequest.Model = strings.TrimSuffix(cohereRequest.Model, \"-internet\")\n\t\tcohereRequest.Connectors = append(cohereRequest.Connectors, WebSearchConnector)\n\t}\n\tfor _, message := range textRequest.Messages {\n\t\tif message.Role == \"user\" {\n\t\t\tcohereRequest.Message = message.Content.(string)\n\t\t} else {\n\t\t\tvar role string\n\t\t\tif message.Role == \"assistant\" {\n\t\t\t\trole = \"CHATBOT\"\n\t\t\t} else if message.Role == \"system\" {\n\t\t\t\trole = \"SYSTEM\"\n\t\t\t} else {\n\t\t\t\trole = \"USER\"\n\t\t\t}\n\t\t\tcohereRequest.ChatHistory = append(cohereRequest.ChatHistory, ChatMessage{\n\t\t\t\tRole:    role,\n\t\t\t\tMessage: message.Content.(string),\n\t\t\t})\n\t\t}\n\t}\n\treturn &cohereRequest\n}\n\nfunc StreamResponseCohere2OpenAI(cohereResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) {\n\tvar response *Response\n\tvar responseText string\n\tvar finishReason string\n\n\tswitch cohereResponse.EventType {\n\tcase \"stream-start\":\n\t\treturn nil, nil\n\tcase \"text-generation\":\n\t\tresponseText += cohereResponse.Text\n\tcase \"stream-end\":\n\t\tusage := cohereResponse.Response.Meta.Tokens\n\t\tresponse = &Response{\n\t\t\tMeta: Meta{\n\t\t\t\tTokens: Usage{\n\t\t\t\t\tInputTokens:  usage.InputTokens,\n\t\t\t\t\tOutputTokens: usage.OutputTokens,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tfinishReason = *cohereResponse.Response.FinishReason\n\tdefault:\n\t\treturn nil, nil\n\t}\n\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = responseText\n\tchoice.Delta.Role = \"assistant\"\n\tif finishReason != \"\" {\n\t\tchoice.FinishReason = &finishReason\n\t}\n\tvar openaiResponse openai.ChatCompletionsStreamResponse\n\topenaiResponse.Object = \"chat.completion.chunk\"\n\topenaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice}\n\treturn &openaiResponse, response\n}\n\nfunc ResponseCohere2OpenAI(cohereResponse *Response) *openai.TextResponse {\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: cohereResponse.Text,\n\t\t\tName:    nil,\n\t\t},\n\t\tFinishReason: stopReasonCohere2OpenAI(cohereResponse.FinishReason),\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", cohereResponse.ResponseID),\n\t\tModel:   \"model\",\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tcreatedTime := helper.GetTimestamp()\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\tvar usage model.Usage\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tdata = strings.TrimSuffix(data, \"\\r\")\n\n\t\tvar cohereResponse StreamResponse\n\t\terr := json.Unmarshal([]byte(data), &cohereResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse, meta := StreamResponseCohere2OpenAI(&cohereResponse)\n\t\tif meta != nil {\n\t\t\tusage.PromptTokens += meta.Meta.Tokens.InputTokens\n\t\t\tusage.CompletionTokens += meta.Meta.Tokens.OutputTokens\n\t\t\tcontinue\n\t\t}\n\t\tif response == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse.Id = fmt.Sprintf(\"chatcmpl-%d\", createdTime)\n\t\tresponse.Model = c.GetString(\"original_model\")\n\t\tresponse.Created = createdTime\n\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\treturn nil, &usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar cohereResponse Response\n\terr = json.Unmarshal(responseBody, &cohereResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif cohereResponse.ResponseID == \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: cohereResponse.Message,\n\t\t\t\tType:    cohereResponse.Message,\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    resp.StatusCode,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := ResponseCohere2OpenAI(&cohereResponse)\n\tfullTextResponse.Model = modelName\n\tusage := model.Usage{\n\t\tPromptTokens:     cohereResponse.Meta.Tokens.InputTokens,\n\t\tCompletionTokens: cohereResponse.Meta.Tokens.OutputTokens,\n\t\tTotalTokens:      cohereResponse.Meta.Tokens.InputTokens + cohereResponse.Meta.Tokens.OutputTokens,\n\t}\n\tfullTextResponse.Usage = usage\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &usage\n}\n"
  },
  {
    "path": "relay/adaptor/cohere/model.go",
    "content": "package cohere\n\ntype Request struct {\n\tMessage          string        `json:\"message\" required:\"true\"`\n\tModel            string        `json:\"model,omitempty\"`  // 默认值为\"command-r\"\n\tStream           bool          `json:\"stream,omitempty\"` // 默认值为false\n\tPreamble         string        `json:\"preamble,omitempty\"`\n\tChatHistory      []ChatMessage `json:\"chat_history,omitempty\"`\n\tConversationID   string        `json:\"conversation_id,omitempty\"`\n\tPromptTruncation string        `json:\"prompt_truncation,omitempty\"` // 默认值为\"AUTO\"\n\tConnectors       []Connector   `json:\"connectors,omitempty\"`\n\tDocuments        []Document    `json:\"documents,omitempty\"`\n\tTemperature      *float64      `json:\"temperature,omitempty\"` // 默认值为0.3\n\tMaxTokens        int           `json:\"max_tokens,omitempty\"`\n\tMaxInputTokens   int           `json:\"max_input_tokens,omitempty\"`\n\tK                int           `json:\"k,omitempty\"` // 默认值为0\n\tP                *float64      `json:\"p,omitempty\"` // 默认值为0.75\n\tSeed             int           `json:\"seed,omitempty\"`\n\tStopSequences    []string      `json:\"stop_sequences,omitempty\"`\n\tFrequencyPenalty *float64      `json:\"frequency_penalty,omitempty\"` // 默认值为0.0\n\tPresencePenalty  *float64      `json:\"presence_penalty,omitempty\"`  // 默认值为0.0\n\tTools            []Tool        `json:\"tools,omitempty\"`\n\tToolResults      []ToolResult  `json:\"tool_results,omitempty\"`\n}\n\ntype ChatMessage struct {\n\tRole    string `json:\"role\" required:\"true\"`\n\tMessage string `json:\"message\" required:\"true\"`\n}\n\ntype Tool struct {\n\tName                 string                   `json:\"name\" required:\"true\"`\n\tDescription          string                   `json:\"description\" required:\"true\"`\n\tParameterDefinitions map[string]ParameterSpec `json:\"parameter_definitions\"`\n}\n\ntype ParameterSpec struct {\n\tDescription string `json:\"description\"`\n\tType        string `json:\"type\" required:\"true\"`\n\tRequired    bool   `json:\"required\"`\n}\n\ntype ToolResult struct {\n\tCall    ToolCall                 `json:\"call\"`\n\tOutputs []map[string]interface{} `json:\"outputs\"`\n}\n\ntype ToolCall struct {\n\tName       string                 `json:\"name\" required:\"true\"`\n\tParameters map[string]interface{} `json:\"parameters\" required:\"true\"`\n}\n\ntype StreamResponse struct {\n\tIsFinished    bool            `json:\"is_finished\"`\n\tEventType     string          `json:\"event_type\"`\n\tGenerationID  string          `json:\"generation_id,omitempty\"`\n\tSearchQueries []*SearchQuery  `json:\"search_queries,omitempty\"`\n\tSearchResults []*SearchResult `json:\"search_results,omitempty\"`\n\tDocuments     []*Document     `json:\"documents,omitempty\"`\n\tText          string          `json:\"text,omitempty\"`\n\tCitations     []*Citation     `json:\"citations,omitempty\"`\n\tResponse      *Response       `json:\"response,omitempty\"`\n\tFinishReason  string          `json:\"finish_reason,omitempty\"`\n}\n\ntype SearchQuery struct {\n\tText         string `json:\"text\"`\n\tGenerationID string `json:\"generation_id\"`\n}\n\ntype SearchResult struct {\n\tSearchQuery *SearchQuery `json:\"search_query\"`\n\tDocumentIDs []string     `json:\"document_ids\"`\n\tConnector   *Connector   `json:\"connector\"`\n}\n\ntype Connector struct {\n\tID string `json:\"id\"`\n}\n\ntype Document struct {\n\tID        string `json:\"id\"`\n\tSnippet   string `json:\"snippet\"`\n\tTimestamp string `json:\"timestamp\"`\n\tTitle     string `json:\"title\"`\n\tURL       string `json:\"url\"`\n}\n\ntype Citation struct {\n\tStart       int      `json:\"start\"`\n\tEnd         int      `json:\"end\"`\n\tText        string   `json:\"text\"`\n\tDocumentIDs []string `json:\"document_ids\"`\n}\n\ntype Response struct {\n\tResponseID    string          `json:\"response_id\"`\n\tText          string          `json:\"text\"`\n\tGenerationID  string          `json:\"generation_id\"`\n\tChatHistory   []*Message      `json:\"chat_history\"`\n\tFinishReason  *string         `json:\"finish_reason\"`\n\tMeta          Meta            `json:\"meta\"`\n\tCitations     []*Citation     `json:\"citations\"`\n\tDocuments     []*Document     `json:\"documents\"`\n\tSearchResults []*SearchResult `json:\"search_results\"`\n\tSearchQueries []*SearchQuery  `json:\"search_queries\"`\n\tMessage       string          `json:\"message\"`\n}\n\ntype Message struct {\n\tRole    string `json:\"role\"`\n\tMessage string `json:\"message\"`\n}\n\ntype Version struct {\n\tVersion string `json:\"version\"`\n}\n\ntype Units struct {\n\tInputTokens  int `json:\"input_tokens\"`\n\tOutputTokens int `json:\"output_tokens\"`\n}\n\ntype ChatEntry struct {\n\tRole    string `json:\"role\"`\n\tMessage string `json:\"message\"`\n}\n\ntype Meta struct {\n\tAPIVersion  APIVersion  `json:\"api_version\"`\n\tBilledUnits BilledUnits `json:\"billed_units\"`\n\tTokens      Usage       `json:\"tokens\"`\n}\n\ntype APIVersion struct {\n\tVersion string `json:\"version\"`\n}\n\ntype BilledUnits struct {\n\tInputTokens  int `json:\"input_tokens\"`\n\tOutputTokens int `json:\"output_tokens\"`\n}\n\ntype Usage struct {\n\tInputTokens  int `json:\"input_tokens\"`\n\tOutputTokens int `json:\"output_tokens\"`\n}\n"
  },
  {
    "path": "relay/adaptor/common.go",
    "content": "package adaptor\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc SetupCommonRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) {\n\treq.Header.Set(\"Content-Type\", c.Request.Header.Get(\"Content-Type\"))\n\treq.Header.Set(\"Accept\", c.Request.Header.Get(\"Accept\"))\n\tif meta.IsStream && c.Request.Header.Get(\"Accept\") == \"\" {\n\t\treq.Header.Set(\"Accept\", \"text/event-stream\")\n\t}\n}\n\nfunc DoRequestHelper(a Adaptor, c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\tfullRequestURL, err := a.GetRequestURL(meta)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get request url failed: %w\", err)\n\t}\n\treq, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"new request failed: %w\", err)\n\t}\n\terr = a.SetupRequestHeader(c, req, meta)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"setup request header failed: %w\", err)\n\t}\n\tresp, err := DoRequest(c, req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"do request failed: %w\", err)\n\t}\n\treturn resp, nil\n}\n\nfunc DoRequest(c *gin.Context, req *http.Request) (*http.Response, error) {\n\tresp, err := client.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp == nil {\n\t\treturn nil, errors.New(\"resp is nil\")\n\t}\n\t_ = req.Body.Close()\n\t_ = c.Request.Body.Close()\n\treturn resp, nil\n}\n"
  },
  {
    "path": "relay/adaptor/coze/adaptor.go",
    "content": "package coze\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Adaptor struct {\n\tmeta *meta.Meta\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn fmt.Sprintf(\"%s/open_api/v2/chat\", meta.BaseURL), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\trequest.User = a.meta.Config.UserID\n\treturn ConvertRequest(*request), nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tvar responseText *string\n\tif meta.IsStream {\n\t\terr, responseText = StreamHandler(c, resp)\n\t} else {\n\t\terr, responseText = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\tif responseText != nil {\n\t\tusage = openai.ResponseText2Usage(*responseText, meta.ActualModelName, meta.PromptTokens)\n\t} else {\n\t\tusage = &model.Usage{}\n\t}\n\tusage.PromptTokens = meta.PromptTokens\n\tusage.TotalTokens = usage.PromptTokens + usage.CompletionTokens\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"coze\"\n}\n"
  },
  {
    "path": "relay/adaptor/coze/constant/contenttype/define.go",
    "content": "package contenttype\n\nconst (\n\tText = \"text\"\n)\n"
  },
  {
    "path": "relay/adaptor/coze/constant/event/define.go",
    "content": "package event\n\nconst (\n\tMessage = \"message\"\n\tDone    = \"done\"\n\tError   = \"error\"\n)\n"
  },
  {
    "path": "relay/adaptor/coze/constant/messagetype/define.go",
    "content": "package messagetype\n\nconst (\n\tAnswer   = \"answer\"\n\tFollowUp = \"follow_up\"\n)\n"
  },
  {
    "path": "relay/adaptor/coze/constants.go",
    "content": "package coze\n\nvar ModelList = []string{}\n"
  },
  {
    "path": "relay/adaptor/coze/helper.go",
    "content": "package coze\n\nimport \"github.com/songquanpeng/one-api/relay/adaptor/coze/constant/event\"\n\nfunc event2StopReason(e *string) string {\n\tif e == nil || *e == event.Message {\n\t\treturn \"\"\n\t}\n\treturn \"stop\"\n}\n"
  },
  {
    "path": "relay/adaptor/coze/main.go",
    "content": "package coze\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/conv\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/coze/constant/messagetype\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://www.coze.com/open\n\nfunc stopReasonCoze2OpenAI(reason *string) string {\n\tif reason == nil {\n\t\treturn \"\"\n\t}\n\tswitch *reason {\n\tcase \"end_turn\":\n\t\treturn \"stop\"\n\tcase \"stop_sequence\":\n\t\treturn \"stop\"\n\tcase \"max_tokens\":\n\t\treturn \"length\"\n\tdefault:\n\t\treturn *reason\n\t}\n}\n\nfunc ConvertRequest(textRequest model.GeneralOpenAIRequest) *Request {\n\tcozeRequest := Request{\n\t\tStream: textRequest.Stream,\n\t\tUser:   textRequest.User,\n\t\tBotId:  strings.TrimPrefix(textRequest.Model, \"bot-\"),\n\t}\n\tfor i, message := range textRequest.Messages {\n\t\tif i == len(textRequest.Messages)-1 {\n\t\t\tcozeRequest.Query = message.StringContent()\n\t\t\tcontinue\n\t\t}\n\t\tcozeMessage := Message{\n\t\t\tRole:    message.Role,\n\t\t\tContent: message.StringContent(),\n\t\t}\n\t\tcozeRequest.ChatHistory = append(cozeRequest.ChatHistory, cozeMessage)\n\t}\n\treturn &cozeRequest\n}\n\nfunc StreamResponseCoze2OpenAI(cozeResponse *StreamResponse) (*openai.ChatCompletionsStreamResponse, *Response) {\n\tvar response *Response\n\tvar stopReason string\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\n\tif cozeResponse.Message != nil {\n\t\tif cozeResponse.Message.Type != messagetype.Answer {\n\t\t\treturn nil, nil\n\t\t}\n\t\tchoice.Delta.Content = cozeResponse.Message.Content\n\t}\n\tchoice.Delta.Role = \"assistant\"\n\tfinishReason := stopReasonCoze2OpenAI(&stopReason)\n\tif finishReason != \"null\" {\n\t\tchoice.FinishReason = &finishReason\n\t}\n\tvar openaiResponse openai.ChatCompletionsStreamResponse\n\topenaiResponse.Object = \"chat.completion.chunk\"\n\topenaiResponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice}\n\topenaiResponse.Id = cozeResponse.ConversationId\n\treturn &openaiResponse, response\n}\n\nfunc ResponseCoze2OpenAI(cozeResponse *Response) *openai.TextResponse {\n\tvar responseText string\n\tfor _, message := range cozeResponse.Messages {\n\t\tif message.Type == messagetype.Answer {\n\t\t\tresponseText = message.Content\n\t\t\tbreak\n\t\t}\n\t}\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: responseText,\n\t\t\tName:    nil,\n\t\t},\n\t\tFinishReason: \"stop\",\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", cozeResponse.ConversationId),\n\t\tModel:   \"coze-bot\",\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *string) {\n\tvar responseText string\n\tcreatedTime := helper.GetTimestamp()\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\tvar modelName string\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < 5 || !strings.HasPrefix(data, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata = strings.TrimPrefix(data, \"data:\")\n\t\tdata = strings.TrimSuffix(data, \"\\r\")\n\n\t\tvar cozeResponse StreamResponse\n\t\terr := json.Unmarshal([]byte(data), &cozeResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse, _ := StreamResponseCoze2OpenAI(&cozeResponse)\n\t\tif response == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, choice := range response.Choices {\n\t\t\tresponseText += conv.AsString(choice.Delta.Content)\n\t\t}\n\t\tresponse.Model = modelName\n\t\tresponse.Created = createdTime\n\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\treturn nil, &responseText\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *string) {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar cozeResponse Response\n\terr = json.Unmarshal(responseBody, &cozeResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif cozeResponse.Code != 0 {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: cozeResponse.Msg,\n\t\t\t\tCode:    cozeResponse.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := ResponseCoze2OpenAI(&cozeResponse)\n\tfullTextResponse.Model = modelName\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\tvar responseText string\n\tif len(fullTextResponse.Choices) > 0 {\n\t\tresponseText = fullTextResponse.Choices[0].Message.StringContent()\n\t}\n\treturn nil, &responseText\n}\n"
  },
  {
    "path": "relay/adaptor/coze/model.go",
    "content": "package coze\n\ntype Message struct {\n\tRole        string `json:\"role\"`\n\tType        string `json:\"type\"`\n\tContent     string `json:\"content\"`\n\tContentType string `json:\"content_type\"`\n}\n\ntype ErrorInformation struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n}\n\ntype Request struct {\n\tConversationId string    `json:\"conversation_id,omitempty\"`\n\tBotId          string    `json:\"bot_id\"`\n\tUser           string    `json:\"user\"`\n\tQuery          string    `json:\"query\"`\n\tChatHistory    []Message `json:\"chat_history,omitempty\"`\n\tStream         bool      `json:\"stream\"`\n}\n\ntype Response struct {\n\tConversationId string    `json:\"conversation_id,omitempty\"`\n\tMessages       []Message `json:\"messages,omitempty\"`\n\tCode           int       `json:\"code,omitempty\"`\n\tMsg            string    `json:\"msg,omitempty\"`\n}\n\ntype StreamResponse struct {\n\tEvent            string            `json:\"event,omitempty\"`\n\tMessage          *Message          `json:\"message,omitempty\"`\n\tIsFinish         bool              `json:\"is_finish,omitempty\"`\n\tIndex            int               `json:\"index,omitempty\"`\n\tConversationId   string            `json:\"conversation_id,omitempty\"`\n\tErrorInformation *ErrorInformation `json:\"error_information,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/deepl/adaptor.go",
    "content": "package deepl\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Adaptor struct {\n\tmeta       *meta.Meta\n\tpromptText string\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn fmt.Sprintf(\"%s/v2/translate\", meta.BaseURL), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"DeepL-Auth-Key \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tconvertedRequest, text := ConvertRequest(*request)\n\ta.promptText = text\n\treturn convertedRequest, nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr = StreamHandler(c, resp, meta.ActualModelName)\n\t} else {\n\t\terr = Handler(c, resp, meta.ActualModelName)\n\t}\n\tpromptTokens := len(a.promptText)\n\tusage = &model.Usage{\n\t\tPromptTokens: promptTokens,\n\t\tTotalTokens:  promptTokens,\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"deepl\"\n}\n"
  },
  {
    "path": "relay/adaptor/deepl/constants.go",
    "content": "package deepl\n\n// https://developers.deepl.com/docs/api-reference/glossaries\n\nvar ModelList = []string{\n\t\"deepl-zh\",\n\t\"deepl-en\",\n\t\"deepl-ja\",\n}\n"
  },
  {
    "path": "relay/adaptor/deepl/helper.go",
    "content": "package deepl\n\nimport \"strings\"\n\nfunc parseLangFromModelName(modelName string) string {\n\tparts := strings.Split(modelName, \"-\")\n\tif len(parts) == 1 {\n\t\treturn \"ZH\"\n\t}\n\treturn parts[1]\n}\n"
  },
  {
    "path": "relay/adaptor/deepl/main.go",
    "content": "package deepl\n\nimport (\n\t\"encoding/json\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/constant/finishreason\"\n\t\"github.com/songquanpeng/one-api/relay/constant/role\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\n// https://developers.deepl.com/docs/getting-started/your-first-api-request\n\nfunc ConvertRequest(textRequest model.GeneralOpenAIRequest) (*Request, string) {\n\tvar text string\n\tif len(textRequest.Messages) != 0 {\n\t\ttext = textRequest.Messages[len(textRequest.Messages)-1].StringContent()\n\t}\n\tdeeplRequest := Request{\n\t\tTargetLang: parseLangFromModelName(textRequest.Model),\n\t\tText:       []string{text},\n\t}\n\treturn &deeplRequest, text\n}\n\nfunc StreamResponseDeepL2OpenAI(deeplResponse *Response) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tif len(deeplResponse.Translations) != 0 {\n\t\tchoice.Delta.Content = deeplResponse.Translations[0].Text\n\t}\n\tchoice.Delta.Role = role.Assistant\n\tchoice.FinishReason = &constant.StopFinishReason\n\topenaiResponse := openai.ChatCompletionsStreamResponse{\n\t\tObject:  constant.StreamObject,\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &openaiResponse\n}\n\nfunc ResponseDeepL2OpenAI(deeplResponse *Response) *openai.TextResponse {\n\tvar responseText string\n\tif len(deeplResponse.Translations) != 0 {\n\t\tresponseText = deeplResponse.Translations[0].Text\n\t}\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:    role.Assistant,\n\t\t\tContent: responseText,\n\t\t\tName:    nil,\n\t\t},\n\t\tFinishReason: finishreason.Stop,\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tObject:  constant.NonStreamObject,\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response, modelName string) *model.ErrorWithStatusCode {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError)\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError)\n\t}\n\tvar deeplResponse Response\n\terr = json.Unmarshal(responseBody, &deeplResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError)\n\t}\n\tfullTextResponse := StreamResponseDeepL2OpenAI(&deeplResponse)\n\tfullTextResponse.Model = modelName\n\tfullTextResponse.Id = helper.GetResponseID(c)\n\tjsonData, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError)\n\t}\n\tcommon.SetEventStreamHeaders(c)\n\tc.Stream(func(w io.Writer) bool {\n\t\tif jsonData != nil {\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: \" + string(jsonData)})\n\t\t\tjsonData = nil\n\t\t\treturn true\n\t\t}\n\t\tc.Render(-1, common.CustomEvent{Data: \"data: [DONE]\"})\n\t\treturn false\n\t})\n\t_ = resp.Body.Close()\n\treturn nil\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, modelName string) *model.ErrorWithStatusCode {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError)\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError)\n\t}\n\tvar deeplResponse Response\n\terr = json.Unmarshal(responseBody, &deeplResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError)\n\t}\n\tif deeplResponse.Message != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: deeplResponse.Message,\n\t\t\t\tCode:    \"deepl_error\",\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}\n\t}\n\tfullTextResponse := ResponseDeepL2OpenAI(&deeplResponse)\n\tfullTextResponse.Model = modelName\n\tfullTextResponse.Id = helper.GetResponseID(c)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError)\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil\n}\n"
  },
  {
    "path": "relay/adaptor/deepl/model.go",
    "content": "package deepl\n\ntype Request struct {\n\tText       []string `json:\"text\"`\n\tTargetLang string   `json:\"target_lang\"`\n}\n\ntype Translation struct {\n\tDetectedSourceLanguage string `json:\"detected_source_language,omitempty\"`\n\tText                   string `json:\"text,omitempty\"`\n}\n\ntype Response struct {\n\tTranslations []Translation `json:\"translations,omitempty\"`\n\tMessage      string        `json:\"message,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/deepseek/constants.go",
    "content": "package deepseek\n\nvar ModelList = []string{\n\t\"deepseek-chat\",\n\t\"deepseek-reasoner\",\n}\n"
  },
  {
    "path": "relay/adaptor/doubao/constants.go",
    "content": "package doubao\n\n// https://console.volcengine.com/ark/region:ark+cn-beijing/model\n\nvar ModelList = []string{\n\t\"Doubao-pro-128k\",\n\t\"Doubao-pro-32k\",\n\t\"Doubao-pro-4k\",\n\t\"Doubao-lite-128k\",\n\t\"Doubao-lite-32k\",\n\t\"Doubao-lite-4k\",\n\t\"Doubao-embedding\",\n}\n"
  },
  {
    "path": "relay/adaptor/doubao/main.go",
    "content": "package doubao\n\nimport (\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc GetRequestURL(meta *meta.Meta) (string, error) {\n\tswitch meta.Mode {\n\tcase relaymode.ChatCompletions:\n\t\treturn fmt.Sprintf(\"%s/api/v3/chat/completions\", meta.BaseURL), nil\n\tcase relaymode.Embeddings:\n\t\treturn fmt.Sprintf(\"%s/api/v3/embeddings\", meta.BaseURL), nil\n\tdefault:\n\t}\n\treturn \"\", fmt.Errorf(\"unsupported relay mode %d for doubao\", meta.Mode)\n}\n"
  },
  {
    "path": "relay/adaptor/gemini/adaptor.go",
    "content": "package gemini\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\tchannelhelper \"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tdefaultVersion := config.GeminiVersion\n\tif strings.Contains(meta.ActualModelName, \"gemini-2.0\") ||\n\t\tstrings.Contains(meta.ActualModelName, \"gemini-1.5\") {\n\t\tdefaultVersion = \"v1beta\"\n\t}\n\n\tversion := helper.AssignOrDefault(meta.Config.APIVersion, defaultVersion)\n\taction := \"\"\n\tswitch meta.Mode {\n\tcase relaymode.Embeddings:\n\t\taction = \"batchEmbedContents\"\n\tdefault:\n\t\taction = \"generateContent\"\n\t}\n\n\tif meta.IsStream {\n\t\taction = \"streamGenerateContent?alt=sse\"\n\t}\n\n\treturn fmt.Sprintf(\"%s/%s/models/%s:%s\", meta.BaseURL, version, meta.ActualModelName, action), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tchannelhelper.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"x-goog-api-key\", meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Embeddings:\n\t\tgeminiEmbeddingRequest := ConvertEmbeddingRequest(*request)\n\t\treturn geminiEmbeddingRequest, nil\n\tdefault:\n\t\tgeminiRequest := ConvertRequest(*request)\n\t\treturn geminiRequest, nil\n\t}\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn channelhelper.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\tvar responseText string\n\t\terr, responseText = StreamHandler(c, resp)\n\t\tusage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.Embeddings:\n\t\t\terr, usage = EmbeddingHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"google gemini\"\n}\n"
  },
  {
    "path": "relay/adaptor/gemini/constants.go",
    "content": "package gemini\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/adaptor/geminiv2\"\n)\n\n// https://ai.google.dev/models/gemini\n\nvar ModelList = geminiv2.ModelList\n\n// ModelsSupportSystemInstruction is the list of models that support system instruction.\n//\n// https://cloud.google.com/vertex-ai/generative-ai/docs/learn/prompts/system-instructions\nvar ModelsSupportSystemInstruction = []string{\n\t// \"gemini-1.0-pro-002\",\n\t// \"gemini-1.5-flash\", \"gemini-1.5-flash-001\", \"gemini-1.5-flash-002\",\n\t// \"gemini-1.5-flash-8b\",\n\t// \"gemini-1.5-pro\", \"gemini-1.5-pro-001\", \"gemini-1.5-pro-002\",\n\t// \"gemini-1.5-pro-experimental\",\n\t\"gemini-2.0-flash\", \"gemini-2.0-flash-exp\",\n\t\"gemini-2.0-flash-thinking-exp-01-21\",\n}\n\n// IsModelSupportSystemInstruction check if the model support system instruction.\n//\n// Because the main version of Go is 1.20, slice.Contains cannot be used\nfunc IsModelSupportSystemInstruction(model string) bool {\n\tfor _, m := range ModelsSupportSystemInstruction {\n\t\tif m == model {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "relay/adaptor/gemini/main.go",
    "content": "package gemini\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/render\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/image\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// https://ai.google.dev/docs/gemini_api_overview?hl=zh-cn\n\nconst (\n\tVisionMaxImageNum = 16\n)\n\nvar mimeTypeMap = map[string]string{\n\t\"json_object\": \"application/json\",\n\t\"text\":        \"text/plain\",\n}\n\n// Setting safety to the lowest possible values since Gemini is already powerless enough\nfunc ConvertRequest(textRequest model.GeneralOpenAIRequest) *ChatRequest {\n\tgeminiRequest := ChatRequest{\n\t\tContents: make([]ChatContent, 0, len(textRequest.Messages)),\n\t\tSafetySettings: []ChatSafetySettings{\n\t\t\t{\n\t\t\t\tCategory:  \"HARM_CATEGORY_HARASSMENT\",\n\t\t\t\tThreshold: config.GeminiSafetySetting,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCategory:  \"HARM_CATEGORY_HATE_SPEECH\",\n\t\t\t\tThreshold: config.GeminiSafetySetting,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCategory:  \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n\t\t\t\tThreshold: config.GeminiSafetySetting,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCategory:  \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n\t\t\t\tThreshold: config.GeminiSafetySetting,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCategory:  \"HARM_CATEGORY_CIVIC_INTEGRITY\",\n\t\t\t\tThreshold: config.GeminiSafetySetting,\n\t\t\t},\n\t\t},\n\t\tGenerationConfig: ChatGenerationConfig{\n\t\t\tTemperature:     textRequest.Temperature,\n\t\t\tTopP:            textRequest.TopP,\n\t\t\tMaxOutputTokens: textRequest.MaxTokens,\n\t\t},\n\t}\n\tif textRequest.ResponseFormat != nil {\n\t\tif mimeType, ok := mimeTypeMap[textRequest.ResponseFormat.Type]; ok {\n\t\t\tgeminiRequest.GenerationConfig.ResponseMimeType = mimeType\n\t\t}\n\t\tif textRequest.ResponseFormat.JsonSchema != nil {\n\t\t\tgeminiRequest.GenerationConfig.ResponseSchema = textRequest.ResponseFormat.JsonSchema.Schema\n\t\t\tgeminiRequest.GenerationConfig.ResponseMimeType = mimeTypeMap[\"json_object\"]\n\t\t}\n\t}\n\tif textRequest.Tools != nil {\n\t\tfunctions := make([]model.Function, 0, len(textRequest.Tools))\n\t\tfor _, tool := range textRequest.Tools {\n\t\t\tfunctions = append(functions, tool.Function)\n\t\t}\n\t\tgeminiRequest.Tools = []ChatTools{\n\t\t\t{\n\t\t\t\tFunctionDeclarations: functions,\n\t\t\t},\n\t\t}\n\t} else if textRequest.Functions != nil {\n\t\tgeminiRequest.Tools = []ChatTools{\n\t\t\t{\n\t\t\t\tFunctionDeclarations: textRequest.Functions,\n\t\t\t},\n\t\t}\n\t}\n\tshouldAddDummyModelMessage := false\n\tfor _, message := range textRequest.Messages {\n\t\tcontent := ChatContent{\n\t\t\tRole: message.Role,\n\t\t\tParts: []Part{\n\t\t\t\t{\n\t\t\t\t\tText: message.StringContent(),\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\topenaiContent := message.ParseContent()\n\t\tvar parts []Part\n\t\timageNum := 0\n\t\tfor _, part := range openaiContent {\n\t\t\tif part.Type == model.ContentTypeText {\n\t\t\t\tparts = append(parts, Part{\n\t\t\t\t\tText: part.Text,\n\t\t\t\t})\n\t\t\t} else if part.Type == model.ContentTypeImageURL {\n\t\t\t\timageNum += 1\n\t\t\t\tif imageNum > VisionMaxImageNum {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmimeType, data, _ := image.GetImageFromUrl(part.ImageURL.Url)\n\t\t\t\tparts = append(parts, Part{\n\t\t\t\t\tInlineData: &InlineData{\n\t\t\t\t\t\tMimeType: mimeType,\n\t\t\t\t\t\tData:     data,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\tcontent.Parts = parts\n\n\t\t// there's no assistant role in gemini and API shall vomit if Role is not user or model\n\t\tif content.Role == \"assistant\" {\n\t\t\tcontent.Role = \"model\"\n\t\t}\n\t\t// Converting system prompt to prompt from user for the same reason\n\t\tif content.Role == \"system\" {\n\t\t\tshouldAddDummyModelMessage = true\n\t\t\tif IsModelSupportSystemInstruction(textRequest.Model) {\n\t\t\t\tgeminiRequest.SystemInstruction = &content\n\t\t\t\tgeminiRequest.SystemInstruction.Role = \"\"\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\tcontent.Role = \"user\"\n\t\t\t}\n\t\t}\n\n\t\tgeminiRequest.Contents = append(geminiRequest.Contents, content)\n\n\t\t// If a system message is the last message, we need to add a dummy model message to make gemini happy\n\t\tif shouldAddDummyModelMessage {\n\t\t\tgeminiRequest.Contents = append(geminiRequest.Contents, ChatContent{\n\t\t\t\tRole: \"model\",\n\t\t\t\tParts: []Part{\n\t\t\t\t\t{\n\t\t\t\t\t\tText: \"Okay\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t\tshouldAddDummyModelMessage = false\n\t\t}\n\t}\n\n\treturn &geminiRequest\n}\n\nfunc ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *BatchEmbeddingRequest {\n\tinputs := request.ParseInput()\n\trequests := make([]EmbeddingRequest, len(inputs))\n\tmodel := fmt.Sprintf(\"models/%s\", request.Model)\n\n\tfor i, input := range inputs {\n\t\trequests[i] = EmbeddingRequest{\n\t\t\tModel: model,\n\t\t\tContent: ChatContent{\n\t\t\t\tParts: []Part{\n\t\t\t\t\t{\n\t\t\t\t\t\tText: input,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\treturn &BatchEmbeddingRequest{\n\t\tRequests: requests,\n\t}\n}\n\ntype ChatResponse struct {\n\tCandidates     []ChatCandidate    `json:\"candidates\"`\n\tPromptFeedback ChatPromptFeedback `json:\"promptFeedback\"`\n}\n\nfunc (g *ChatResponse) GetResponseText() string {\n\tif g == nil {\n\t\treturn \"\"\n\t}\n\tif len(g.Candidates) > 0 && len(g.Candidates[0].Content.Parts) > 0 {\n\t\treturn g.Candidates[0].Content.Parts[0].Text\n\t}\n\treturn \"\"\n}\n\ntype ChatCandidate struct {\n\tContent       ChatContent        `json:\"content\"`\n\tFinishReason  string             `json:\"finishReason\"`\n\tIndex         int64              `json:\"index\"`\n\tSafetyRatings []ChatSafetyRating `json:\"safetyRatings\"`\n}\n\ntype ChatSafetyRating struct {\n\tCategory    string `json:\"category\"`\n\tProbability string `json:\"probability\"`\n}\n\ntype ChatPromptFeedback struct {\n\tSafetyRatings []ChatSafetyRating `json:\"safetyRatings\"`\n}\n\nfunc getToolCalls(candidate *ChatCandidate) []model.Tool {\n\tvar toolCalls []model.Tool\n\n\titem := candidate.Content.Parts[0]\n\tif item.FunctionCall == nil {\n\t\treturn toolCalls\n\t}\n\targsBytes, err := json.Marshal(item.FunctionCall.Arguments)\n\tif err != nil {\n\t\tlogger.FatalLog(\"getToolCalls failed: \" + err.Error())\n\t\treturn toolCalls\n\t}\n\ttoolCall := model.Tool{\n\t\tId:   fmt.Sprintf(\"call_%s\", random.GetUUID()),\n\t\tType: \"function\",\n\t\tFunction: model.Function{\n\t\t\tArguments: string(argsBytes),\n\t\t\tName:      item.FunctionCall.FunctionName,\n\t\t},\n\t}\n\ttoolCalls = append(toolCalls, toolCall)\n\treturn toolCalls\n}\n\nfunc responseGeminiChat2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: make([]openai.TextResponseChoice, 0, len(response.Candidates)),\n\t}\n\tfor i, candidate := range response.Candidates {\n\t\tchoice := openai.TextResponseChoice{\n\t\t\tIndex: i,\n\t\t\tMessage: model.Message{\n\t\t\t\tRole: \"assistant\",\n\t\t\t},\n\t\t\tFinishReason: constant.StopFinishReason,\n\t\t}\n\t\tif len(candidate.Content.Parts) > 0 {\n\t\t\tif candidate.Content.Parts[0].FunctionCall != nil {\n\t\t\t\tchoice.Message.ToolCalls = getToolCalls(&candidate)\n\t\t\t} else {\n\t\t\t\tvar builder strings.Builder\n\t\t\t\tfor _, part := range candidate.Content.Parts {\n\t\t\t\t\tif i > 0 {\n\t\t\t\t\t\tbuilder.WriteString(\"\\n\")\n\t\t\t\t\t}\n\t\t\t\t\tbuilder.WriteString(part.Text)\n\t\t\t\t}\n\t\t\t\tchoice.Message.Content = builder.String()\n\t\t\t}\n\t\t} else {\n\t\t\tchoice.Message.Content = \"\"\n\t\t\tchoice.FinishReason = candidate.FinishReason\n\t\t}\n\t\tfullTextResponse.Choices = append(fullTextResponse.Choices, choice)\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseGeminiChat2OpenAI(geminiResponse *ChatResponse) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = geminiResponse.GetResponseText()\n\t//choice.FinishReason = &constant.StopFinishReason\n\tvar response openai.ChatCompletionsStreamResponse\n\tresponse.Id = fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID())\n\tresponse.Created = helper.GetTimestamp()\n\tresponse.Object = \"chat.completion.chunk\"\n\tresponse.Model = \"gemini\"\n\tresponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice}\n\treturn &response\n}\n\nfunc embeddingResponseGemini2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {\n\topenAIEmbeddingResponse := openai.EmbeddingResponse{\n\t\tObject: \"list\",\n\t\tData:   make([]openai.EmbeddingResponseItem, 0, len(response.Embeddings)),\n\t\tModel:  \"gemini-embedding\",\n\t\tUsage:  model.Usage{TotalTokens: 0},\n\t}\n\tfor _, item := range response.Embeddings {\n\t\topenAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{\n\t\t\tObject:    `embedding`,\n\t\t\tIndex:     0,\n\t\t\tEmbedding: item.Values,\n\t\t})\n\t}\n\treturn &openAIEmbeddingResponse\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, string) {\n\tresponseText := \"\"\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tdata = strings.TrimSpace(data)\n\t\tif !strings.HasPrefix(data, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tdata = strings.TrimPrefix(data, \"data: \")\n\t\tdata = strings.TrimSuffix(data, \"\\\"\")\n\n\t\tvar geminiResponse ChatResponse\n\t\terr := json.Unmarshal([]byte(data), &geminiResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse := streamResponseGeminiChat2OpenAI(&geminiResponse)\n\t\tif response == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tresponseText += response.Choices[0].Delta.StringContent()\n\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), \"\"\n\t}\n\n\treturn nil, responseText\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar geminiResponse ChatResponse\n\terr = json.Unmarshal(responseBody, &geminiResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif len(geminiResponse.Candidates) == 0 {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: \"No candidates returned\",\n\t\t\t\tType:    \"server_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    500,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseGeminiChat2OpenAI(&geminiResponse)\n\tfullTextResponse.Model = modelName\n\tcompletionTokens := openai.CountTokenText(geminiResponse.GetResponseText(), modelName)\n\tusage := model.Usage{\n\t\tPromptTokens:     promptTokens,\n\t\tCompletionTokens: completionTokens,\n\t\tTotalTokens:      promptTokens + completionTokens,\n\t}\n\tfullTextResponse.Usage = usage\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &usage\n}\n\nfunc EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar geminiEmbeddingResponse EmbeddingResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &geminiEmbeddingResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif geminiEmbeddingResponse.Error != nil {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: geminiEmbeddingResponse.Error.Message,\n\t\t\t\tType:    \"gemini_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    geminiEmbeddingResponse.Error.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := embeddingResponseGemini2OpenAI(&geminiEmbeddingResponse)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n"
  },
  {
    "path": "relay/adaptor/gemini/model.go",
    "content": "package gemini\n\ntype ChatRequest struct {\n\tContents          []ChatContent        `json:\"contents\"`\n\tSafetySettings    []ChatSafetySettings `json:\"safety_settings,omitempty\"`\n\tGenerationConfig  ChatGenerationConfig `json:\"generation_config,omitempty\"`\n\tTools             []ChatTools          `json:\"tools,omitempty\"`\n\tSystemInstruction *ChatContent         `json:\"system_instruction,omitempty\"`\n}\n\ntype EmbeddingRequest struct {\n\tModel                string      `json:\"model\"`\n\tContent              ChatContent `json:\"content\"`\n\tTaskType             string      `json:\"taskType,omitempty\"`\n\tTitle                string      `json:\"title,omitempty\"`\n\tOutputDimensionality int         `json:\"outputDimensionality,omitempty\"`\n}\n\ntype BatchEmbeddingRequest struct {\n\tRequests []EmbeddingRequest `json:\"requests\"`\n}\n\ntype EmbeddingData struct {\n\tValues []float64 `json:\"values\"`\n}\n\ntype EmbeddingResponse struct {\n\tEmbeddings []EmbeddingData `json:\"embeddings\"`\n\tError      *Error          `json:\"error,omitempty\"`\n}\n\ntype Error struct {\n\tCode    int    `json:\"code,omitempty\"`\n\tMessage string `json:\"message,omitempty\"`\n\tStatus  string `json:\"status,omitempty\"`\n}\n\ntype InlineData struct {\n\tMimeType string `json:\"mimeType\"`\n\tData     string `json:\"data\"`\n}\n\ntype FunctionCall struct {\n\tFunctionName string `json:\"name\"`\n\tArguments    any    `json:\"args\"`\n}\n\ntype Part struct {\n\tText         string        `json:\"text,omitempty\"`\n\tInlineData   *InlineData   `json:\"inlineData,omitempty\"`\n\tFunctionCall *FunctionCall `json:\"functionCall,omitempty\"`\n}\n\ntype ChatContent struct {\n\tRole  string `json:\"role,omitempty\"`\n\tParts []Part `json:\"parts\"`\n}\n\ntype ChatSafetySettings struct {\n\tCategory  string `json:\"category\"`\n\tThreshold string `json:\"threshold\"`\n}\n\ntype ChatTools struct {\n\tFunctionDeclarations any `json:\"function_declarations,omitempty\"`\n}\n\ntype ChatGenerationConfig struct {\n\tResponseMimeType string   `json:\"responseMimeType,omitempty\"`\n\tResponseSchema   any      `json:\"responseSchema,omitempty\"`\n\tTemperature      *float64 `json:\"temperature,omitempty\"`\n\tTopP             *float64 `json:\"topP,omitempty\"`\n\tTopK             float64  `json:\"topK,omitempty\"`\n\tMaxOutputTokens  int      `json:\"maxOutputTokens,omitempty\"`\n\tCandidateCount   int      `json:\"candidateCount,omitempty\"`\n\tStopSequences    []string `json:\"stopSequences,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/geminiv2/constants.go",
    "content": "package geminiv2\n\n// https://ai.google.dev/models/gemini\n\nvar ModelList = []string{\n\t\"gemini-pro\", \"gemini-1.0-pro\",\n\t// \"gemma-2-2b-it\", \"gemma-2-9b-it\", \"gemma-2-27b-it\",\n\t\"gemini-1.5-flash\", \"gemini-1.5-flash-8b\",\n\t\"gemini-1.5-pro\", \"gemini-1.5-pro-experimental\",\n\t\"text-embedding-004\", \"aqa\",\n\t\"gemini-2.0-flash\", \"gemini-2.0-flash-exp\",\n\t\"gemini-2.0-flash-lite-preview-02-05\",\n\t\"gemini-2.0-flash-thinking-exp-01-21\",\n\t\"gemini-2.0-pro-exp-02-05\",\n}\n"
  },
  {
    "path": "relay/adaptor/geminiv2/main.go",
    "content": "package geminiv2\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n)\n\nfunc GetRequestURL(meta *meta.Meta) (string, error) {\n\tbaseURL := strings.TrimSuffix(meta.BaseURL, \"/\")\n\trequestPath := strings.TrimPrefix(meta.RequestURLPath, \"/v1\")\n\treturn fmt.Sprintf(\"%s%s\", baseURL, requestPath), nil\n}\n"
  },
  {
    "path": "relay/adaptor/groq/constants.go",
    "content": "package groq\n\n// https://console.groq.com/docs/models\n\nvar ModelList = []string{\n\t\"gemma2-9b-it\",\n\t\"llama-3.1-70b-versatile\",\n\t\"llama-3.1-8b-instant\",\n\t\"llama-3.2-11b-text-preview\",\n\t\"llama-3.2-11b-vision-preview\",\n\t\"llama-3.2-1b-preview\",\n\t\"llama-3.2-3b-preview\",\n\t\"llama-3.2-90b-text-preview\",\n\t\"llama-3.2-90b-vision-preview\",\n\t\"llama-guard-3-8b\",\n\t\"llama3-70b-8192\",\n\t\"llama3-8b-8192\",\n\t\"llama3-groq-70b-8192-tool-use-preview\",\n\t\"llama3-groq-8b-8192-tool-use-preview\",\n\t\"llava-v1.5-7b-4096-preview\",\n\t\"mixtral-8x7b-32768\",\n\t\"distil-whisper-large-v3-en\",\n\t\"whisper-large-v3\",\n\t\"whisper-large-v3-turbo\",\n\t\"deepseek-r1-distill-llama-70b-specdec\",\n\t\"deepseek-r1-distill-llama-70b\",\n}\n"
  },
  {
    "path": "relay/adaptor/interface.go",
    "content": "package adaptor\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Adaptor interface {\n\tInit(meta *meta.Meta)\n\tGetRequestURL(meta *meta.Meta) (string, error)\n\tSetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error\n\tConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error)\n\tConvertImageRequest(request *model.ImageRequest) (any, error)\n\tDoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error)\n\tDoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode)\n\tGetModelList() []string\n\tGetChannelName() string\n}\n"
  },
  {
    "path": "relay/adaptor/lingyiwanwu/constants.go",
    "content": "package lingyiwanwu\n\n// https://platform.lingyiwanwu.com/docs\n\nvar ModelList = []string{\n\t\"yi-34b-chat-0205\",\n\t\"yi-34b-chat-200k\",\n\t\"yi-vl-plus\",\n}\n"
  },
  {
    "path": "relay/adaptor/minimax/constants.go",
    "content": "package minimax\n\n// https://www.minimaxi.com/document/guides/chat-model/V2?id=65e0736ab2845de20908e2dd\n\nvar ModelList = []string{\n\t\"abab6.5-chat\",\n\t\"abab6.5s-chat\",\n\t\"abab6-chat\",\n\t\"abab5.5-chat\",\n\t\"abab5.5s-chat\",\n\t\"MiniMax-VL-01\",\n\t\"MiniMax-Text-01\",\n}\n"
  },
  {
    "path": "relay/adaptor/minimax/main.go",
    "content": "package minimax\n\nimport (\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc GetRequestURL(meta *meta.Meta) (string, error) {\n\tif meta.Mode == relaymode.ChatCompletions {\n\t\treturn fmt.Sprintf(\"%s/v1/text/chatcompletion_v2\", meta.BaseURL), nil\n\t}\n\treturn \"\", fmt.Errorf(\"unsupported relay mode %d for minimax\", meta.Mode)\n}\n"
  },
  {
    "path": "relay/adaptor/mistral/constants.go",
    "content": "package mistral\n\nvar ModelList = []string{\n\t\"open-mistral-7b\",\n\t\"open-mixtral-8x7b\",\n\t\"mistral-small-latest\",\n\t\"mistral-medium-latest\",\n\t\"mistral-large-latest\",\n\t\"mistral-embed\",\n}\n"
  },
  {
    "path": "relay/adaptor/moonshot/constants.go",
    "content": "package moonshot\n\nvar ModelList = []string{\n\t\"moonshot-v1-8k\",\n\t\"moonshot-v1-32k\",\n\t\"moonshot-v1-128k\",\n}\n"
  },
  {
    "path": "relay/adaptor/novita/constants.go",
    "content": "package novita\n\n// https://novita.ai/llm-api\n\nvar ModelList = []string{\n\t\"meta-llama/llama-3-8b-instruct\",\n\t\"meta-llama/llama-3-70b-instruct\",\n\t\"nousresearch/hermes-2-pro-llama-3-8b\",\n\t\"nousresearch/nous-hermes-llama2-13b\",\n\t\"mistralai/mistral-7b-instruct\",\n\t\"cognitivecomputations/dolphin-mixtral-8x22b\",\n\t\"sao10k/l3-70b-euryale-v2.1\",\n\t\"sophosympatheia/midnight-rose-70b\",\n\t\"gryphe/mythomax-l2-13b\",\n\t\"Nous-Hermes-2-Mixtral-8x7B-DPO\",\n\t\"lzlv_70b\",\n\t\"teknium/openhermes-2.5-mistral-7b\",\n\t\"microsoft/wizardlm-2-8x22b\",\n}\n"
  },
  {
    "path": "relay/adaptor/novita/main.go",
    "content": "package novita\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc GetRequestURL(meta *meta.Meta) (string, error) {\n\tif meta.Mode == relaymode.ChatCompletions {\n\t\treturn fmt.Sprintf(\"%s/chat/completions\", meta.BaseURL), nil\n\t}\n\treturn \"\", fmt.Errorf(\"unsupported relay mode %d for novita\", meta.Mode)\n}\n"
  },
  {
    "path": "relay/adaptor/ollama/adaptor.go",
    "content": "package ollama\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\t// https://github.com/ollama/ollama/blob/main/docs/api.md\n\tfullRequestURL := fmt.Sprintf(\"%s/api/chat\", meta.BaseURL)\n\tif meta.Mode == relaymode.Embeddings {\n\t\tfullRequestURL = fmt.Sprintf(\"%s/api/embed\", meta.BaseURL)\n\t}\n\treturn fullRequestURL, nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Embeddings:\n\t\tollamaEmbeddingRequest := ConvertEmbeddingRequest(*request)\n\t\treturn ollamaEmbeddingRequest, nil\n\tdefault:\n\t\treturn ConvertRequest(*request), nil\n\t}\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.Embeddings:\n\t\t\terr, usage = EmbeddingHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = Handler(c, resp)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"ollama\"\n}\n"
  },
  {
    "path": "relay/adaptor/ollama/constants.go",
    "content": "package ollama\n\nvar ModelList = []string{\n\t\"codellama:7b-instruct\",\n\t\"llama2:7b\",\n\t\"llama2:latest\",\n\t\"llama3:latest\",\n\t\"phi3:latest\",\n\t\"qwen:0.5b-chat\",\n\t\"qwen:7b\",\n}\n"
  },
  {
    "path": "relay/adaptor/ollama/main.go",
    "content": "package ollama\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/image\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest {\n\tollamaRequest := ChatRequest{\n\t\tModel: request.Model,\n\t\tOptions: &Options{\n\t\t\tSeed:             int(request.Seed),\n\t\t\tTemperature:      request.Temperature,\n\t\t\tTopP:             request.TopP,\n\t\t\tFrequencyPenalty: request.FrequencyPenalty,\n\t\t\tPresencePenalty:  request.PresencePenalty,\n\t\t\tNumPredict:       request.MaxTokens,\n\t\t\tNumCtx:           request.NumCtx,\n\t\t},\n\t\tStream: request.Stream,\n\t}\n\tfor _, message := range request.Messages {\n\t\topenaiContent := message.ParseContent()\n\t\tvar imageUrls []string\n\t\tvar contentText string\n\t\tfor _, part := range openaiContent {\n\t\t\tswitch part.Type {\n\t\t\tcase model.ContentTypeText:\n\t\t\t\tcontentText = part.Text\n\t\t\tcase model.ContentTypeImageURL:\n\t\t\t\t_, data, _ := image.GetImageFromUrl(part.ImageURL.Url)\n\t\t\t\timageUrls = append(imageUrls, data)\n\t\t\t}\n\t\t}\n\t\tollamaRequest.Messages = append(ollamaRequest.Messages, Message{\n\t\t\tRole:    message.Role,\n\t\t\tContent: contentText,\n\t\t\tImages:  imageUrls,\n\t\t})\n\t}\n\treturn &ollamaRequest\n}\n\nfunc responseOllama2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:    response.Message.Role,\n\t\t\tContent: response.Message.Content,\n\t\t},\n\t}\n\tif response.Done {\n\t\tchoice.FinishReason = \"stop\"\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tModel:   response.Model,\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t\tUsage: model.Usage{\n\t\t\tPromptTokens:     response.PromptEvalCount,\n\t\t\tCompletionTokens: response.EvalCount,\n\t\t\tTotalTokens:      response.PromptEvalCount + response.EvalCount,\n\t\t},\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseOllama2OpenAI(ollamaResponse *ChatResponse) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Role = ollamaResponse.Message.Role\n\tchoice.Delta.Content = ollamaResponse.Message.Content\n\tif ollamaResponse.Done {\n\t\tchoice.FinishReason = &constant.StopFinishReason\n\t}\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   ollamaResponse.Model,\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &response\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar usage model.Usage\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {\n\t\tif atEOF && len(data) == 0 {\n\t\t\treturn 0, nil, nil\n\t\t}\n\t\tif i := strings.Index(string(data), \"}\\n\"); i >= 0 {\n\t\t\treturn i + 2, data[0 : i+1], nil\n\t\t}\n\t\tif atEOF {\n\t\t\treturn len(data), data, nil\n\t\t}\n\t\treturn 0, nil, nil\n\t})\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif strings.HasPrefix(data, \"}\") {\n\t\t\tdata = strings.TrimPrefix(data, \"}\") + \"}\"\n\t\t}\n\n\t\tvar ollamaResponse ChatResponse\n\t\terr := json.Unmarshal([]byte(data), &ollamaResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tif ollamaResponse.EvalCount != 0 {\n\t\t\tusage.PromptTokens = ollamaResponse.PromptEvalCount\n\t\t\tusage.CompletionTokens = ollamaResponse.EvalCount\n\t\t\tusage.TotalTokens = ollamaResponse.PromptEvalCount + ollamaResponse.EvalCount\n\t\t}\n\n\t\tresponse := streamResponseOllama2OpenAI(&ollamaResponse)\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\treturn nil, &usage\n}\n\nfunc ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *EmbeddingRequest {\n\treturn &EmbeddingRequest{\n\t\tModel: request.Model,\n\t\tInput: request.ParseInput(),\n\t\tOptions: &Options{\n\t\t\tSeed:             int(request.Seed),\n\t\t\tTemperature:      request.Temperature,\n\t\t\tTopP:             request.TopP,\n\t\t\tFrequencyPenalty: request.FrequencyPenalty,\n\t\t\tPresencePenalty:  request.PresencePenalty,\n\t\t},\n\t}\n}\n\nfunc EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar ollamaResponse EmbeddingResponse\n\terr := json.NewDecoder(resp.Body).Decode(&ollamaResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tif ollamaResponse.Error != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: ollamaResponse.Error,\n\t\t\t\tType:    \"ollama_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    \"ollama_error\",\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\n\tfullTextResponse := embeddingResponseOllama2OpenAI(&ollamaResponse)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc embeddingResponseOllama2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {\n\topenAIEmbeddingResponse := openai.EmbeddingResponse{\n\t\tObject: \"list\",\n\t\tData:   make([]openai.EmbeddingResponseItem, 0, 1),\n\t\tModel:  response.Model,\n\t\tUsage:  model.Usage{TotalTokens: 0},\n\t}\n\n\tfor i, embedding := range response.Embeddings {\n\t\topenAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{\n\t\t\tObject:    `embedding`,\n\t\t\tIndex:     i,\n\t\t\tEmbedding: embedding,\n\t\t})\n\t}\n\treturn &openAIEmbeddingResponse\n}\n\nfunc Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tctx := context.TODO()\n\tvar ollamaResponse ChatResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tlogger.Debugf(ctx, \"ollama response: %s\", string(responseBody))\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &ollamaResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif ollamaResponse.Error != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: ollamaResponse.Error,\n\t\t\t\tType:    \"ollama_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    \"ollama_error\",\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseOllama2OpenAI(&ollamaResponse)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n"
  },
  {
    "path": "relay/adaptor/ollama/model.go",
    "content": "package ollama\n\ntype Options struct {\n\tSeed             int      `json:\"seed,omitempty\"`\n\tTemperature      *float64 `json:\"temperature,omitempty\"`\n\tTopK             int      `json:\"top_k,omitempty\"`\n\tTopP             *float64 `json:\"top_p,omitempty\"`\n\tFrequencyPenalty *float64 `json:\"frequency_penalty,omitempty\"`\n\tPresencePenalty  *float64 `json:\"presence_penalty,omitempty\"`\n\tNumPredict       int      `json:\"num_predict,omitempty\"`\n\tNumCtx           int      `json:\"num_ctx,omitempty\"`\n}\n\ntype Message struct {\n\tRole    string   `json:\"role,omitempty\"`\n\tContent string   `json:\"content,omitempty\"`\n\tImages  []string `json:\"images,omitempty\"`\n}\n\ntype ChatRequest struct {\n\tModel    string    `json:\"model,omitempty\"`\n\tMessages []Message `json:\"messages,omitempty\"`\n\tStream   bool      `json:\"stream\"`\n\tOptions  *Options  `json:\"options,omitempty\"`\n}\n\ntype ChatResponse struct {\n\tModel           string  `json:\"model,omitempty\"`\n\tCreatedAt       string  `json:\"created_at,omitempty\"`\n\tMessage         Message `json:\"message,omitempty\"`\n\tResponse        string  `json:\"response,omitempty\"` // for stream response\n\tDone            bool    `json:\"done,omitempty\"`\n\tTotalDuration   int     `json:\"total_duration,omitempty\"`\n\tLoadDuration    int     `json:\"load_duration,omitempty\"`\n\tPromptEvalCount int     `json:\"prompt_eval_count,omitempty\"`\n\tEvalCount       int     `json:\"eval_count,omitempty\"`\n\tEvalDuration    int     `json:\"eval_duration,omitempty\"`\n\tError           string  `json:\"error,omitempty\"`\n}\n\ntype EmbeddingRequest struct {\n\tModel string   `json:\"model\"`\n\tInput []string `json:\"input\"`\n\t// Truncate  bool     `json:\"truncate,omitempty\"`\n\tOptions *Options `json:\"options,omitempty\"`\n\t// KeepAlive string   `json:\"keep_alive,omitempty\"`\n}\n\ntype EmbeddingResponse struct {\n\tError      string      `json:\"error,omitempty\"`\n\tModel      string      `json:\"model\"`\n\tEmbeddings [][]float64 `json:\"embeddings\"`\n}\n"
  },
  {
    "path": "relay/adaptor/openai/adaptor.go",
    "content": "package openai\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/alibailian\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/baiduv2\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/doubao\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/geminiv2\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/minimax\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/novita\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\ntype Adaptor struct {\n\tChannelType int\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.ChannelType = meta.ChannelType\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tswitch meta.ChannelType {\n\tcase channeltype.Azure:\n\t\tif meta.Mode == relaymode.ImagesGenerations {\n\t\t\t// https://learn.microsoft.com/en-us/azure/ai-services/openai/dall-e-quickstart?tabs=dalle3%2Ccommand-line&pivots=rest-api\n\t\t\t// https://{resource_name}.openai.azure.com/openai/deployments/dall-e-3/images/generations?api-version=2024-03-01-preview\n\t\t\tfullRequestURL := fmt.Sprintf(\"%s/openai/deployments/%s/images/generations?api-version=%s\", meta.BaseURL, meta.ActualModelName, meta.Config.APIVersion)\n\t\t\treturn fullRequestURL, nil\n\t\t}\n\n\t\t// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api\n\t\trequestURL := strings.Split(meta.RequestURLPath, \"?\")[0]\n\t\trequestURL = fmt.Sprintf(\"%s?api-version=%s\", requestURL, meta.Config.APIVersion)\n\t\ttask := strings.TrimPrefix(requestURL, \"/v1/\")\n\t\tmodel_ := meta.ActualModelName\n\t\tmodel_ = strings.Replace(model_, \".\", \"\", -1)\n\t\t//https://github.com/songquanpeng/one-api/issues/1191\n\t\t// {your endpoint}/openai/deployments/{your azure_model}/chat/completions?api-version={api_version}\n\t\trequestURL = fmt.Sprintf(\"/openai/deployments/%s/%s\", model_, task)\n\t\treturn GetFullRequestURL(meta.BaseURL, requestURL, meta.ChannelType), nil\n\tcase channeltype.Minimax:\n\t\treturn minimax.GetRequestURL(meta)\n\tcase channeltype.Doubao:\n\t\treturn doubao.GetRequestURL(meta)\n\tcase channeltype.Novita:\n\t\treturn novita.GetRequestURL(meta)\n\tcase channeltype.BaiduV2:\n\t\treturn baiduv2.GetRequestURL(meta)\n\tcase channeltype.AliBailian:\n\t\treturn alibailian.GetRequestURL(meta)\n\tcase channeltype.GeminiOpenAICompatible:\n\t\treturn geminiv2.GetRequestURL(meta)\n\tdefault:\n\t\treturn GetFullRequestURL(meta.BaseURL, meta.RequestURLPath, meta.ChannelType), nil\n\t}\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\tif meta.ChannelType == channeltype.Azure {\n\t\treq.Header.Set(\"api-key\", meta.APIKey)\n\t\treturn nil\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\tif meta.ChannelType == channeltype.OpenRouter {\n\t\treq.Header.Set(\"HTTP-Referer\", \"https://github.com/songquanpeng/one-api\")\n\t\treq.Header.Set(\"X-Title\", \"One API\")\n\t}\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tif request.Stream {\n\t\t// always return usage in stream mode\n\t\tif request.StreamOptions == nil {\n\t\t\trequest.StreamOptions = &model.StreamOptions{}\n\t\t}\n\t\trequest.StreamOptions.IncludeUsage = true\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\tvar responseText string\n\t\terr, responseText, usage = StreamHandler(c, resp, meta.Mode)\n\t\tif usage == nil || usage.TotalTokens == 0 {\n\t\t\tusage = ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)\n\t\t}\n\t\tif usage.TotalTokens != 0 && usage.PromptTokens == 0 { // some channels don't return prompt tokens & completion tokens\n\t\t\tusage.PromptTokens = meta.PromptTokens\n\t\t\tusage.CompletionTokens = usage.TotalTokens - meta.PromptTokens\n\t\t}\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.ImagesGenerations:\n\t\t\terr, _ = ImageHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\t_, modelList := GetCompatibleChannelMeta(a.ChannelType)\n\treturn modelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\tchannelName, _ := GetCompatibleChannelMeta(a.ChannelType)\n\treturn channelName\n}\n"
  },
  {
    "path": "relay/adaptor/openai/compatible.go",
    "content": "package openai\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/adaptor/ai360\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/alibailian\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/baichuan\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/baiduv2\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/deepseek\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/doubao\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/geminiv2\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/groq\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/lingyiwanwu\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/minimax\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/mistral\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/moonshot\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/novita\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openrouter\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/siliconflow\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/stepfun\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/togetherai\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/xai\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/xunfeiv2\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n)\n\nvar CompatibleChannels = []int{\n\tchanneltype.Azure,\n\tchanneltype.AI360,\n\tchanneltype.Moonshot,\n\tchanneltype.Baichuan,\n\tchanneltype.Minimax,\n\tchanneltype.Doubao,\n\tchanneltype.Mistral,\n\tchanneltype.Groq,\n\tchanneltype.LingYiWanWu,\n\tchanneltype.StepFun,\n\tchanneltype.DeepSeek,\n\tchanneltype.TogetherAI,\n\tchanneltype.Novita,\n\tchanneltype.SiliconFlow,\n\tchanneltype.XAI,\n\tchanneltype.BaiduV2,\n\tchanneltype.XunfeiV2,\n}\n\nfunc GetCompatibleChannelMeta(channelType int) (string, []string) {\n\tswitch channelType {\n\tcase channeltype.Azure:\n\t\treturn \"azure\", ModelList\n\tcase channeltype.AI360:\n\t\treturn \"360\", ai360.ModelList\n\tcase channeltype.Moonshot:\n\t\treturn \"moonshot\", moonshot.ModelList\n\tcase channeltype.Baichuan:\n\t\treturn \"baichuan\", baichuan.ModelList\n\tcase channeltype.Minimax:\n\t\treturn \"minimax\", minimax.ModelList\n\tcase channeltype.Mistral:\n\t\treturn \"mistralai\", mistral.ModelList\n\tcase channeltype.Groq:\n\t\treturn \"groq\", groq.ModelList\n\tcase channeltype.LingYiWanWu:\n\t\treturn \"lingyiwanwu\", lingyiwanwu.ModelList\n\tcase channeltype.StepFun:\n\t\treturn \"stepfun\", stepfun.ModelList\n\tcase channeltype.DeepSeek:\n\t\treturn \"deepseek\", deepseek.ModelList\n\tcase channeltype.TogetherAI:\n\t\treturn \"together.ai\", togetherai.ModelList\n\tcase channeltype.Doubao:\n\t\treturn \"doubao\", doubao.ModelList\n\tcase channeltype.Novita:\n\t\treturn \"novita\", novita.ModelList\n\tcase channeltype.SiliconFlow:\n\t\treturn \"siliconflow\", siliconflow.ModelList\n\tcase channeltype.XAI:\n\t\treturn \"xai\", xai.ModelList\n\tcase channeltype.BaiduV2:\n\t\treturn \"baiduv2\", baiduv2.ModelList\n\tcase channeltype.XunfeiV2:\n\t\treturn \"xunfeiv2\", xunfeiv2.ModelList\n\tcase channeltype.OpenRouter:\n\t\treturn \"openrouter\", openrouter.ModelList\n\tcase channeltype.AliBailian:\n\t\treturn \"alibailian\", alibailian.ModelList\n\tcase channeltype.GeminiOpenAICompatible:\n\t\treturn \"geminiv2\", geminiv2.ModelList\n\tdefault:\n\t\treturn \"openai\", ModelList\n\t}\n}\n"
  },
  {
    "path": "relay/adaptor/openai/constants.go",
    "content": "package openai\n\nvar ModelList = []string{\n\t\"gpt-3.5-turbo\", \"gpt-3.5-turbo-0301\", \"gpt-3.5-turbo-0613\", \"gpt-3.5-turbo-1106\", \"gpt-3.5-turbo-0125\",\n\t\"gpt-3.5-turbo-16k\", \"gpt-3.5-turbo-16k-0613\",\n\t\"gpt-3.5-turbo-instruct\",\n\t\"gpt-4\", \"gpt-4-0314\", \"gpt-4-0613\", \"gpt-4-1106-preview\", \"gpt-4-0125-preview\",\n\t\"gpt-4-32k\", \"gpt-4-32k-0314\", \"gpt-4-32k-0613\",\n\t\"gpt-4-turbo-preview\", \"gpt-4-turbo\", \"gpt-4-turbo-2024-04-09\",\n\t\"gpt-4o\", \"gpt-4o-2024-05-13\",\n\t\"gpt-4o-2024-08-06\",\n\t\"gpt-4o-2024-11-20\",\n\t\"chatgpt-4o-latest\",\n\t\"gpt-4o-mini\", \"gpt-4o-mini-2024-07-18\",\n\t\"gpt-4-vision-preview\",\n\t\"text-embedding-ada-002\", \"text-embedding-3-small\", \"text-embedding-3-large\",\n\t\"text-curie-001\", \"text-babbage-001\", \"text-ada-001\", \"text-davinci-002\", \"text-davinci-003\",\n\t\"text-moderation-latest\", \"text-moderation-stable\",\n\t\"text-davinci-edit-001\",\n\t\"davinci-002\", \"babbage-002\",\n\t\"dall-e-2\", \"dall-e-3\",\n\t\"whisper-1\",\n\t\"tts-1\", \"tts-1-1106\", \"tts-1-hd\", \"tts-1-hd-1106\",\n\t\"o1\", \"o1-2024-12-17\",\n\t\"o1-preview\", \"o1-preview-2024-09-12\",\n\t\"o1-mini\", \"o1-mini-2024-09-12\",\n}\n"
  },
  {
    "path": "relay/adaptor/openai/helper.go",
    "content": "package openai\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ResponseText2Usage(responseText string, modelName string, promptTokens int) *model.Usage {\n\tusage := &model.Usage{}\n\tusage.PromptTokens = promptTokens\n\tusage.CompletionTokens = CountTokenText(responseText, modelName)\n\tusage.TotalTokens = usage.PromptTokens + usage.CompletionTokens\n\treturn usage\n}\n\nfunc GetFullRequestURL(baseURL string, requestURL string, channelType int) string {\n\tif channelType == channeltype.OpenAICompatible {\n\t\treturn fmt.Sprintf(\"%s%s\", strings.TrimSuffix(baseURL, \"/\"), strings.TrimPrefix(requestURL, \"/v1\"))\n\t}\n\tfullRequestURL := fmt.Sprintf(\"%s%s\", baseURL, requestURL)\n\n\tif strings.HasPrefix(baseURL, \"https://gateway.ai.cloudflare.com\") {\n\t\tswitch channelType {\n\t\tcase channeltype.OpenAI:\n\t\t\tfullRequestURL = fmt.Sprintf(\"%s%s\", baseURL, strings.TrimPrefix(requestURL, \"/v1\"))\n\t\tcase channeltype.Azure:\n\t\t\tfullRequestURL = fmt.Sprintf(\"%s%s\", baseURL, strings.TrimPrefix(requestURL, \"/openai/deployments\"))\n\t\t}\n\t}\n\treturn fullRequestURL\n}\n"
  },
  {
    "path": "relay/adaptor/openai/image.go",
    "content": "package openai\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc ImageHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar imageResponse ImageResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &imageResponse)\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tresp.Body = io.NopCloser(bytes.NewBuffer(responseBody))\n\n\tfor k, v := range resp.Header {\n\t\tc.Writer.Header().Set(k, v[0])\n\t}\n\tc.Writer.WriteHeader(resp.StatusCode)\n\n\t_, err = io.Copy(c.Writer, resp.Body)\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"copy_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\treturn nil, nil\n}\n"
  },
  {
    "path": "relay/adaptor/openai/main.go",
    "content": "package openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/render\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/conv\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nconst (\n\tdataPrefix       = \"data: \"\n\tdone             = \"[DONE]\"\n\tdataPrefixLength = len(dataPrefix)\n)\n\nfunc StreamHandler(c *gin.Context, resp *http.Response, relayMode int) (*model.ErrorWithStatusCode, string, *model.Usage) {\n\tresponseText := \"\"\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\tvar usage *model.Usage\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tdoneRendered := false\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < dataPrefixLength { // ignore blank line or wrong format\n\t\t\tcontinue\n\t\t}\n\t\tif data[:dataPrefixLength] != dataPrefix && data[:dataPrefixLength] != done {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasPrefix(data[dataPrefixLength:], done) {\n\t\t\trender.StringData(c, data)\n\t\t\tdoneRendered = true\n\t\t\tcontinue\n\t\t}\n\t\tswitch relayMode {\n\t\tcase relaymode.ChatCompletions:\n\t\t\tvar streamResponse ChatCompletionsStreamResponse\n\t\t\terr := json.Unmarshal([]byte(data[dataPrefixLength:]), &streamResponse)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\t\trender.StringData(c, data) // if error happened, pass the data to client\n\t\t\t\tcontinue                   // just ignore the error\n\t\t\t}\n\t\t\tif len(streamResponse.Choices) == 0 && streamResponse.Usage == nil {\n\t\t\t\t// but for empty choice and no usage, we should not pass it to client, this is for azure\n\t\t\t\tcontinue // just ignore empty choice\n\t\t\t}\n\t\t\trender.StringData(c, data)\n\t\t\tfor _, choice := range streamResponse.Choices {\n\t\t\t\tresponseText += conv.AsString(choice.Delta.Content)\n\t\t\t}\n\t\t\tif streamResponse.Usage != nil {\n\t\t\t\tusage = streamResponse.Usage\n\t\t\t}\n\t\tcase relaymode.Completions:\n\t\t\trender.StringData(c, data)\n\t\t\tvar streamResponse CompletionsStreamResponse\n\t\t\terr := json.Unmarshal([]byte(data[dataPrefixLength:]), &streamResponse)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, choice := range streamResponse.Choices {\n\t\t\t\tresponseText += choice.Text\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\tif !doneRendered {\n\t\trender.Done(c)\n\t}\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), \"\", nil\n\t}\n\n\treturn nil, responseText, usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar textResponse SlimTextResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &textResponse)\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif textResponse.Error.Type != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError:      textResponse.Error,\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\t// Reset response body\n\tresp.Body = io.NopCloser(bytes.NewBuffer(responseBody))\n\n\t// We shouldn't set the header before we parse the response body, because the parse part may fail.\n\t// And then we will have to send an error response, but in this case, the header has already been set.\n\t// So the HTTPClient will be confused by the response.\n\t// For example, Postman will report error, and we cannot check the response at all.\n\tfor k, v := range resp.Header {\n\t\tc.Writer.Header().Set(k, v[0])\n\t}\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = io.Copy(c.Writer, resp.Body)\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"copy_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tif textResponse.Usage.TotalTokens == 0 || (textResponse.Usage.PromptTokens == 0 && textResponse.Usage.CompletionTokens == 0) {\n\t\tcompletionTokens := 0\n\t\tfor _, choice := range textResponse.Choices {\n\t\t\tcompletionTokens += CountTokenText(choice.Message.StringContent(), modelName)\n\t\t}\n\t\ttextResponse.Usage = model.Usage{\n\t\t\tPromptTokens:     promptTokens,\n\t\t\tCompletionTokens: completionTokens,\n\t\t\tTotalTokens:      promptTokens + completionTokens,\n\t\t}\n\t}\n\treturn nil, &textResponse.Usage\n}\n"
  },
  {
    "path": "relay/adaptor/openai/model.go",
    "content": "package openai\n\nimport \"github.com/songquanpeng/one-api/relay/model\"\n\ntype TextContent struct {\n\tType string `json:\"type,omitempty\"`\n\tText string `json:\"text,omitempty\"`\n}\n\ntype ImageContent struct {\n\tType     string          `json:\"type,omitempty\"`\n\tImageURL *model.ImageURL `json:\"image_url,omitempty\"`\n}\n\ntype ChatRequest struct {\n\tModel     string          `json:\"model\"`\n\tMessages  []model.Message `json:\"messages\"`\n\tMaxTokens int             `json:\"max_tokens\"`\n}\n\ntype TextRequest struct {\n\tModel     string          `json:\"model\"`\n\tMessages  []model.Message `json:\"messages\"`\n\tPrompt    string          `json:\"prompt\"`\n\tMaxTokens int             `json:\"max_tokens\"`\n\t//Stream   bool      `json:\"stream\"`\n}\n\n// ImageRequest docs: https://platform.openai.com/docs/api-reference/images/create\ntype ImageRequest struct {\n\tModel          string `json:\"model\"`\n\tPrompt         string `json:\"prompt\" binding:\"required\"`\n\tN              int    `json:\"n,omitempty\"`\n\tSize           string `json:\"size,omitempty\"`\n\tQuality        string `json:\"quality,omitempty\"`\n\tResponseFormat string `json:\"response_format,omitempty\"`\n\tStyle          string `json:\"style,omitempty\"`\n\tUser           string `json:\"user,omitempty\"`\n}\n\ntype WhisperJSONResponse struct {\n\tText string `json:\"text,omitempty\"`\n}\n\ntype WhisperVerboseJSONResponse struct {\n\tTask     string    `json:\"task,omitempty\"`\n\tLanguage string    `json:\"language,omitempty\"`\n\tDuration float64   `json:\"duration,omitempty\"`\n\tText     string    `json:\"text,omitempty\"`\n\tSegments []Segment `json:\"segments,omitempty\"`\n}\n\ntype Segment struct {\n\tId               int     `json:\"id\"`\n\tSeek             int     `json:\"seek\"`\n\tStart            float64 `json:\"start\"`\n\tEnd              float64 `json:\"end\"`\n\tText             string  `json:\"text\"`\n\tTokens           []int   `json:\"tokens\"`\n\tTemperature      float64 `json:\"temperature\"`\n\tAvgLogprob       float64 `json:\"avg_logprob\"`\n\tCompressionRatio float64 `json:\"compression_ratio\"`\n\tNoSpeechProb     float64 `json:\"no_speech_prob\"`\n}\n\ntype TextToSpeechRequest struct {\n\tModel          string  `json:\"model\" binding:\"required\"`\n\tInput          string  `json:\"input\" binding:\"required\"`\n\tVoice          string  `json:\"voice\" binding:\"required\"`\n\tSpeed          float64 `json:\"speed\"`\n\tResponseFormat string  `json:\"response_format\"`\n}\n\ntype UsageOrResponseText struct {\n\t*model.Usage\n\tResponseText string\n}\n\ntype SlimTextResponse struct {\n\tChoices     []TextResponseChoice `json:\"choices\"`\n\tmodel.Usage `json:\"usage\"`\n\tError       model.Error `json:\"error\"`\n}\n\ntype TextResponseChoice struct {\n\tIndex         int `json:\"index\"`\n\tmodel.Message `json:\"message\"`\n\tFinishReason  string `json:\"finish_reason\"`\n}\n\ntype TextResponse struct {\n\tId          string               `json:\"id\"`\n\tModel       string               `json:\"model,omitempty\"`\n\tObject      string               `json:\"object\"`\n\tCreated     int64                `json:\"created\"`\n\tChoices     []TextResponseChoice `json:\"choices\"`\n\tmodel.Usage `json:\"usage\"`\n}\n\ntype EmbeddingResponseItem struct {\n\tObject    string    `json:\"object\"`\n\tIndex     int       `json:\"index\"`\n\tEmbedding []float64 `json:\"embedding\"`\n}\n\ntype EmbeddingResponse struct {\n\tObject      string                  `json:\"object\"`\n\tData        []EmbeddingResponseItem `json:\"data\"`\n\tModel       string                  `json:\"model\"`\n\tmodel.Usage `json:\"usage\"`\n}\n\ntype ImageData struct {\n\tUrl           string `json:\"url,omitempty\"`\n\tB64Json       string `json:\"b64_json,omitempty\"`\n\tRevisedPrompt string `json:\"revised_prompt,omitempty\"`\n}\n\ntype ImageResponse struct {\n\tCreated int64       `json:\"created\"`\n\tData    []ImageData `json:\"data\"`\n\t//model.Usage `json:\"usage\"`\n}\n\ntype ChatCompletionsStreamResponseChoice struct {\n\tIndex        int           `json:\"index\"`\n\tDelta        model.Message `json:\"delta\"`\n\tFinishReason *string       `json:\"finish_reason,omitempty\"`\n}\n\ntype ChatCompletionsStreamResponse struct {\n\tId      string                                `json:\"id\"`\n\tObject  string                                `json:\"object\"`\n\tCreated int64                                 `json:\"created\"`\n\tModel   string                                `json:\"model\"`\n\tChoices []ChatCompletionsStreamResponseChoice `json:\"choices\"`\n\tUsage   *model.Usage                          `json:\"usage,omitempty\"`\n}\n\ntype CompletionsStreamResponse struct {\n\tChoices []struct {\n\t\tText         string `json:\"text\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n}\n"
  },
  {
    "path": "relay/adaptor/openai/token.go",
    "content": "package openai\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"strings\"\n\n\t\"github.com/pkoukk/tiktoken-go\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/image\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// tokenEncoderMap won't grow after initialization\nvar tokenEncoderMap = map[string]*tiktoken.Tiktoken{}\nvar defaultTokenEncoder *tiktoken.Tiktoken\n\nfunc InitTokenEncoders() {\n\tlogger.SysLog(\"initializing token encoders\")\n\tgpt35TokenEncoder, err := tiktoken.EncodingForModel(\"gpt-3.5-turbo\")\n\tif err != nil {\n\t\tlogger.FatalLog(fmt.Sprintf(\"failed to get gpt-3.5-turbo token encoder: %s, \"+\n\t\t\t\"if you are using in offline environment, please set TIKTOKEN_CACHE_DIR to use exsited files, check this link for more information: https://stackoverflow.com/questions/76106366/how-to-use-tiktoken-in-offline-mode-computer \", err.Error()))\n\t}\n\tdefaultTokenEncoder = gpt35TokenEncoder\n\tgpt4oTokenEncoder, err := tiktoken.EncodingForModel(\"gpt-4o\")\n\tif err != nil {\n\t\tlogger.FatalLog(fmt.Sprintf(\"failed to get gpt-4o token encoder: %s\", err.Error()))\n\t}\n\tgpt4TokenEncoder, err := tiktoken.EncodingForModel(\"gpt-4\")\n\tif err != nil {\n\t\tlogger.FatalLog(fmt.Sprintf(\"failed to get gpt-4 token encoder: %s\", err.Error()))\n\t}\n\tfor model := range billingratio.ModelRatio {\n\t\tif strings.HasPrefix(model, \"gpt-3.5\") {\n\t\t\ttokenEncoderMap[model] = gpt35TokenEncoder\n\t\t} else if strings.HasPrefix(model, \"gpt-4o\") {\n\t\t\ttokenEncoderMap[model] = gpt4oTokenEncoder\n\t\t} else if strings.HasPrefix(model, \"gpt-4\") {\n\t\t\ttokenEncoderMap[model] = gpt4TokenEncoder\n\t\t} else {\n\t\t\ttokenEncoderMap[model] = nil\n\t\t}\n\t}\n\tlogger.SysLog(\"token encoders initialized\")\n}\n\nfunc getTokenEncoder(model string) *tiktoken.Tiktoken {\n\ttokenEncoder, ok := tokenEncoderMap[model]\n\tif ok && tokenEncoder != nil {\n\t\treturn tokenEncoder\n\t}\n\tif ok {\n\t\ttokenEncoder, err := tiktoken.EncodingForModel(model)\n\t\tif err != nil {\n\t\t\tlogger.SysError(fmt.Sprintf(\"failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo\", model, err.Error()))\n\t\t\ttokenEncoder = defaultTokenEncoder\n\t\t}\n\t\ttokenEncoderMap[model] = tokenEncoder\n\t\treturn tokenEncoder\n\t}\n\treturn defaultTokenEncoder\n}\n\nfunc getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {\n\tif config.ApproximateTokenEnabled {\n\t\treturn int(float64(len(text)) * 0.38)\n\t}\n\treturn len(tokenEncoder.Encode(text, nil, nil))\n}\n\nfunc CountTokenMessages(messages []model.Message, model string) int {\n\ttokenEncoder := getTokenEncoder(model)\n\t// Reference:\n\t// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb\n\t// https://github.com/pkoukk/tiktoken-go/issues/6\n\t//\n\t// Every message follows <|start|>{role/name}\\n{content}<|end|>\\n\n\tvar tokensPerMessage int\n\tvar tokensPerName int\n\tif model == \"gpt-3.5-turbo-0301\" {\n\t\ttokensPerMessage = 4\n\t\ttokensPerName = -1 // If there's a name, the role is omitted\n\t} else {\n\t\ttokensPerMessage = 3\n\t\ttokensPerName = 1\n\t}\n\ttokenNum := 0\n\tfor _, message := range messages {\n\t\ttokenNum += tokensPerMessage\n\t\tswitch v := message.Content.(type) {\n\t\tcase string:\n\t\t\ttokenNum += getTokenNum(tokenEncoder, v)\n\t\tcase []any:\n\t\t\tfor _, it := range v {\n\t\t\t\tm := it.(map[string]any)\n\t\t\t\tswitch m[\"type\"] {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tif textValue, ok := m[\"text\"]; ok {\n\t\t\t\t\t\tif textString, ok := textValue.(string); ok {\n\t\t\t\t\t\t\ttokenNum += getTokenNum(tokenEncoder, textString)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase \"image_url\":\n\t\t\t\t\timageUrl, ok := m[\"image_url\"].(map[string]any)\n\t\t\t\t\tif ok {\n\t\t\t\t\t\turl := imageUrl[\"url\"].(string)\n\t\t\t\t\t\tdetail := \"\"\n\t\t\t\t\t\tif imageUrl[\"detail\"] != nil {\n\t\t\t\t\t\t\tdetail = imageUrl[\"detail\"].(string)\n\t\t\t\t\t\t}\n\t\t\t\t\t\timageTokens, err := countImageTokens(url, detail, model)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tlogger.SysError(\"error counting image tokens: \" + err.Error())\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttokenNum += imageTokens\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ttokenNum += getTokenNum(tokenEncoder, message.Role)\n\t\tif message.Name != nil {\n\t\t\ttokenNum += tokensPerName\n\t\t\ttokenNum += getTokenNum(tokenEncoder, *message.Name)\n\t\t}\n\t}\n\ttokenNum += 3 // Every reply is primed with <|start|>assistant<|message|>\n\treturn tokenNum\n}\n\nconst (\n\tlowDetailCost         = 85\n\thighDetailCostPerTile = 170\n\tadditionalCost        = 85\n\t// gpt-4o-mini cost higher than other model\n\tgpt4oMiniLowDetailCost  = 2833\n\tgpt4oMiniHighDetailCost = 5667\n\tgpt4oMiniAdditionalCost = 2833\n)\n\n// https://platform.openai.com/docs/guides/vision/calculating-costs\n// https://github.com/openai/openai-cookbook/blob/05e3f9be4c7a2ae7ecf029a7c32065b024730ebe/examples/How_to_count_tokens_with_tiktoken.ipynb\nfunc countImageTokens(url string, detail string, model string) (_ int, err error) {\n\tvar fetchSize = true\n\tvar width, height int\n\t// Reference: https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding\n\t// detail == \"auto\" is undocumented on how it works, it just said the model will use the auto setting which will look at the image input size and decide if it should use the low or high setting.\n\t// According to the official guide, \"low\" disable the high-res model,\n\t// and only receive low-res 512px x 512px version of the image, indicating\n\t// that image is treated as low-res when size is smaller than 512px x 512px,\n\t// then we can assume that image size larger than 512px x 512px is treated\n\t// as high-res. Then we have the following logic:\n\t// if detail == \"\" || detail == \"auto\" {\n\t// \twidth, height, err = image.GetImageSize(url)\n\t// \tif err != nil {\n\t// \t\treturn 0, err\n\t// \t}\n\t// \tfetchSize = false\n\t// \t// not sure if this is correct\n\t// \tif width > 512 || height > 512 {\n\t// \t\tdetail = \"high\"\n\t// \t} else {\n\t// \t\tdetail = \"low\"\n\t// \t}\n\t// }\n\n\t// However, in my test, it seems to be always the same as \"high\".\n\t// The following image, which is 125x50, is still treated as high-res, taken\n\t// 255 tokens in the response of non-stream chat completion api.\n\t// https://upload.wikimedia.org/wikipedia/commons/1/10/18_Infantry_Division_Messina.jpg\n\tif detail == \"\" || detail == \"auto\" {\n\t\t// assume by test, not sure if this is correct\n\t\tdetail = \"high\"\n\t}\n\tswitch detail {\n\tcase \"low\":\n\t\tif strings.HasPrefix(model, \"gpt-4o-mini\") {\n\t\t\treturn gpt4oMiniLowDetailCost, nil\n\t\t}\n\t\treturn lowDetailCost, nil\n\tcase \"high\":\n\t\tif fetchSize {\n\t\t\twidth, height, err = image.GetImageSize(url)\n\t\t\tif err != nil {\n\t\t\t\treturn 0, err\n\t\t\t}\n\t\t}\n\t\tif width > 2048 || height > 2048 { // max(width, height) > 2048\n\t\t\tratio := float64(2048) / math.Max(float64(width), float64(height))\n\t\t\twidth = int(float64(width) * ratio)\n\t\t\theight = int(float64(height) * ratio)\n\t\t}\n\t\tif width > 768 && height > 768 { // min(width, height) > 768\n\t\t\tratio := float64(768) / math.Min(float64(width), float64(height))\n\t\t\twidth = int(float64(width) * ratio)\n\t\t\theight = int(float64(height) * ratio)\n\t\t}\n\t\tnumSquares := int(math.Ceil(float64(width)/512) * math.Ceil(float64(height)/512))\n\t\tif strings.HasPrefix(model, \"gpt-4o-mini\") {\n\t\t\treturn numSquares*gpt4oMiniHighDetailCost + gpt4oMiniAdditionalCost, nil\n\t\t}\n\t\tresult := numSquares*highDetailCostPerTile + additionalCost\n\t\treturn result, nil\n\tdefault:\n\t\treturn 0, errors.New(\"invalid detail option\")\n\t}\n}\n\nfunc CountTokenInput(input any, model string) int {\n\tswitch v := input.(type) {\n\tcase string:\n\t\treturn CountTokenText(v, model)\n\tcase []string:\n\t\ttext := \"\"\n\t\tfor _, s := range v {\n\t\t\ttext += s\n\t\t}\n\t\treturn CountTokenText(text, model)\n\t}\n\treturn 0\n}\n\nfunc CountTokenText(text string, model string) int {\n\ttokenEncoder := getTokenEncoder(model)\n\treturn getTokenNum(tokenEncoder, text)\n}\n\nfunc CountToken(text string) int {\n\treturn CountTokenInput(text, \"gpt-3.5-turbo\")\n}\n"
  },
  {
    "path": "relay/adaptor/openai/util.go",
    "content": "package openai\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ErrorWrapper(err error, code string, statusCode int) *model.ErrorWithStatusCode {\n\tlogger.Error(context.TODO(), fmt.Sprintf(\"[%s]%+v\", code, err))\n\n\tError := model.Error{\n\t\tMessage: err.Error(),\n\t\tType:    \"one_api_error\",\n\t\tCode:    code,\n\t}\n\treturn &model.ErrorWithStatusCode{\n\t\tError:      Error,\n\t\tStatusCode: statusCode,\n\t}\n}\n"
  },
  {
    "path": "relay/adaptor/openrouter/constants.go",
    "content": "package openrouter\n\nvar ModelList = []string{\n\t\"01-ai/yi-large\",\n\t\"aetherwiing/mn-starcannon-12b\",\n\t\"ai21/jamba-1-5-large\",\n\t\"ai21/jamba-1-5-mini\",\n\t\"ai21/jamba-instruct\",\n\t\"aion-labs/aion-1.0\",\n\t\"aion-labs/aion-1.0-mini\",\n\t\"aion-labs/aion-rp-llama-3.1-8b\",\n\t\"allenai/llama-3.1-tulu-3-405b\",\n\t\"alpindale/goliath-120b\",\n\t\"alpindale/magnum-72b\",\n\t\"amazon/nova-lite-v1\",\n\t\"amazon/nova-micro-v1\",\n\t\"amazon/nova-pro-v1\",\n\t\"anthracite-org/magnum-v2-72b\",\n\t\"anthracite-org/magnum-v4-72b\",\n\t\"anthropic/claude-2\",\n\t\"anthropic/claude-2.0\",\n\t\"anthropic/claude-2.0:beta\",\n\t\"anthropic/claude-2.1\",\n\t\"anthropic/claude-2.1:beta\",\n\t\"anthropic/claude-2:beta\",\n\t\"anthropic/claude-3-haiku\",\n\t\"anthropic/claude-3-haiku:beta\",\n\t\"anthropic/claude-3-opus\",\n\t\"anthropic/claude-3-opus:beta\",\n\t\"anthropic/claude-3-sonnet\",\n\t\"anthropic/claude-3-sonnet:beta\",\n\t\"anthropic/claude-3.5-haiku\",\n\t\"anthropic/claude-3.5-haiku-20241022\",\n\t\"anthropic/claude-3.5-haiku-20241022:beta\",\n\t\"anthropic/claude-3.5-haiku:beta\",\n\t\"anthropic/claude-3.5-sonnet\",\n\t\"anthropic/claude-3.5-sonnet-20240620\",\n\t\"anthropic/claude-3.5-sonnet-20240620:beta\",\n\t\"anthropic/claude-3.5-sonnet:beta\",\n\t\"cognitivecomputations/dolphin-mixtral-8x22b\",\n\t\"cognitivecomputations/dolphin-mixtral-8x7b\",\n\t\"cohere/command\",\n\t\"cohere/command-r\",\n\t\"cohere/command-r-03-2024\",\n\t\"cohere/command-r-08-2024\",\n\t\"cohere/command-r-plus\",\n\t\"cohere/command-r-plus-04-2024\",\n\t\"cohere/command-r-plus-08-2024\",\n\t\"cohere/command-r7b-12-2024\",\n\t\"databricks/dbrx-instruct\",\n\t\"deepseek/deepseek-chat\",\n\t\"deepseek/deepseek-chat-v2.5\",\n\t\"deepseek/deepseek-chat:free\",\n\t\"deepseek/deepseek-r1\",\n\t\"deepseek/deepseek-r1-distill-llama-70b\",\n\t\"deepseek/deepseek-r1-distill-llama-70b:free\",\n\t\"deepseek/deepseek-r1-distill-llama-8b\",\n\t\"deepseek/deepseek-r1-distill-qwen-1.5b\",\n\t\"deepseek/deepseek-r1-distill-qwen-14b\",\n\t\"deepseek/deepseek-r1-distill-qwen-32b\",\n\t\"deepseek/deepseek-r1:free\",\n\t\"eva-unit-01/eva-llama-3.33-70b\",\n\t\"eva-unit-01/eva-qwen-2.5-32b\",\n\t\"eva-unit-01/eva-qwen-2.5-72b\",\n\t\"google/gemini-2.0-flash-001\",\n\t\"google/gemini-2.0-flash-exp:free\",\n\t\"google/gemini-2.0-flash-lite-preview-02-05:free\",\n\t\"google/gemini-2.0-flash-thinking-exp-1219:free\",\n\t\"google/gemini-2.0-flash-thinking-exp:free\",\n\t\"google/gemini-2.0-pro-exp-02-05:free\",\n\t\"google/gemini-exp-1206:free\",\n\t\"google/gemini-flash-1.5\",\n\t\"google/gemini-flash-1.5-8b\",\n\t\"google/gemini-flash-1.5-8b-exp\",\n\t\"google/gemini-pro\",\n\t\"google/gemini-pro-1.5\",\n\t\"google/gemini-pro-vision\",\n\t\"google/gemma-2-27b-it\",\n\t\"google/gemma-2-9b-it\",\n\t\"google/gemma-2-9b-it:free\",\n\t\"google/gemma-7b-it\",\n\t\"google/learnlm-1.5-pro-experimental:free\",\n\t\"google/palm-2-chat-bison\",\n\t\"google/palm-2-chat-bison-32k\",\n\t\"google/palm-2-codechat-bison\",\n\t\"google/palm-2-codechat-bison-32k\",\n\t\"gryphe/mythomax-l2-13b\",\n\t\"gryphe/mythomax-l2-13b:free\",\n\t\"huggingfaceh4/zephyr-7b-beta:free\",\n\t\"infermatic/mn-inferor-12b\",\n\t\"inflection/inflection-3-pi\",\n\t\"inflection/inflection-3-productivity\",\n\t\"jondurbin/airoboros-l2-70b\",\n\t\"liquid/lfm-3b\",\n\t\"liquid/lfm-40b\",\n\t\"liquid/lfm-7b\",\n\t\"mancer/weaver\",\n\t\"meta-llama/llama-2-13b-chat\",\n\t\"meta-llama/llama-2-70b-chat\",\n\t\"meta-llama/llama-3-70b-instruct\",\n\t\"meta-llama/llama-3-8b-instruct\",\n\t\"meta-llama/llama-3-8b-instruct:free\",\n\t\"meta-llama/llama-3.1-405b\",\n\t\"meta-llama/llama-3.1-405b-instruct\",\n\t\"meta-llama/llama-3.1-70b-instruct\",\n\t\"meta-llama/llama-3.1-8b-instruct\",\n\t\"meta-llama/llama-3.2-11b-vision-instruct\",\n\t\"meta-llama/llama-3.2-11b-vision-instruct:free\",\n\t\"meta-llama/llama-3.2-1b-instruct\",\n\t\"meta-llama/llama-3.2-3b-instruct\",\n\t\"meta-llama/llama-3.2-90b-vision-instruct\",\n\t\"meta-llama/llama-3.3-70b-instruct\",\n\t\"meta-llama/llama-3.3-70b-instruct:free\",\n\t\"meta-llama/llama-guard-2-8b\",\n\t\"microsoft/phi-3-medium-128k-instruct\",\n\t\"microsoft/phi-3-medium-128k-instruct:free\",\n\t\"microsoft/phi-3-mini-128k-instruct\",\n\t\"microsoft/phi-3-mini-128k-instruct:free\",\n\t\"microsoft/phi-3.5-mini-128k-instruct\",\n\t\"microsoft/phi-4\",\n\t\"microsoft/wizardlm-2-7b\",\n\t\"microsoft/wizardlm-2-8x22b\",\n\t\"minimax/minimax-01\",\n\t\"mistralai/codestral-2501\",\n\t\"mistralai/codestral-mamba\",\n\t\"mistralai/ministral-3b\",\n\t\"mistralai/ministral-8b\",\n\t\"mistralai/mistral-7b-instruct\",\n\t\"mistralai/mistral-7b-instruct-v0.1\",\n\t\"mistralai/mistral-7b-instruct-v0.3\",\n\t\"mistralai/mistral-7b-instruct:free\",\n\t\"mistralai/mistral-large\",\n\t\"mistralai/mistral-large-2407\",\n\t\"mistralai/mistral-large-2411\",\n\t\"mistralai/mistral-medium\",\n\t\"mistralai/mistral-nemo\",\n\t\"mistralai/mistral-nemo:free\",\n\t\"mistralai/mistral-small\",\n\t\"mistralai/mistral-small-24b-instruct-2501\",\n\t\"mistralai/mistral-small-24b-instruct-2501:free\",\n\t\"mistralai/mistral-tiny\",\n\t\"mistralai/mixtral-8x22b-instruct\",\n\t\"mistralai/mixtral-8x7b\",\n\t\"mistralai/mixtral-8x7b-instruct\",\n\t\"mistralai/pixtral-12b\",\n\t\"mistralai/pixtral-large-2411\",\n\t\"neversleep/llama-3-lumimaid-70b\",\n\t\"neversleep/llama-3-lumimaid-8b\",\n\t\"neversleep/llama-3-lumimaid-8b:extended\",\n\t\"neversleep/llama-3.1-lumimaid-70b\",\n\t\"neversleep/llama-3.1-lumimaid-8b\",\n\t\"neversleep/noromaid-20b\",\n\t\"nothingiisreal/mn-celeste-12b\",\n\t\"nousresearch/hermes-2-pro-llama-3-8b\",\n\t\"nousresearch/hermes-3-llama-3.1-405b\",\n\t\"nousresearch/hermes-3-llama-3.1-70b\",\n\t\"nousresearch/nous-hermes-2-mixtral-8x7b-dpo\",\n\t\"nousresearch/nous-hermes-llama2-13b\",\n\t\"nvidia/llama-3.1-nemotron-70b-instruct\",\n\t\"nvidia/llama-3.1-nemotron-70b-instruct:free\",\n\t\"openai/chatgpt-4o-latest\",\n\t\"openai/gpt-3.5-turbo\",\n\t\"openai/gpt-3.5-turbo-0125\",\n\t\"openai/gpt-3.5-turbo-0613\",\n\t\"openai/gpt-3.5-turbo-1106\",\n\t\"openai/gpt-3.5-turbo-16k\",\n\t\"openai/gpt-3.5-turbo-instruct\",\n\t\"openai/gpt-4\",\n\t\"openai/gpt-4-0314\",\n\t\"openai/gpt-4-1106-preview\",\n\t\"openai/gpt-4-32k\",\n\t\"openai/gpt-4-32k-0314\",\n\t\"openai/gpt-4-turbo\",\n\t\"openai/gpt-4-turbo-preview\",\n\t\"openai/gpt-4o\",\n\t\"openai/gpt-4o-2024-05-13\",\n\t\"openai/gpt-4o-2024-08-06\",\n\t\"openai/gpt-4o-2024-11-20\",\n\t\"openai/gpt-4o-mini\",\n\t\"openai/gpt-4o-mini-2024-07-18\",\n\t\"openai/gpt-4o:extended\",\n\t\"openai/o1\",\n\t\"openai/o1-mini\",\n\t\"openai/o1-mini-2024-09-12\",\n\t\"openai/o1-preview\",\n\t\"openai/o1-preview-2024-09-12\",\n\t\"openai/o3-mini\",\n\t\"openai/o3-mini-high\",\n\t\"openchat/openchat-7b\",\n\t\"openchat/openchat-7b:free\",\n\t\"openrouter/auto\",\n\t\"perplexity/llama-3.1-sonar-huge-128k-online\",\n\t\"perplexity/llama-3.1-sonar-large-128k-chat\",\n\t\"perplexity/llama-3.1-sonar-large-128k-online\",\n\t\"perplexity/llama-3.1-sonar-small-128k-chat\",\n\t\"perplexity/llama-3.1-sonar-small-128k-online\",\n\t\"perplexity/sonar\",\n\t\"perplexity/sonar-reasoning\",\n\t\"pygmalionai/mythalion-13b\",\n\t\"qwen/qvq-72b-preview\",\n\t\"qwen/qwen-2-72b-instruct\",\n\t\"qwen/qwen-2-7b-instruct\",\n\t\"qwen/qwen-2-7b-instruct:free\",\n\t\"qwen/qwen-2-vl-72b-instruct\",\n\t\"qwen/qwen-2-vl-7b-instruct\",\n\t\"qwen/qwen-2.5-72b-instruct\",\n\t\"qwen/qwen-2.5-7b-instruct\",\n\t\"qwen/qwen-2.5-coder-32b-instruct\",\n\t\"qwen/qwen-max\",\n\t\"qwen/qwen-plus\",\n\t\"qwen/qwen-turbo\",\n\t\"qwen/qwen-vl-plus:free\",\n\t\"qwen/qwen2.5-vl-72b-instruct:free\",\n\t\"qwen/qwq-32b-preview\",\n\t\"raifle/sorcererlm-8x22b\",\n\t\"sao10k/fimbulvetr-11b-v2\",\n\t\"sao10k/l3-euryale-70b\",\n\t\"sao10k/l3-lunaris-8b\",\n\t\"sao10k/l3.1-70b-hanami-x1\",\n\t\"sao10k/l3.1-euryale-70b\",\n\t\"sao10k/l3.3-euryale-70b\",\n\t\"sophosympatheia/midnight-rose-70b\",\n\t\"sophosympatheia/rogue-rose-103b-v0.2:free\",\n\t\"teknium/openhermes-2.5-mistral-7b\",\n\t\"thedrummer/rocinante-12b\",\n\t\"thedrummer/unslopnemo-12b\",\n\t\"undi95/remm-slerp-l2-13b\",\n\t\"undi95/toppy-m-7b\",\n\t\"undi95/toppy-m-7b:free\",\n\t\"x-ai/grok-2-1212\",\n\t\"x-ai/grok-2-vision-1212\",\n\t\"x-ai/grok-beta\",\n\t\"x-ai/grok-vision-beta\",\n\t\"xwin-lm/xwin-lm-70b\",\n}\n"
  },
  {
    "path": "relay/adaptor/palm/adaptor.go",
    "content": "package palm\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n)\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn fmt.Sprintf(\"%s/v1beta2/models/chat-bison-001:generateMessage\", meta.BaseURL), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"x-goog-api-key\", meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn ConvertRequest(*request), nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\tvar responseText string\n\t\terr, responseText = StreamHandler(c, resp)\n\t\tusage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)\n\t} else {\n\t\terr, usage = Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"google palm\"\n}\n"
  },
  {
    "path": "relay/adaptor/palm/constants.go",
    "content": "package palm\n\nvar ModelList = []string{\n\t\"PaLM-2\",\n}\n"
  },
  {
    "path": "relay/adaptor/palm/model.go",
    "content": "package palm\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype ChatMessage struct {\n\tAuthor  string `json:\"author\"`\n\tContent string `json:\"content\"`\n}\n\ntype Filter struct {\n\tReason  string `json:\"reason\"`\n\tMessage string `json:\"message\"`\n}\n\ntype Prompt struct {\n\tMessages []ChatMessage `json:\"messages\"`\n}\n\ntype ChatRequest struct {\n\tPrompt         Prompt   `json:\"prompt\"`\n\tTemperature    *float64 `json:\"temperature,omitempty\"`\n\tCandidateCount int      `json:\"candidateCount,omitempty\"`\n\tTopP           *float64 `json:\"topP,omitempty\"`\n\tTopK           int      `json:\"topK,omitempty\"`\n}\n\ntype Error struct {\n\tCode    int    `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tStatus  string `json:\"status\"`\n}\n\ntype ChatResponse struct {\n\tCandidates []ChatMessage   `json:\"candidates\"`\n\tMessages   []model.Message `json:\"messages\"`\n\tFilters    []Filter        `json:\"filters\"`\n\tError      Error           `json:\"error\"`\n}\n"
  },
  {
    "path": "relay/adaptor/palm/palm.go",
    "content": "package palm\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body\n// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body\n\nfunc ConvertRequest(textRequest model.GeneralOpenAIRequest) *ChatRequest {\n\tpalmRequest := ChatRequest{\n\t\tPrompt: Prompt{\n\t\t\tMessages: make([]ChatMessage, 0, len(textRequest.Messages)),\n\t\t},\n\t\tTemperature:    textRequest.Temperature,\n\t\tCandidateCount: textRequest.N,\n\t\tTopP:           textRequest.TopP,\n\t\tTopK:           textRequest.MaxTokens,\n\t}\n\tfor _, message := range textRequest.Messages {\n\t\tpalmMessage := ChatMessage{\n\t\t\tContent: message.StringContent(),\n\t\t}\n\t\tif message.Role == \"user\" {\n\t\t\tpalmMessage.Author = \"0\"\n\t\t} else {\n\t\t\tpalmMessage.Author = \"1\"\n\t\t}\n\t\tpalmRequest.Prompt.Messages = append(palmRequest.Prompt.Messages, palmMessage)\n\t}\n\treturn &palmRequest\n}\n\nfunc responsePaLM2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tfullTextResponse := openai.TextResponse{\n\t\tChoices: make([]openai.TextResponseChoice, 0, len(response.Candidates)),\n\t}\n\tfor i, candidate := range response.Candidates {\n\t\tchoice := openai.TextResponseChoice{\n\t\t\tIndex: i,\n\t\t\tMessage: model.Message{\n\t\t\t\tRole:    \"assistant\",\n\t\t\t\tContent: candidate.Content,\n\t\t\t},\n\t\t\tFinishReason: \"stop\",\n\t\t}\n\t\tfullTextResponse.Choices = append(fullTextResponse.Choices, choice)\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponsePaLM2OpenAI(palmResponse *ChatResponse) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tif len(palmResponse.Candidates) > 0 {\n\t\tchoice.Delta.Content = palmResponse.Candidates[0].Content\n\t}\n\tchoice.FinishReason = &constant.StopFinishReason\n\tvar response openai.ChatCompletionsStreamResponse\n\tresponse.Object = \"chat.completion.chunk\"\n\tresponse.Model = \"palm2\"\n\tresponse.Choices = []openai.ChatCompletionsStreamResponseChoice{choice}\n\treturn &response\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, string) {\n\tresponseText := \"\"\n\tresponseId := fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID())\n\tcreatedTime := helper.GetTimestamp()\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tlogger.SysError(\"error reading stream response: \" + err.Error())\n\t\terr := resp.Body.Close()\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), \"\"\n\t\t}\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), \"\"\n\t}\n\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), \"\"\n\t}\n\n\tvar palmResponse ChatResponse\n\terr = json.Unmarshal(responseBody, &palmResponse)\n\tif err != nil {\n\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), \"\"\n\t}\n\n\tfullTextResponse := streamResponsePaLM2OpenAI(&palmResponse)\n\tfullTextResponse.Id = responseId\n\tfullTextResponse.Created = createdTime\n\tif len(palmResponse.Candidates) > 0 {\n\t\tresponseText = palmResponse.Candidates[0].Content\n\t}\n\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\tlogger.SysError(\"error marshalling stream response: \" + err.Error())\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), \"\"\n\t}\n\n\terr = render.ObjectData(c, string(jsonResponse))\n\tif err != nil {\n\t\tlogger.SysError(err.Error())\n\t}\n\n\trender.Done(c)\n\n\treturn nil, responseText\n}\n\nfunc Handler(c *gin.Context, resp *http.Response, promptTokens int, modelName string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar palmResponse ChatResponse\n\terr = json.Unmarshal(responseBody, &palmResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: palmResponse.Error.Message,\n\t\t\t\tType:    palmResponse.Error.Status,\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    palmResponse.Error.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responsePaLM2OpenAI(&palmResponse)\n\tfullTextResponse.Model = modelName\n\tcompletionTokens := openai.CountTokenText(palmResponse.Candidates[0].Content, modelName)\n\tusage := model.Usage{\n\t\tPromptTokens:     promptTokens,\n\t\tCompletionTokens: completionTokens,\n\t\tTotalTokens:      promptTokens + completionTokens,\n\t}\n\tfullTextResponse.Usage = usage\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &usage\n}\n"
  },
  {
    "path": "relay/adaptor/proxy/adaptor.go",
    "content": "package proxy\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\tchannelhelper \"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar _ adaptor.Adaptor = new(Adaptor)\n\nconst channelName = \"proxy\"\n\ntype Adaptor struct{}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\treturn nil, errors.New(\"notimplement\")\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tfor k, v := range resp.Header {\n\t\tfor _, vv := range v {\n\t\t\tc.Writer.Header().Set(k, vv)\n\t\t}\n\t}\n\n\tc.Writer.WriteHeader(resp.StatusCode)\n\tif _, gerr := io.Copy(c.Writer, resp.Body); gerr != nil {\n\t\treturn nil, &relaymodel.ErrorWithStatusCode{\n\t\t\tStatusCode: http.StatusInternalServerError,\n\t\t\tError: relaymodel.Error{\n\t\t\t\tMessage: gerr.Error(),\n\t\t\t},\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (a *Adaptor) GetModelList() (models []string) {\n\treturn nil\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn channelName\n}\n\n// GetRequestURL remove static prefix, and return the real request url to the upstream service\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tprefix := fmt.Sprintf(\"/v1/oneapi/proxy/%d\", meta.ChannelId)\n\treturn meta.BaseURL + strings.TrimPrefix(meta.RequestURLPath, prefix), nil\n\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tfor k, v := range c.Request.Header {\n\t\treq.Header.Set(k, v[0])\n\t}\n\n\t// remove unnecessary headers\n\treq.Header.Del(\"Host\")\n\treq.Header.Del(\"Content-Length\")\n\treq.Header.Del(\"Accept-Encoding\")\n\treq.Header.Del(\"Connection\")\n\n\t// set authorization header\n\treq.Header.Set(\"Authorization\", meta.APIKey)\n\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\treturn nil, errors.Errorf(\"not implement\")\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn channelhelper.DoRequestHelper(a, c, meta, requestBody)\n}\n"
  },
  {
    "path": "relay/adaptor/replicate/adaptor.go",
    "content": "package replicate\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\ntype Adaptor struct {\n\tmeta *meta.Meta\n}\n\n// ConvertImageRequest implements adaptor.Adaptor.\nfunc (*Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\treturn DrawImageRequest{\n\t\tInput: ImageInput{\n\t\t\tSteps:           25,\n\t\t\tPrompt:          request.Prompt,\n\t\t\tGuidance:        3,\n\t\t\tSeed:            int(time.Now().UnixNano()),\n\t\t\tSafetyTolerance: 5,\n\t\t\tNImages:         1, // replicate will always return 1 image\n\t\t\tWidth:           1440,\n\t\t\tHeight:          1440,\n\t\t\tAspectRatio:     \"1:1\",\n\t\t},\n\t}, nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif !request.Stream {\n\t\t// TODO: support non-stream mode\n\t\treturn nil, errors.Errorf(\"replicate models only support stream mode now, please set stream=true\")\n\t}\n\n\t// Build the prompt from OpenAI messages\n\tvar promptBuilder strings.Builder\n\tfor _, message := range request.Messages {\n\t\tswitch msgCnt := message.Content.(type) {\n\t\tcase string:\n\t\t\tpromptBuilder.WriteString(message.Role)\n\t\t\tpromptBuilder.WriteString(\": \")\n\t\t\tpromptBuilder.WriteString(msgCnt)\n\t\t\tpromptBuilder.WriteString(\"\\n\")\n\t\tdefault:\n\t\t}\n\t}\n\n\treplicateRequest := ReplicateChatRequest{\n\t\tInput: ChatInput{\n\t\t\tPrompt:           promptBuilder.String(),\n\t\t\tMaxTokens:        request.MaxTokens,\n\t\t\tTemperature:      1.0,\n\t\t\tTopP:             1.0,\n\t\t\tPresencePenalty:  0.0,\n\t\t\tFrequencyPenalty: 0.0,\n\t\t},\n\t}\n\n\t// Map optional fields\n\tif request.Temperature != nil {\n\t\treplicateRequest.Input.Temperature = *request.Temperature\n\t}\n\tif request.TopP != nil {\n\t\treplicateRequest.Input.TopP = *request.TopP\n\t}\n\tif request.PresencePenalty != nil {\n\t\treplicateRequest.Input.PresencePenalty = *request.PresencePenalty\n\t}\n\tif request.FrequencyPenalty != nil {\n\t\treplicateRequest.Input.FrequencyPenalty = *request.FrequencyPenalty\n\t}\n\tif request.MaxTokens > 0 {\n\t\treplicateRequest.Input.MaxTokens = request.MaxTokens\n\t} else if request.MaxTokens == 0 {\n\t\treplicateRequest.Input.MaxTokens = 500\n\t}\n\n\treturn replicateRequest, nil\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tif !slices.Contains(ModelList, meta.OriginModelName) {\n\t\treturn \"\", errors.Errorf(\"model %s not supported\", meta.OriginModelName)\n\t}\n\n\treturn fmt.Sprintf(\"https://api.replicate.com/v1/models/%s/predictions\", meta.OriginModelName), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", \"Bearer \"+meta.APIKey)\n\treturn nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\tlogger.Info(c, \"send request to replicate\")\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tswitch meta.Mode {\n\tcase relaymode.ImagesGenerations:\n\t\terr, usage = ImageHandler(c, resp)\n\tcase relaymode.ChatCompletions:\n\t\terr, usage = ChatHandler(c, resp)\n\tdefault:\n\t\terr = openai.ErrorWrapper(errors.New(\"not implemented\"), \"not_implemented\", http.StatusInternalServerError)\n\t}\n\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"replicate\"\n}\n"
  },
  {
    "path": "relay/adaptor/replicate/chat.go",
    "content": "package replicate\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ChatHandler(c *gin.Context, resp *http.Response) (\n\tsrvErr *model.ErrorWithStatusCode, usage *model.Usage) {\n\tif resp.StatusCode != http.StatusCreated {\n\t\tpayload, _ := io.ReadAll(resp.Body)\n\t\treturn openai.ErrorWrapper(\n\t\t\t\terrors.Errorf(\"bad_status_code [%d]%s\", resp.StatusCode, string(payload)),\n\t\t\t\t\"bad_status_code\", http.StatusInternalServerError),\n\t\t\tnil\n\t}\n\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\trespData := new(ChatResponse)\n\tif err = json.Unmarshal(respBody, respData); err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tfor {\n\t\terr = func() error {\n\t\t\t// get task\n\t\t\ttaskReq, err := http.NewRequestWithContext(c.Request.Context(),\n\t\t\t\thttp.MethodGet, respData.URLs.Get, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"new request\")\n\t\t\t}\n\n\t\t\ttaskReq.Header.Set(\"Authorization\", \"Bearer \"+meta.GetByContext(c).APIKey)\n\t\t\ttaskResp, err := http.DefaultClient.Do(taskReq)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"get task\")\n\t\t\t}\n\t\t\tdefer taskResp.Body.Close()\n\n\t\t\tif taskResp.StatusCode != http.StatusOK {\n\t\t\t\tpayload, _ := io.ReadAll(taskResp.Body)\n\t\t\t\treturn errors.Errorf(\"bad status code [%d]%s\",\n\t\t\t\t\ttaskResp.StatusCode, string(payload))\n\t\t\t}\n\n\t\t\ttaskBody, err := io.ReadAll(taskResp.Body)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"read task response\")\n\t\t\t}\n\n\t\t\ttaskData := new(ChatResponse)\n\t\t\tif err = json.Unmarshal(taskBody, taskData); err != nil {\n\t\t\t\treturn errors.Wrap(err, \"decode task response\")\n\t\t\t}\n\n\t\t\tswitch taskData.Status {\n\t\t\tcase \"succeeded\":\n\t\t\tcase \"failed\", \"canceled\":\n\t\t\t\treturn errors.Errorf(\"task failed, [%s]%s\", taskData.Status, taskData.Error)\n\t\t\tdefault:\n\t\t\t\ttime.Sleep(time.Second * 3)\n\t\t\t\treturn errNextLoop\n\t\t\t}\n\n\t\t\tif taskData.URLs.Stream == \"\" {\n\t\t\t\treturn errors.New(\"stream url is empty\")\n\t\t\t}\n\n\t\t\t// request stream url\n\t\t\tresponseText, err := chatStreamHandler(c, taskData.URLs.Stream)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"chat stream handler\")\n\t\t\t}\n\n\t\t\tctxMeta := meta.GetByContext(c)\n\t\t\tusage = openai.ResponseText2Usage(responseText,\n\t\t\t\tctxMeta.ActualModelName, ctxMeta.PromptTokens)\n\t\t\treturn nil\n\t\t}()\n\t\tif err != nil {\n\t\t\tif errors.Is(err, errNextLoop) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn openai.ErrorWrapper(err, \"chat_task_failed\", http.StatusInternalServerError), nil\n\t\t}\n\n\t\tbreak\n\t}\n\n\treturn nil, usage\n}\n\nconst (\n\teventPrefix = \"event: \"\n\tdataPrefix  = \"data: \"\n\tdone        = \"[DONE]\"\n)\n\nfunc chatStreamHandler(c *gin.Context, streamUrl string) (responseText string, err error) {\n\t// request stream endpoint\n\tstreamReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, streamUrl, nil)\n\tif err != nil {\n\t\treturn \"\", errors.Wrap(err, \"new request to stream\")\n\t}\n\n\tstreamReq.Header.Set(\"Authorization\", \"Bearer \"+meta.GetByContext(c).APIKey)\n\tstreamReq.Header.Set(\"Accept\", \"text/event-stream\")\n\tstreamReq.Header.Set(\"Cache-Control\", \"no-store\")\n\n\tresp, err := http.DefaultClient.Do(streamReq)\n\tif err != nil {\n\t\treturn \"\", errors.Wrap(err, \"do request to stream\")\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tpayload, _ := io.ReadAll(resp.Body)\n\t\treturn \"\", errors.Errorf(\"bad status code [%d]%s\", resp.StatusCode, string(payload))\n\t}\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\tdoneRendered := false\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Handle comments starting with ':'\n\t\tif strings.HasPrefix(line, \":\") {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse SSE fields\n\t\tif strings.HasPrefix(line, eventPrefix) {\n\t\t\tevent := strings.TrimSpace(line[len(eventPrefix):])\n\t\t\tvar data string\n\t\t\t// Read the following lines to get data and id\n\t\t\tfor scanner.Scan() {\n\t\t\t\tnextLine := scanner.Text()\n\t\t\t\tif nextLine == \"\" {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif strings.HasPrefix(nextLine, dataPrefix) {\n\t\t\t\t\tdata = nextLine[len(dataPrefix):]\n\t\t\t\t} else if strings.HasPrefix(nextLine, \"id:\") {\n\t\t\t\t\t// id = strings.TrimSpace(nextLine[len(\"id:\"):])\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif event == \"output\" {\n\t\t\t\trender.StringData(c, data)\n\t\t\t\tresponseText += data\n\t\t\t} else if event == \"done\" {\n\t\t\t\trender.Done(c)\n\t\t\t\tdoneRendered = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn \"\", errors.Wrap(err, \"scan stream\")\n\t}\n\n\tif !doneRendered {\n\t\trender.Done(c)\n\t}\n\n\treturn responseText, nil\n}\n"
  },
  {
    "path": "relay/adaptor/replicate/constant.go",
    "content": "package replicate\n\n// ModelList is a list of models that can be used with Replicate.\n//\n// https://replicate.com/pricing\nvar ModelList = []string{\n\t// -------------------------------------\n\t// image model\n\t// -------------------------------------\n\t\"black-forest-labs/flux-1.1-pro\",\n\t\"black-forest-labs/flux-1.1-pro-ultra\",\n\t\"black-forest-labs/flux-canny-dev\",\n\t\"black-forest-labs/flux-canny-pro\",\n\t\"black-forest-labs/flux-depth-dev\",\n\t\"black-forest-labs/flux-depth-pro\",\n\t\"black-forest-labs/flux-dev\",\n\t\"black-forest-labs/flux-dev-lora\",\n\t\"black-forest-labs/flux-fill-dev\",\n\t\"black-forest-labs/flux-fill-pro\",\n\t\"black-forest-labs/flux-pro\",\n\t\"black-forest-labs/flux-redux-dev\",\n\t\"black-forest-labs/flux-redux-schnell\",\n\t\"black-forest-labs/flux-schnell\",\n\t\"black-forest-labs/flux-schnell-lora\",\n\t\"ideogram-ai/ideogram-v2\",\n\t\"ideogram-ai/ideogram-v2-turbo\",\n\t\"recraft-ai/recraft-v3\",\n\t\"recraft-ai/recraft-v3-svg\",\n\t\"stability-ai/stable-diffusion-3\",\n\t\"stability-ai/stable-diffusion-3.5-large\",\n\t\"stability-ai/stable-diffusion-3.5-large-turbo\",\n\t\"stability-ai/stable-diffusion-3.5-medium\",\n\t// -------------------------------------\n\t// language model\n\t// -------------------------------------\n\t\"ibm-granite/granite-20b-code-instruct-8k\",\n\t\"ibm-granite/granite-3.0-2b-instruct\",\n\t\"ibm-granite/granite-3.0-8b-instruct\",\n\t\"ibm-granite/granite-8b-code-instruct-128k\",\n\t\"meta/llama-2-13b\",\n\t\"meta/llama-2-13b-chat\",\n\t\"meta/llama-2-70b\",\n\t\"meta/llama-2-70b-chat\",\n\t\"meta/llama-2-7b\",\n\t\"meta/llama-2-7b-chat\",\n\t\"meta/meta-llama-3.1-405b-instruct\",\n\t\"meta/meta-llama-3-70b\",\n\t\"meta/meta-llama-3-70b-instruct\",\n\t\"meta/meta-llama-3-8b\",\n\t\"meta/meta-llama-3-8b-instruct\",\n\t\"mistralai/mistral-7b-instruct-v0.2\",\n\t\"mistralai/mistral-7b-v0.1\",\n\t\"mistralai/mixtral-8x7b-instruct-v0.1\",\n\t// -------------------------------------\n\t// video model\n\t// -------------------------------------\n\t// \"minimax/video-01\",  // TODO: implement the adaptor\n}\n"
  },
  {
    "path": "relay/adaptor/replicate/image.go",
    "content": "package replicate\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image\"\n\t\"image/png\"\n\t\"io\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"golang.org/x/image/webp\"\n\t\"golang.org/x/sync/errgroup\"\n)\n\n// ImagesEditsHandler just copy response body to client\n//\n// https://replicate.com/black-forest-labs/flux-fill-pro\n// func ImagesEditsHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n// \tc.Writer.WriteHeader(resp.StatusCode)\n// \tfor k, v := range resp.Header {\n// \t\tc.Writer.Header().Set(k, v[0])\n// \t}\n\n// \tif _, err := io.Copy(c.Writer, resp.Body); err != nil {\n// \t\treturn ErrorWrapper(err, \"copy_response_body_failed\", http.StatusInternalServerError), nil\n// \t}\n// \tdefer resp.Body.Close()\n\n// \treturn nil, nil\n// }\n\nvar errNextLoop = errors.New(\"next_loop\")\n\nfunc ImageHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tif resp.StatusCode != http.StatusCreated {\n\t\tpayload, _ := io.ReadAll(resp.Body)\n\t\treturn openai.ErrorWrapper(\n\t\t\t\terrors.Errorf(\"bad_status_code [%d]%s\", resp.StatusCode, string(payload)),\n\t\t\t\t\"bad_status_code\", http.StatusInternalServerError),\n\t\t\tnil\n\t}\n\n\trespBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\trespData := new(ImageResponse)\n\tif err = json.Unmarshal(respBody, respData); err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\tfor {\n\t\terr = func() error {\n\t\t\t// get task\n\t\t\ttaskReq, err := http.NewRequestWithContext(c.Request.Context(),\n\t\t\t\thttp.MethodGet, respData.URLs.Get, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"new request\")\n\t\t\t}\n\n\t\t\ttaskReq.Header.Set(\"Authorization\", \"Bearer \"+meta.GetByContext(c).APIKey)\n\t\t\ttaskResp, err := http.DefaultClient.Do(taskReq)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"get task\")\n\t\t\t}\n\t\t\tdefer taskResp.Body.Close()\n\n\t\t\tif taskResp.StatusCode != http.StatusOK {\n\t\t\t\tpayload, _ := io.ReadAll(taskResp.Body)\n\t\t\t\treturn errors.Errorf(\"bad status code [%d]%s\",\n\t\t\t\t\ttaskResp.StatusCode, string(payload))\n\t\t\t}\n\n\t\t\ttaskBody, err := io.ReadAll(taskResp.Body)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"read task response\")\n\t\t\t}\n\n\t\t\ttaskData := new(ImageResponse)\n\t\t\tif err = json.Unmarshal(taskBody, taskData); err != nil {\n\t\t\t\treturn errors.Wrap(err, \"decode task response\")\n\t\t\t}\n\n\t\t\tswitch taskData.Status {\n\t\t\tcase \"succeeded\":\n\t\t\tcase \"failed\", \"canceled\":\n\t\t\t\treturn errors.Errorf(\"task failed: %s\", taskData.Status)\n\t\t\tdefault:\n\t\t\t\ttime.Sleep(time.Second * 3)\n\t\t\t\treturn errNextLoop\n\t\t\t}\n\n\t\t\toutput, err := taskData.GetOutput()\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(err, \"get output\")\n\t\t\t}\n\t\t\tif len(output) == 0 {\n\t\t\t\treturn errors.New(\"response output is empty\")\n\t\t\t}\n\n\t\t\tvar mu sync.Mutex\n\t\t\tvar pool errgroup.Group\n\t\t\trespBody := &openai.ImageResponse{\n\t\t\t\tCreated: taskData.CompletedAt.Unix(),\n\t\t\t\tData:    []openai.ImageData{},\n\t\t\t}\n\n\t\t\tfor _, imgOut := range output {\n\t\t\t\timgOut := imgOut\n\t\t\t\tpool.Go(func() error {\n\t\t\t\t\t// download image\n\t\t\t\t\tdownloadReq, err := http.NewRequestWithContext(c.Request.Context(),\n\t\t\t\t\t\thttp.MethodGet, imgOut, nil)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn errors.Wrap(err, \"new request\")\n\t\t\t\t\t}\n\n\t\t\t\t\timgResp, err := http.DefaultClient.Do(downloadReq)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn errors.Wrap(err, \"download image\")\n\t\t\t\t\t}\n\t\t\t\t\tdefer imgResp.Body.Close()\n\n\t\t\t\t\tif imgResp.StatusCode != http.StatusOK {\n\t\t\t\t\t\tpayload, _ := io.ReadAll(imgResp.Body)\n\t\t\t\t\t\treturn errors.Errorf(\"bad status code [%d]%s\",\n\t\t\t\t\t\t\timgResp.StatusCode, string(payload))\n\t\t\t\t\t}\n\n\t\t\t\t\timgData, err := io.ReadAll(imgResp.Body)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn errors.Wrap(err, \"read image\")\n\t\t\t\t\t}\n\n\t\t\t\t\timgData, err = ConvertImageToPNG(imgData)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn errors.Wrap(err, \"convert image\")\n\t\t\t\t\t}\n\n\t\t\t\t\tmu.Lock()\n\t\t\t\t\trespBody.Data = append(respBody.Data, openai.ImageData{\n\t\t\t\t\t\tB64Json: fmt.Sprintf(\"data:image/png;base64,%s\",\n\t\t\t\t\t\t\tbase64.StdEncoding.EncodeToString(imgData)),\n\t\t\t\t\t})\n\t\t\t\t\tmu.Unlock()\n\n\t\t\t\t\treturn nil\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif err := pool.Wait(); err != nil {\n\t\t\t\tif len(respBody.Data) == 0 {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\n\t\t\t\tlogger.Error(c, fmt.Sprintf(\"some images failed to download: %+v\", err))\n\t\t\t}\n\n\t\t\tc.JSON(http.StatusOK, respBody)\n\t\t\treturn nil\n\t\t}()\n\t\tif err != nil {\n\t\t\tif errors.Is(err, errNextLoop) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn openai.ErrorWrapper(err, \"image_task_failed\", http.StatusInternalServerError), nil\n\t\t}\n\n\t\tbreak\n\t}\n\n\treturn nil, nil\n}\n\n// ConvertImageToPNG converts a WebP image to PNG format\nfunc ConvertImageToPNG(webpData []byte) ([]byte, error) {\n\t// bypass if it's already a PNG image\n\tif bytes.HasPrefix(webpData, []byte(\"\\x89PNG\")) {\n\t\treturn webpData, nil\n\t}\n\n\t// check if is jpeg, convert to png\n\tif bytes.HasPrefix(webpData, []byte(\"\\xff\\xd8\\xff\")) {\n\t\timg, _, err := image.Decode(bytes.NewReader(webpData))\n\t\tif err != nil {\n\t\t\treturn nil, errors.Wrap(err, \"decode jpeg\")\n\t\t}\n\n\t\tvar pngBuffer bytes.Buffer\n\t\tif err := png.Encode(&pngBuffer, img); err != nil {\n\t\t\treturn nil, errors.Wrap(err, \"encode png\")\n\t\t}\n\n\t\treturn pngBuffer.Bytes(), nil\n\t}\n\n\t// Decode the WebP image\n\timg, err := webp.Decode(bytes.NewReader(webpData))\n\tif err != nil {\n\t\treturn nil, errors.Wrap(err, \"decode webp\")\n\t}\n\n\t// Encode the image as PNG\n\tvar pngBuffer bytes.Buffer\n\tif err := png.Encode(&pngBuffer, img); err != nil {\n\t\treturn nil, errors.Wrap(err, \"encode png\")\n\t}\n\n\treturn pngBuffer.Bytes(), nil\n}\n"
  },
  {
    "path": "relay/adaptor/replicate/model.go",
    "content": "package replicate\n\nimport (\n\t\"time\"\n\n\t\"github.com/pkg/errors\"\n)\n\n// DrawImageRequest draw image by fluxpro\n//\n// https://replicate.com/black-forest-labs/flux-pro?prediction=kg1krwsdf9rg80ch1sgsrgq7h8&output=json\ntype DrawImageRequest struct {\n\tInput ImageInput `json:\"input\"`\n}\n\n// ImageInput is input of DrawImageByFluxProRequest\n//\n// https://replicate.com/black-forest-labs/flux-1.1-pro/api/schema\ntype ImageInput struct {\n\tSteps           int    `json:\"steps\" binding:\"required,min=1\"`\n\tPrompt          string `json:\"prompt\" binding:\"required,min=5\"`\n\tImagePrompt     string `json:\"image_prompt\"`\n\tGuidance        int    `json:\"guidance\" binding:\"required,min=2,max=5\"`\n\tInterval        int    `json:\"interval\" binding:\"required,min=1,max=4\"`\n\tAspectRatio     string `json:\"aspect_ratio\" binding:\"required,oneof=1:1 16:9 2:3 3:2 4:5 5:4 9:16\"`\n\tSafetyTolerance int    `json:\"safety_tolerance\" binding:\"required,min=1,max=5\"`\n\tSeed            int    `json:\"seed\"`\n\tNImages         int    `json:\"n_images\" binding:\"required,min=1,max=8\"`\n\tWidth           int    `json:\"width\" binding:\"required,min=256,max=1440\"`\n\tHeight          int    `json:\"height\" binding:\"required,min=256,max=1440\"`\n}\n\n// InpaintingImageByFlusReplicateRequest is request to inpainting image by flux pro\n//\n// https://replicate.com/black-forest-labs/flux-fill-pro/api/schema\ntype InpaintingImageByFlusReplicateRequest struct {\n\tInput FluxInpaintingInput `json:\"input\"`\n}\n\n// FluxInpaintingInput is input of DrawImageByFluxProRequest\n//\n// https://replicate.com/black-forest-labs/flux-fill-pro/api/schema\ntype FluxInpaintingInput struct {\n\tMask             string `json:\"mask\" binding:\"required\"`\n\tImage            string `json:\"image\" binding:\"required\"`\n\tSeed             int    `json:\"seed\"`\n\tSteps            int    `json:\"steps\" binding:\"required,min=1\"`\n\tPrompt           string `json:\"prompt\" binding:\"required,min=5\"`\n\tGuidance         int    `json:\"guidance\" binding:\"required,min=2,max=5\"`\n\tOutputFormat     string `json:\"output_format\"`\n\tSafetyTolerance  int    `json:\"safety_tolerance\" binding:\"required,min=1,max=5\"`\n\tPromptUnsampling bool   `json:\"prompt_unsampling\"`\n}\n\n// ImageResponse is response of DrawImageByFluxProRequest\n//\n// https://replicate.com/black-forest-labs/flux-pro?prediction=kg1krwsdf9rg80ch1sgsrgq7h8&output=json\ntype ImageResponse struct {\n\tCompletedAt time.Time        `json:\"completed_at\"`\n\tCreatedAt   time.Time        `json:\"created_at\"`\n\tDataRemoved bool             `json:\"data_removed\"`\n\tError       string           `json:\"error\"`\n\tID          string           `json:\"id\"`\n\tInput       DrawImageRequest `json:\"input\"`\n\tLogs        string           `json:\"logs\"`\n\tMetrics     FluxMetrics      `json:\"metrics\"`\n\t// Output could be `string` or `[]string`\n\tOutput    any       `json:\"output\"`\n\tStartedAt time.Time `json:\"started_at\"`\n\tStatus    string    `json:\"status\"`\n\tURLs      FluxURLs  `json:\"urls\"`\n\tVersion   string    `json:\"version\"`\n}\n\nfunc (r *ImageResponse) GetOutput() ([]string, error) {\n\tswitch v := r.Output.(type) {\n\tcase string:\n\t\treturn []string{v}, nil\n\tcase []string:\n\t\treturn v, nil\n\tcase nil:\n\t\treturn nil, nil\n\tcase []interface{}:\n\t\t// convert []interface{} to []string\n\t\tret := make([]string, len(v))\n\t\tfor idx, vv := range v {\n\t\t\tif vvv, ok := vv.(string); ok {\n\t\t\t\tret[idx] = vvv\n\t\t\t} else {\n\t\t\t\treturn nil, errors.Errorf(\"unknown output type: [%T]%v\", vv, vv)\n\t\t\t}\n\t\t}\n\n\t\treturn ret, nil\n\tdefault:\n\t\treturn nil, errors.Errorf(\"unknown output type: [%T]%v\", r.Output, r.Output)\n\t}\n}\n\n// FluxMetrics is metrics of ImageResponse\ntype FluxMetrics struct {\n\tImageCount  int     `json:\"image_count\"`\n\tPredictTime float64 `json:\"predict_time\"`\n\tTotalTime   float64 `json:\"total_time\"`\n}\n\n// FluxURLs is urls of ImageResponse\ntype FluxURLs struct {\n\tGet    string `json:\"get\"`\n\tCancel string `json:\"cancel\"`\n}\n\ntype ReplicateChatRequest struct {\n\tInput ChatInput `json:\"input\" form:\"input\" binding:\"required\"`\n}\n\n// ChatInput is input of ChatByReplicateRequest\n//\n// https://replicate.com/meta/meta-llama-3.1-405b-instruct/api/schema\ntype ChatInput struct {\n\tTopK             int     `json:\"top_k\"`\n\tTopP             float64 `json:\"top_p\"`\n\tPrompt           string  `json:\"prompt\"`\n\tMaxTokens        int     `json:\"max_tokens\"`\n\tMinTokens        int     `json:\"min_tokens\"`\n\tTemperature      float64 `json:\"temperature\"`\n\tSystemPrompt     string  `json:\"system_prompt\"`\n\tStopSequences    string  `json:\"stop_sequences\"`\n\tPromptTemplate   string  `json:\"prompt_template\"`\n\tPresencePenalty  float64 `json:\"presence_penalty\"`\n\tFrequencyPenalty float64 `json:\"frequency_penalty\"`\n}\n\n// ChatResponse is response of ChatByReplicateRequest\n//\n// https://replicate.com/meta/meta-llama-3.1-405b-instruct/examples?input=http&output=json\ntype ChatResponse struct {\n\tCompletedAt time.Time   `json:\"completed_at\"`\n\tCreatedAt   time.Time   `json:\"created_at\"`\n\tDataRemoved bool        `json:\"data_removed\"`\n\tError       string      `json:\"error\"`\n\tID          string      `json:\"id\"`\n\tInput       ChatInput   `json:\"input\"`\n\tLogs        string      `json:\"logs\"`\n\tMetrics     FluxMetrics `json:\"metrics\"`\n\t// Output could be `string` or `[]string`\n\tOutput    []string        `json:\"output\"`\n\tStartedAt time.Time       `json:\"started_at\"`\n\tStatus    string          `json:\"status\"`\n\tURLs      ChatResponseUrl `json:\"urls\"`\n\tVersion   string          `json:\"version\"`\n}\n\n// ChatResponseUrl is task urls of ChatResponse\ntype ChatResponseUrl struct {\n\tStream string `json:\"stream\"`\n\tGet    string `json:\"get\"`\n\tCancel string `json:\"cancel\"`\n}\n"
  },
  {
    "path": "relay/adaptor/siliconflow/constants.go",
    "content": "package siliconflow\n\n// https://docs.siliconflow.cn/docs/getting-started\n\nvar ModelList = []string{\n\t\"deepseek-ai/deepseek-llm-67b-chat\",\n\t\"Qwen/Qwen1.5-14B-Chat\",\n\t\"Qwen/Qwen1.5-7B-Chat\",\n\t\"Qwen/Qwen1.5-110B-Chat\",\n\t\"Qwen/Qwen1.5-32B-Chat\",\n\t\"01-ai/Yi-1.5-6B-Chat\",\n\t\"01-ai/Yi-1.5-9B-Chat-16K\",\n\t\"01-ai/Yi-1.5-34B-Chat-16K\",\n\t\"THUDM/chatglm3-6b\",\n\t\"deepseek-ai/DeepSeek-V2-Chat\",\n\t\"THUDM/glm-4-9b-chat\",\n\t\"Qwen/Qwen2-72B-Instruct\",\n\t\"Qwen/Qwen2-7B-Instruct\",\n\t\"Qwen/Qwen2-57B-A14B-Instruct\",\n\t\"deepseek-ai/DeepSeek-Coder-V2-Instruct\",\n\t\"Qwen/Qwen2-1.5B-Instruct\",\n\t\"internlm/internlm2_5-7b-chat\",\n\t\"BAAI/bge-large-en-v1.5\",\n\t\"BAAI/bge-large-zh-v1.5\",\n\t\"Pro/Qwen/Qwen2-7B-Instruct\",\n\t\"Pro/Qwen/Qwen2-1.5B-Instruct\",\n\t\"Pro/Qwen/Qwen1.5-7B-Chat\",\n\t\"Pro/THUDM/glm-4-9b-chat\",\n\t\"Pro/THUDM/chatglm3-6b\",\n\t\"Pro/01-ai/Yi-1.5-9B-Chat-16K\",\n\t\"Pro/01-ai/Yi-1.5-6B-Chat\",\n\t\"Pro/google/gemma-2-9b-it\",\n\t\"Pro/internlm/internlm2_5-7b-chat\",\n\t\"Pro/meta-llama/Meta-Llama-3-8B-Instruct\",\n\t\"Pro/mistralai/Mistral-7B-Instruct-v0.2\",\n}\n"
  },
  {
    "path": "relay/adaptor/stepfun/constants.go",
    "content": "package stepfun\n\nvar ModelList = []string{\n\t\"step-1-8k\",\n\t\"step-1-32k\",\n\t\"step-1-128k\",\n\t\"step-1-256k\",\n\t\"step-1-flash\",\n\t\"step-2-16k\",\n\t\"step-1v-8k\",\n\t\"step-1v-32k\",\n\t\"step-1x-medium\",\n}\n"
  },
  {
    "path": "relay/adaptor/tencent/adaptor.go",
    "content": "package tencent\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\n// https://cloud.tencent.com/document/api/1729/101837\n\ntype Adaptor struct {\n\tSign      string\n\tAction    string\n\tVersion   string\n\tTimestamp int64\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.Action = \"ChatCompletions\"\n\ta.Version = \"2023-09-01\"\n\ta.Timestamp = helper.GetTimestamp()\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn meta.BaseURL + \"/\", nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\treq.Header.Set(\"Authorization\", a.Sign)\n\treq.Header.Set(\"X-TC-Action\", a.Action)\n\treq.Header.Set(\"X-TC-Version\", a.Version)\n\treq.Header.Set(\"X-TC-Timestamp\", strconv.FormatInt(a.Timestamp, 10))\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tapiKey := c.Request.Header.Get(\"Authorization\")\n\tapiKey = strings.TrimPrefix(apiKey, \"Bearer \")\n\t_, secretId, secretKey, err := ParseConfig(apiKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar convertedRequest any\n\tswitch relayMode {\n\tcase relaymode.Embeddings:\n\t\ta.Action = \"GetEmbedding\"\n\t\tconvertedRequest = ConvertEmbeddingRequest(*request)\n\tdefault:\n\t\ta.Action = \"ChatCompletions\"\n\t\tconvertedRequest = ConvertRequest(*request)\n\t}\n\t// we have to calculate the sign here\n\ta.Sign = GetSign(convertedRequest, a, secretId, secretKey)\n\treturn convertedRequest, nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\tvar responseText string\n\t\terr, responseText = StreamHandler(c, resp)\n\t\tusage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.Embeddings:\n\t\t\terr, usage = EmbeddingHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = Handler(c, resp)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"tencent\"\n}\n"
  },
  {
    "path": "relay/adaptor/tencent/constants.go",
    "content": "package tencent\n\nvar ModelList = []string{\n\t\"hunyuan-lite\",\n\t\"hunyuan-standard\",\n\t\"hunyuan-standard-256K\",\n\t\"hunyuan-pro\",\n\t\"hunyuan-vision\",\n\t\"hunyuan-embedding\",\n}\n"
  },
  {
    "path": "relay/adaptor/tencent/main.go",
    "content": "package tencent\n\nimport (\n\t\"bufio\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/conv\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest {\n\tmessages := make([]*Message, 0, len(request.Messages))\n\tfor i := 0; i < len(request.Messages); i++ {\n\t\tmessage := request.Messages[i]\n\t\tmessages = append(messages, &Message{\n\t\t\tContent: message.StringContent(),\n\t\t\tRole:    message.Role,\n\t\t})\n\t}\n\treturn &ChatRequest{\n\t\tModel:       &request.Model,\n\t\tStream:      &request.Stream,\n\t\tMessages:    messages,\n\t\tTopP:        request.TopP,\n\t\tTemperature: request.Temperature,\n\t}\n}\n\nfunc ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *EmbeddingRequest {\n\treturn &EmbeddingRequest{\n\t\tInputList: request.ParseInput(),\n\t}\n}\n\nfunc EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar tencentResponseP EmbeddingResponseP\n\terr := json.NewDecoder(resp.Body).Decode(&tencentResponseP)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\ttencentResponse := tencentResponseP.Response\n\tif tencentResponse.Error.Code != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: tencentResponse.Error.Message,\n\t\t\t\tCode:    tencentResponse.Error.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\trequestModel := c.GetString(ctxkey.RequestModel)\n\tfullTextResponse := embeddingResponseTencent2OpenAI(&tencentResponse)\n\tfullTextResponse.Model = requestModel\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc embeddingResponseTencent2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {\n\topenAIEmbeddingResponse := openai.EmbeddingResponse{\n\t\tObject: \"list\",\n\t\tData:   make([]openai.EmbeddingResponseItem, 0, len(response.Data)),\n\t\tModel:  \"hunyuan-embedding\",\n\t\tUsage:  model.Usage{TotalTokens: response.EmbeddingUsage.TotalTokens},\n\t}\n\n\tfor _, item := range response.Data {\n\t\topenAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{\n\t\t\tObject:    item.Object,\n\t\t\tIndex:     item.Index,\n\t\t\tEmbedding: item.Embedding,\n\t\t})\n\t}\n\treturn &openAIEmbeddingResponse\n}\n\nfunc responseTencent2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      response.ReqID,\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tUsage: model.Usage{\n\t\t\tPromptTokens:     response.Usage.PromptTokens,\n\t\t\tCompletionTokens: response.Usage.CompletionTokens,\n\t\t\tTotalTokens:      response.Usage.TotalTokens,\n\t\t},\n\t}\n\tif len(response.Choices) > 0 {\n\t\tchoice := openai.TextResponseChoice{\n\t\t\tIndex: 0,\n\t\t\tMessage: model.Message{\n\t\t\t\tRole:    \"assistant\",\n\t\t\t\tContent: response.Choices[0].Messages.Content,\n\t\t\t},\n\t\t\tFinishReason: response.Choices[0].FinishReason,\n\t\t}\n\t\tfullTextResponse.Choices = append(fullTextResponse.Choices, choice)\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseTencent2OpenAI(TencentResponse *ChatResponse) *openai.ChatCompletionsStreamResponse {\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   \"tencent-hunyuan\",\n\t}\n\tif len(TencentResponse.Choices) > 0 {\n\t\tvar choice openai.ChatCompletionsStreamResponseChoice\n\t\tchoice.Delta.Content = TencentResponse.Choices[0].Delta.Content\n\t\tif TencentResponse.Choices[0].FinishReason == \"stop\" {\n\t\t\tchoice.FinishReason = &constant.StopFinishReason\n\t\t}\n\t\tresponse.Choices = append(response.Choices, choice)\n\t}\n\treturn &response\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, string) {\n\tvar responseText string\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(bufio.ScanLines)\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tif len(data) < 5 || !strings.HasPrefix(data, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata = strings.TrimPrefix(data, \"data:\")\n\n\t\tvar tencentResponse ChatResponse\n\t\terr := json.Unmarshal([]byte(data), &tencentResponse)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tresponse := streamResponseTencent2OpenAI(&tencentResponse)\n\t\tif len(response.Choices) != 0 {\n\t\t\tresponseText += conv.AsString(response.Choices[0].Delta.Content)\n\t\t}\n\n\t\terr = render.ObjectData(c, response)\n\t\tif err != nil {\n\t\t\tlogger.SysError(err.Error())\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), \"\"\n\t}\n\n\treturn nil, responseText\n}\n\nfunc Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar TencentResponse ChatResponse\n\tvar responseP ChatResponseP\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &responseP)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tTencentResponse = responseP.Response\n\tif TencentResponse.Error.Code != \"\" {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: TencentResponse.Error.Message,\n\t\t\t\tCode:    TencentResponse.Error.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseTencent2OpenAI(&TencentResponse)\n\tfullTextResponse.Model = \"hunyuan\"\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"write_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc ParseConfig(config string) (appId int64, secretId string, secretKey string, err error) {\n\tparts := strings.Split(config, \"|\")\n\tif len(parts) != 3 {\n\t\terr = errors.New(\"invalid tencent config\")\n\t\treturn\n\t}\n\tappId, err = strconv.ParseInt(parts[0], 10, 64)\n\tsecretId = parts[1]\n\tsecretKey = parts[2]\n\treturn\n}\n\nfunc sha256hex(s string) string {\n\tb := sha256.Sum256([]byte(s))\n\treturn hex.EncodeToString(b[:])\n}\n\nfunc hmacSha256(s, key string) string {\n\thashed := hmac.New(sha256.New, []byte(key))\n\thashed.Write([]byte(s))\n\treturn string(hashed.Sum(nil))\n}\n\nfunc GetSign(req any, adaptor *Adaptor, secId, secKey string) string {\n\t// build canonical request string\n\thost := \"hunyuan.tencentcloudapi.com\"\n\thttpRequestMethod := \"POST\"\n\tcanonicalURI := \"/\"\n\tcanonicalQueryString := \"\"\n\tcanonicalHeaders := fmt.Sprintf(\"content-type:%s\\nhost:%s\\nx-tc-action:%s\\n\",\n\t\t\"application/json\", host, strings.ToLower(adaptor.Action))\n\tsignedHeaders := \"content-type;host;x-tc-action\"\n\tpayload, _ := json.Marshal(req)\n\thashedRequestPayload := sha256hex(string(payload))\n\tcanonicalRequest := fmt.Sprintf(\"%s\\n%s\\n%s\\n%s\\n%s\\n%s\",\n\t\thttpRequestMethod,\n\t\tcanonicalURI,\n\t\tcanonicalQueryString,\n\t\tcanonicalHeaders,\n\t\tsignedHeaders,\n\t\thashedRequestPayload)\n\t// build string to sign\n\talgorithm := \"TC3-HMAC-SHA256\"\n\trequestTimestamp := strconv.FormatInt(adaptor.Timestamp, 10)\n\ttimestamp, _ := strconv.ParseInt(requestTimestamp, 10, 64)\n\tt := time.Unix(timestamp, 0).UTC()\n\t// must be the format 2006-01-02, ref to package time for more info\n\tdate := t.Format(\"2006-01-02\")\n\tcredentialScope := fmt.Sprintf(\"%s/%s/tc3_request\", date, \"hunyuan\")\n\thashedCanonicalRequest := sha256hex(canonicalRequest)\n\tstring2sign := fmt.Sprintf(\"%s\\n%s\\n%s\\n%s\",\n\t\talgorithm,\n\t\trequestTimestamp,\n\t\tcredentialScope,\n\t\thashedCanonicalRequest)\n\n\t// sign string\n\tsecretDate := hmacSha256(date, \"TC3\"+secKey)\n\tsecretService := hmacSha256(\"hunyuan\", secretDate)\n\tsecretKey := hmacSha256(\"tc3_request\", secretService)\n\tsignature := hex.EncodeToString([]byte(hmacSha256(string2sign, secretKey)))\n\n\t// build authorization\n\tauthorization := fmt.Sprintf(\"%s Credential=%s/%s, SignedHeaders=%s, Signature=%s\",\n\t\talgorithm,\n\t\tsecId,\n\t\tcredentialScope,\n\t\tsignedHeaders,\n\t\tsignature)\n\treturn authorization\n}\n"
  },
  {
    "path": "relay/adaptor/tencent/model.go",
    "content": "package tencent\n\ntype Message struct {\n\tRole    string `json:\"Role\"`\n\tContent string `json:\"Content\"`\n}\n\ntype ChatRequest struct {\n\t// 模型名称，可选值包括 hunyuan-lite、hunyuan-standard、hunyuan-standard-256K、hunyuan-pro。\n\t// 各模型介绍请阅读 [产品概述](https://cloud.tencent.com/document/product/1729/104753) 中的说明。\n\t//\n\t// 注意：\n\t// 不同的模型计费不同，请根据 [购买指南](https://cloud.tencent.com/document/product/1729/97731) 按需调用。\n\tModel *string `json:\"Model\"`\n\t// 聊天上下文信息。\n\t// 说明：\n\t// 1. 长度最多为 40，按对话时间从旧到新在数组中排列。\n\t// 2. Message.Role 可选值：system、user、assistant。\n\t// 其中，system 角色可选，如存在则必须位于列表的最开始。user 和 assistant 需交替出现（一问一答），以 user 提问开始和结束，且 Content 不能为空。Role 的顺序示例：[system（可选） user assistant user assistant user ...]。\n\t// 3. Messages 中 Content 总长度不能超过模型输入长度上限（可参考 [产品概述](https://cloud.tencent.com/document/product/1729/104753) 文档），超过则会截断最前面的内容，只保留尾部内容。\n\tMessages []*Message `json:\"Messages\"`\n\t// 流式调用开关。\n\t// 说明：\n\t// 1. 未传值时默认为非流式调用（false）。\n\t// 2. 流式调用时以 SSE 协议增量返回结果（返回值取 Choices[n].Delta 中的值，需要拼接增量数据才能获得完整结果）。\n\t// 3. 非流式调用时：\n\t// 调用方式与普通 HTTP 请求无异。\n\t// 接口响应耗时较长，**如需更低时延建议设置为 true**。\n\t// 只返回一次最终结果（返回值取 Choices[n].Message 中的值）。\n\t//\n\t// 注意：\n\t// 通过 SDK 调用时，流式和非流式调用需用**不同的方式**获取返回值，具体参考 SDK 中的注释或示例（在各语言 SDK 代码仓库的 examples/hunyuan/v20230901/ 目录中）。\n\tStream *bool `json:\"Stream\"`\n\t// 说明：\n\t// 1. 影响输出文本的多样性，取值越大，生成文本的多样性越强。\n\t// 2. 取值区间为 [0.0, 1.0]，未传值时使用各模型推荐值。\n\t// 3. 非必要不建议使用，不合理的取值会影响效果。\n\tTopP *float64 `json:\"TopP,omitempty\"`\n\t// 说明：\n\t// 1. 较高的数值会使输出更加随机，而较低的数值会使其更加集中和确定。\n\t// 2. 取值区间为 [0.0, 2.0]，未传值时使用各模型推荐值。\n\t// 3. 非必要不建议使用，不合理的取值会影响效果。\n\tTemperature *float64 `json:\"Temperature,omitempty\"`\n}\n\ntype Error struct {\n\tCode    string `json:\"Code\"`\n\tMessage string `json:\"Message\"`\n}\n\ntype Usage struct {\n\tPromptTokens     int `json:\"PromptTokens\"`\n\tCompletionTokens int `json:\"CompletionTokens\"`\n\tTotalTokens      int `json:\"TotalTokens\"`\n}\n\ntype ResponseChoices struct {\n\tFinishReason string  `json:\"FinishReason,omitempty\"` // 流式结束标志位，为 stop 则表示尾包\n\tMessages     Message `json:\"Message,omitempty\"`      // 内容，同步模式返回内容，流模式为 null 输出 content 内容总数最多支持 1024token。\n\tDelta        Message `json:\"Delta,omitempty\"`        // 内容，流模式返回内容，同步模式为 null 输出 content 内容总数最多支持 1024token。\n}\n\ntype ChatResponse struct {\n\tChoices []ResponseChoices `json:\"Choices,omitempty\"`   // 结果\n\tCreated int64             `json:\"Created,omitempty\"`   // unix 时间戳的字符串\n\tId      string            `json:\"Id,omitempty\"`        // 会话 id\n\tUsage   Usage             `json:\"Usage,omitempty\"`     // token 数量\n\tError   Error             `json:\"Error,omitempty\"`     // 错误信息 注意：此字段可能返回 null，表示取不到有效值\n\tNote    string            `json:\"Note,omitempty\"`      // 注释\n\tReqID   string            `json:\"RequestId,omitempty\"` // 唯一请求 Id，每次请求都会返回。用于反馈接口入参\n}\n\ntype ChatResponseP struct {\n\tResponse ChatResponse `json:\"Response,omitempty\"`\n}\n\ntype EmbeddingRequest struct {\n\tInputList []string `json:\"InputList\"`\n}\n\ntype EmbeddingData struct {\n\tEmbedding []float64 `json:\"Embedding\"`\n\tIndex     int       `json:\"Index\"`\n\tObject    string    `json:\"Object\"`\n}\n\ntype EmbeddingUsage struct {\n\tPromptTokens int `json:\"PromptTokens\"`\n\tTotalTokens  int `json:\"TotalTokens\"`\n}\n\ntype EmbeddingResponse struct {\n\tData           []EmbeddingData `json:\"Data\"`\n\tEmbeddingUsage EmbeddingUsage  `json:\"Usage,omitempty\"`\n\tRequestId      string          `json:\"RequestId,omitempty\"`\n\tError          Error           `json:\"Error,omitempty\"`\n}\n\ntype EmbeddingResponseP struct {\n\tResponse EmbeddingResponse `json:\"Response,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/togetherai/constants.go",
    "content": "package togetherai\n\n// https://docs.together.ai/docs/inference-models\n\nvar ModelList = []string{\n\t\"meta-llama/Llama-3-70b-chat-hf\",\n\t\"deepseek-ai/deepseek-coder-33b-instruct\",\n\t\"mistralai/Mixtral-8x22B-Instruct-v0.1\",\n\t\"Qwen/Qwen1.5-72B-Chat\",\n}\n"
  },
  {
    "path": "relay/adaptor/vertexai/adaptor.go",
    "content": "package vertexai\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\tchannelhelper \"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar _ adaptor.Adaptor = new(Adaptor)\n\nconst channelName = \"vertexai\"\n\ntype Adaptor struct{}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\tadaptor := GetAdaptor(request.Model)\n\tif adaptor == nil {\n\t\treturn nil, errors.New(\"adaptor not found\")\n\t}\n\n\treturn adaptor.ConvertRequest(c, relayMode, request)\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tadaptor := GetAdaptor(meta.ActualModelName)\n\tif adaptor == nil {\n\t\treturn nil, &relaymodel.ErrorWithStatusCode{\n\t\t\tStatusCode: http.StatusInternalServerError,\n\t\t\tError: relaymodel.Error{\n\t\t\t\tMessage: \"adaptor not found\",\n\t\t\t},\n\t\t}\n\t}\n\treturn adaptor.DoResponse(c, resp, meta)\n}\n\nfunc (a *Adaptor) GetModelList() (models []string) {\n\tmodels = modelList\n\treturn\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn channelName\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tsuffix := \"\"\n\tif strings.HasPrefix(meta.ActualModelName, \"gemini\") {\n\t\tif meta.IsStream {\n\t\t\tsuffix = \"streamGenerateContent?alt=sse\"\n\t\t} else {\n\t\t\tsuffix = \"generateContent\"\n\t\t}\n\t} else {\n\t\tif meta.IsStream {\n\t\t\tsuffix = \"streamRawPredict?alt=sse\"\n\t\t} else {\n\t\t\tsuffix = \"rawPredict\"\n\t\t}\n\t}\n\n\tif meta.BaseURL != \"\" {\n\t\treturn fmt.Sprintf(\n\t\t\t\"%s/v1/projects/%s/locations/%s/publishers/google/models/%s:%s\",\n\t\t\tmeta.BaseURL,\n\t\t\tmeta.Config.VertexAIProjectID,\n\t\t\tmeta.Config.Region,\n\t\t\tmeta.ActualModelName,\n\t\t\tsuffix,\n\t\t), nil\n\t}\n\treturn fmt.Sprintf(\n\t\t\"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s\",\n\t\tmeta.Config.Region,\n\t\tmeta.Config.VertexAIProjectID,\n\t\tmeta.Config.Region,\n\t\tmeta.ActualModelName,\n\t\tsuffix,\n\t), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\ttoken, err := getToken(c, meta.ChannelId, meta.Config.VertexAIADC)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+token)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn channelhelper.DoRequestHelper(a, c, meta, requestBody)\n}\n"
  },
  {
    "path": "relay/adaptor/vertexai/claude/adapter.go",
    "content": "package vertexai\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/anthropic\"\n\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar ModelList = []string{\n\t\"claude-3-haiku@20240307\",\n\t\"claude-3-sonnet@20240229\",\n\t\"claude-3-opus@20240229\",\n\t\"claude-3-5-sonnet@20240620\",\n\t\"claude-3-5-sonnet-v2@20241022\",\n\t\"claude-3-5-haiku@20241022\",\n}\n\nconst anthropicVersion = \"vertex-2023-10-16\"\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\tclaudeReq := anthropic.ConvertRequest(*request)\n\treq := Request{\n\t\tAnthropicVersion: anthropicVersion,\n\t\t// Model:            claudeReq.Model,\n\t\tMessages:    claudeReq.Messages,\n\t\tSystem:      claudeReq.System,\n\t\tMaxTokens:   claudeReq.MaxTokens,\n\t\tTemperature: claudeReq.Temperature,\n\t\tTopP:        claudeReq.TopP,\n\t\tTopK:        claudeReq.TopK,\n\t\tStream:      claudeReq.Stream,\n\t\tTools:       claudeReq.Tools,\n\t}\n\n\tc.Set(ctxkey.RequestModel, request.Model)\n\tc.Set(ctxkey.ConvertedRequest, req)\n\treturn req, nil\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, usage = anthropic.StreamHandler(c, resp)\n\t} else {\n\t\terr, usage = anthropic.Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "relay/adaptor/vertexai/claude/model.go",
    "content": "package vertexai\n\nimport \"github.com/songquanpeng/one-api/relay/adaptor/anthropic\"\n\ntype Request struct {\n\t// AnthropicVersion must be \"vertex-2023-10-16\"\n\tAnthropicVersion string `json:\"anthropic_version\"`\n\t// Model            string              `json:\"model\"`\n\tMessages      []anthropic.Message `json:\"messages\"`\n\tSystem        string              `json:\"system,omitempty\"`\n\tMaxTokens     int                 `json:\"max_tokens,omitempty\"`\n\tStopSequences []string            `json:\"stop_sequences,omitempty\"`\n\tStream        bool                `json:\"stream,omitempty\"`\n\tTemperature   *float64            `json:\"temperature,omitempty\"`\n\tTopP          *float64            `json:\"top_p,omitempty\"`\n\tTopK          int                 `json:\"top_k,omitempty\"`\n\tTools         []anthropic.Tool    `json:\"tools,omitempty\"`\n\tToolChoice    any                 `json:\"tool_choice,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor/vertexai/gemini/adapter.go",
    "content": "package vertexai\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pkg/errors\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/gemini\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nvar ModelList = []string{\n\t\"gemini-pro\", \"gemini-pro-vision\",\n\t\"gemini-exp-1206\",\n\t\"gemini-1.5-pro-001\", \"gemini-1.5-pro-002\",\n\t\"gemini-1.5-flash-001\", \"gemini-1.5-flash-002\",\n\t\"gemini-2.0-flash-exp\", \"gemini-2.0-flash-001\",\n\t\"gemini-2.0-flash-lite-preview-02-05\",\n\t\"gemini-2.0-flash-thinking-exp-01-21\",\n}\n\ntype Adaptor struct {\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\n\tgeminiRequest := gemini.ConvertRequest(*request)\n\tc.Set(ctxkey.RequestModel, request.Model)\n\tc.Set(ctxkey.ConvertedRequest, geminiRequest)\n\treturn geminiRequest, nil\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\tvar responseText string\n\t\terr, responseText = gemini.StreamHandler(c, resp)\n\t\tusage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)\n\t} else {\n\t\tswitch meta.Mode {\n\t\tcase relaymode.Embeddings:\n\t\t\terr, usage = gemini.EmbeddingHandler(c, resp)\n\t\tdefault:\n\t\t\terr, usage = gemini.Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t\t}\n\t}\n\treturn\n}\n"
  },
  {
    "path": "relay/adaptor/vertexai/registry.go",
    "content": "package vertexai\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\tclaude \"github.com/songquanpeng/one-api/relay/adaptor/vertexai/claude\"\n\tgemini \"github.com/songquanpeng/one-api/relay/adaptor/vertexai/gemini\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype VertexAIModelType int\n\nconst (\n\tVerterAIClaude VertexAIModelType = iota + 1\n\tVerterAIGemini\n)\n\nvar modelMapping = map[string]VertexAIModelType{}\nvar modelList = []string{}\n\nfunc init() {\n\tmodelList = append(modelList, claude.ModelList...)\n\tfor _, model := range claude.ModelList {\n\t\tmodelMapping[model] = VerterAIClaude\n\t}\n\n\tmodelList = append(modelList, gemini.ModelList...)\n\tfor _, model := range gemini.ModelList {\n\t\tmodelMapping[model] = VerterAIGemini\n\t}\n}\n\ntype innerAIAdapter interface {\n\tConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error)\n\tDoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode)\n}\n\nfunc GetAdaptor(model string) innerAIAdapter {\n\tadaptorType := modelMapping[model]\n\tswitch adaptorType {\n\tcase VerterAIClaude:\n\t\treturn &claude.Adaptor{}\n\tcase VerterAIGemini:\n\t\treturn &gemini.Adaptor{}\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "relay/adaptor/vertexai/token.go",
    "content": "package vertexai\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\tcredentials \"cloud.google.com/go/iam/credentials/apiv1\"\n\t\"cloud.google.com/go/iam/credentials/apiv1/credentialspb\"\n\t\"github.com/patrickmn/go-cache\"\n\t\"google.golang.org/api/option\"\n)\n\ntype ApplicationDefaultCredentials struct {\n\tType                    string `json:\"type\"`\n\tProjectID               string `json:\"project_id\"`\n\tPrivateKeyID            string `json:\"private_key_id\"`\n\tPrivateKey              string `json:\"private_key\"`\n\tClientEmail             string `json:\"client_email\"`\n\tClientID                string `json:\"client_id\"`\n\tAuthURI                 string `json:\"auth_uri\"`\n\tTokenURI                string `json:\"token_uri\"`\n\tAuthProviderX509CertURL string `json:\"auth_provider_x509_cert_url\"`\n\tClientX509CertURL       string `json:\"client_x509_cert_url\"`\n\tUniverseDomain          string `json:\"universe_domain\"`\n}\n\nvar Cache = cache.New(50*time.Minute, 55*time.Minute)\n\nconst defaultScope = \"https://www.googleapis.com/auth/cloud-platform\"\n\nfunc getToken(ctx context.Context, channelId int, adcJson string) (string, error) {\n\tcacheKey := fmt.Sprintf(\"vertexai-token-%d\", channelId)\n\tif token, found := Cache.Get(cacheKey); found {\n\t\treturn token.(string), nil\n\t}\n\tadc := &ApplicationDefaultCredentials{}\n\tif err := json.Unmarshal([]byte(adcJson), adc); err != nil {\n\t\treturn \"\", fmt.Errorf(\"Failed to decode credentials file: %w\", err)\n\t}\n\n\tc, err := credentials.NewIamCredentialsClient(ctx, option.WithCredentialsJSON([]byte(adcJson)))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"Failed to create client: %w\", err)\n\t}\n\tdefer c.Close()\n\n\treq := &credentialspb.GenerateAccessTokenRequest{\n\t\t// See https://pkg.go.dev/cloud.google.com/go/iam/credentials/apiv1/credentialspb#GenerateAccessTokenRequest.\n\t\tName:  fmt.Sprintf(\"projects/-/serviceAccounts/%s\", adc.ClientEmail),\n\t\tScope: []string{defaultScope},\n\t}\n\tresp, err := c.GenerateAccessToken(ctx, req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"Failed to generate access token: %w\", err)\n\t}\n\t_ = resp\n\n\tCache.Set(cacheKey, resp.AccessToken, cache.DefaultExpiration)\n\treturn resp.AccessToken, nil\n}\n"
  },
  {
    "path": "relay/adaptor/xai/constants.go",
    "content": "package xai\n\n//https://console.x.ai/\n\nvar ModelList = []string{\n\t\"grok-2\",\n\t\"grok-vision-beta\",\n\t\"grok-2-vision-1212\",\n\t\"grok-2-vision\",\n\t\"grok-2-vision-latest\",\n\t\"grok-2-1212\",\n\t\"grok-2-latest\",\n\t\"grok-beta\",\n}\n"
  },
  {
    "path": "relay/adaptor/xunfei/adaptor.go",
    "content": "package xunfei\n\nimport (\n\t\"errors\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\ntype Adaptor struct {\n\trequest *model.GeneralOpenAIRequest\n\tmeta    *meta.Meta\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\ta.meta = meta\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\treturn \"\", nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\t// check DoResponse for auth part\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\ta.request = request\n\treturn nil, nil\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\treturn request, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\t// xunfei's request is not http request, so we don't need to do anything here\n\tdummyResp := &http.Response{}\n\tdummyResp.StatusCode = http.StatusOK\n\treturn dummyResp, nil\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tsplits := strings.Split(meta.APIKey, \"|\")\n\tif len(splits) != 3 {\n\t\treturn nil, openai.ErrorWrapper(errors.New(\"invalid auth\"), \"invalid_auth\", http.StatusBadRequest)\n\t}\n\tif a.request == nil {\n\t\treturn nil, openai.ErrorWrapper(errors.New(\"request is nil\"), \"request_is_nil\", http.StatusBadRequest)\n\t}\n\tversion := parseAPIVersionByModelName(meta.ActualModelName)\n\tif version == \"\" {\n\t\tversion = a.meta.Config.APIVersion\n\t}\n\tif version == \"\" {\n\t\tversion = \"v1.1\"\n\t}\n\ta.meta.Config.APIVersion = version\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, meta, *a.request, splits[0], splits[1], splits[2])\n\t} else {\n\t\terr, usage = Handler(c, meta, *a.request, splits[0], splits[1], splits[2])\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"xunfei\"\n}\n"
  },
  {
    "path": "relay/adaptor/xunfei/constants.go",
    "content": "package xunfei\n\nvar ModelList = []string{\n\t\"Spark-Lite\",\n\t\"Spark-Pro\",\n\t\"Spark-Pro-128K\",\n\t\"Spark-Max\",\n\t\"Spark-Max-32K\",\n\t\"Spark-4.0-Ultra\",\n}\n"
  },
  {
    "path": "relay/adaptor/xunfei/domain.go",
    "content": "package xunfei\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\n// https://www.xfyun.cn/doc/spark/Web.html#_1-%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E\n\n//Spark4.0 Ultra 请求地址，对应的domain参数为4.0Ultra：\n//\n//wss://spark-api.xf-yun.com/v4.0/chat\n//Spark Max-32K请求地址，对应的domain参数为max-32k\n//\n//wss://spark-api.xf-yun.com/chat/max-32k\n//Spark Max请求地址，对应的domain参数为generalv3.5\n//\n//wss://spark-api.xf-yun.com/v3.5/chat\n//Spark Pro-128K请求地址，对应的domain参数为pro-128k：\n//\n// wss://spark-api.xf-yun.com/chat/pro-128k\n//Spark Pro请求地址，对应的domain参数为generalv3：\n//\n//wss://spark-api.xf-yun.com/v3.1/chat\n//Spark Lite请求地址，对应的domain参数为lite：\n//\n//wss://spark-api.xf-yun.com/v1.1/chat\n\n// Lite、Pro、Pro-128K、Max、Max-32K和4.0 Ultra\n\nfunc parseAPIVersionByModelName(modelName string) string {\n\tapiVersion := modelName2APIVersion(modelName)\n\tif apiVersion != \"\" {\n\t\treturn apiVersion\n\t}\n\n\tindex := strings.IndexAny(modelName, \"-\")\n\tif index != -1 {\n\t\treturn modelName[index+1:]\n\t}\n\treturn \"\"\n}\n\nfunc modelName2APIVersion(modelName string) string {\n\tswitch modelName {\n\tcase \"Spark-Lite\":\n\t\treturn \"v1.1\"\n\tcase \"Spark-Pro\":\n\t\treturn \"v3.1\"\n\tcase \"Spark-Pro-128K\":\n\t\treturn \"v3.1-128K\"\n\tcase \"Spark-Max\":\n\t\treturn \"v3.5\"\n\tcase \"Spark-Max-32K\":\n\t\treturn \"v3.5-32K\"\n\tcase \"Spark-4.0-Ultra\":\n\t\treturn \"v4.0\"\n\t}\n\treturn \"\"\n}\n\n// https://www.xfyun.cn/doc/spark/Web.html#_1-%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E\nfunc apiVersion2domain(apiVersion string) string {\n\tswitch apiVersion {\n\tcase \"v1.1\":\n\t\treturn \"lite\"\n\tcase \"v2.1\":\n\t\treturn \"generalv2\"\n\tcase \"v3.1\":\n\t\treturn \"generalv3\"\n\tcase \"v3.1-128K\":\n\t\treturn \"pro-128k\"\n\tcase \"v3.5\":\n\t\treturn \"generalv3.5\"\n\tcase \"v3.5-32K\":\n\t\treturn \"max-32k\"\n\tcase \"v4.0\":\n\t\treturn \"4.0Ultra\"\n\t}\n\treturn \"general\" + apiVersion\n}\n\nfunc getXunfeiAuthUrl(apiVersion string, apiKey string, apiSecret string) (string, string) {\n\tvar authUrl string\n\tdomain := apiVersion2domain(apiVersion)\n\tswitch apiVersion {\n\tcase \"v3.1-128K\":\n\t\tauthUrl = buildXunfeiAuthUrl(fmt.Sprintf(\"wss://spark-api.xf-yun.com/chat/pro-128k\"), apiKey, apiSecret)\n\t\tbreak\n\tcase \"v3.5-32K\":\n\t\tauthUrl = buildXunfeiAuthUrl(fmt.Sprintf(\"wss://spark-api.xf-yun.com/chat/max-32k\"), apiKey, apiSecret)\n\t\tbreak\n\tdefault:\n\t\tauthUrl = buildXunfeiAuthUrl(fmt.Sprintf(\"wss://spark-api.xf-yun.com/%s/chat\", apiVersion), apiKey, apiSecret)\n\t}\n\treturn domain, authUrl\n}\n"
  },
  {
    "path": "relay/adaptor/xunfei/main.go",
    "content": "package xunfei\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gorilla/websocket\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/common/random\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://console.xfyun.cn/services/cbm\n// https://www.xfyun.cn/doc/spark/Web.html\n\nfunc requestOpenAI2Xunfei(request model.GeneralOpenAIRequest, xunfeiAppId string, domain string) *ChatRequest {\n\tmessages := make([]Message, 0, len(request.Messages))\n\tfor _, message := range request.Messages {\n\t\tmessages = append(messages, Message{\n\t\t\tRole:    message.Role,\n\t\t\tContent: message.StringContent(),\n\t\t})\n\t}\n\txunfeiRequest := ChatRequest{}\n\txunfeiRequest.Header.AppId = xunfeiAppId\n\txunfeiRequest.Parameter.Chat.Domain = domain\n\txunfeiRequest.Parameter.Chat.Temperature = request.Temperature\n\txunfeiRequest.Parameter.Chat.TopK = request.N\n\txunfeiRequest.Parameter.Chat.MaxTokens = request.MaxTokens\n\txunfeiRequest.Payload.Message.Text = messages\n\n\tif strings.HasPrefix(domain, \"generalv3\") || domain == \"4.0Ultra\" {\n\t\tfunctions := make([]model.Function, len(request.Tools))\n\t\tfor i, tool := range request.Tools {\n\t\t\tfunctions[i] = tool.Function\n\t\t}\n\t\txunfeiRequest.Payload.Functions = &Functions{\n\t\t\tText: functions,\n\t\t}\n\t}\n\n\treturn &xunfeiRequest\n}\n\nfunc getToolCalls(response *ChatResponse) []model.Tool {\n\tvar toolCalls []model.Tool\n\tif len(response.Payload.Choices.Text) == 0 {\n\t\treturn toolCalls\n\t}\n\titem := response.Payload.Choices.Text[0]\n\tif item.FunctionCall == nil {\n\t\treturn toolCalls\n\t}\n\ttoolCall := model.Tool{\n\t\tId:       fmt.Sprintf(\"call_%s\", random.GetUUID()),\n\t\tType:     \"function\",\n\t\tFunction: *item.FunctionCall,\n\t}\n\ttoolCalls = append(toolCalls, toolCall)\n\treturn toolCalls\n}\n\nfunc responseXunfei2OpenAI(response *ChatResponse) *openai.TextResponse {\n\tif len(response.Payload.Choices.Text) == 0 {\n\t\tresponse.Payload.Choices.Text = []ChatResponseTextItem{\n\t\t\t{\n\t\t\t\tContent: \"\",\n\t\t\t},\n\t\t}\n\t}\n\tchoice := openai.TextResponseChoice{\n\t\tIndex: 0,\n\t\tMessage: model.Message{\n\t\t\tRole:      \"assistant\",\n\t\t\tContent:   response.Payload.Choices.Text[0].Content,\n\t\t\tToolCalls: getToolCalls(response),\n\t\t},\n\t\tFinishReason: constant.StopFinishReason,\n\t}\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: []openai.TextResponseChoice{choice},\n\t\tUsage:   response.Payload.Usage.Text,\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseXunfei2OpenAI(xunfeiResponse *ChatResponse) *openai.ChatCompletionsStreamResponse {\n\tif len(xunfeiResponse.Payload.Choices.Text) == 0 {\n\t\txunfeiResponse.Payload.Choices.Text = []ChatResponseTextItem{\n\t\t\t{\n\t\t\t\tContent: \"\",\n\t\t\t},\n\t\t}\n\t}\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = xunfeiResponse.Payload.Choices.Text[0].Content\n\tchoice.Delta.ToolCalls = getToolCalls(xunfeiResponse)\n\tif xunfeiResponse.Payload.Choices.Status == 2 {\n\t\tchoice.FinishReason = &constant.StopFinishReason\n\t}\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tId:      fmt.Sprintf(\"chatcmpl-%s\", random.GetUUID()),\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   \"SparkDesk\",\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &response\n}\n\nfunc buildXunfeiAuthUrl(hostUrl string, apiKey, apiSecret string) string {\n\tHmacWithShaToBase64 := func(algorithm, data, key string) string {\n\t\tmac := hmac.New(sha256.New, []byte(key))\n\t\tmac.Write([]byte(data))\n\t\tencodeData := mac.Sum(nil)\n\t\treturn base64.StdEncoding.EncodeToString(encodeData)\n\t}\n\tul, err := url.Parse(hostUrl)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t}\n\tdate := time.Now().UTC().Format(time.RFC1123)\n\tsignString := []string{\"host: \" + ul.Host, \"date: \" + date, \"GET \" + ul.Path + \" HTTP/1.1\"}\n\tsign := strings.Join(signString, \"\\n\")\n\tsha := HmacWithShaToBase64(\"hmac-sha256\", sign, apiSecret)\n\tauthUrl := fmt.Sprintf(\"hmac username=\\\"%s\\\", algorithm=\\\"%s\\\", headers=\\\"%s\\\", signature=\\\"%s\\\"\", apiKey,\n\t\t\"hmac-sha256\", \"host date request-line\", sha)\n\tauthorization := base64.StdEncoding.EncodeToString([]byte(authUrl))\n\tv := url.Values{}\n\tv.Add(\"host\", ul.Host)\n\tv.Add(\"date\", date)\n\tv.Add(\"authorization\", authorization)\n\tcallUrl := hostUrl + \"?\" + v.Encode()\n\treturn callUrl\n}\n\nfunc StreamHandler(c *gin.Context, meta *meta.Meta, textRequest model.GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tdomain, authUrl := getXunfeiAuthUrl(meta.Config.APIVersion, apiKey, apiSecret)\n\tdataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"xunfei_request_failed\", http.StatusInternalServerError), nil\n\t}\n\tcommon.SetEventStreamHeaders(c)\n\tvar usage model.Usage\n\tc.Stream(func(w io.Writer) bool {\n\t\tselect {\n\t\tcase xunfeiResponse := <-dataChan:\n\t\t\tusage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens\n\t\t\tusage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens\n\t\t\tusage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens\n\t\t\tresponse := streamResponseXunfei2OpenAI(&xunfeiResponse)\n\t\t\tjsonResponse, err := json.Marshal(response)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error marshalling stream response: \" + err.Error())\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: \" + string(jsonResponse)})\n\t\t\treturn true\n\t\tcase <-stopChan:\n\t\t\tc.Render(-1, common.CustomEvent{Data: \"data: [DONE]\"})\n\t\t\treturn false\n\t\t}\n\t})\n\treturn nil, &usage\n}\n\nfunc Handler(c *gin.Context, meta *meta.Meta, textRequest model.GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*model.ErrorWithStatusCode, *model.Usage) {\n\tdomain, authUrl := getXunfeiAuthUrl(meta.Config.APIVersion, apiKey, apiSecret)\n\tdataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"xunfei_request_failed\", http.StatusInternalServerError), nil\n\t}\n\tvar usage model.Usage\n\tvar content string\n\tvar xunfeiResponse ChatResponse\n\tstop := false\n\tfor !stop {\n\t\tselect {\n\t\tcase xunfeiResponse = <-dataChan:\n\t\t\tif len(xunfeiResponse.Payload.Choices.Text) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcontent += xunfeiResponse.Payload.Choices.Text[0].Content\n\t\t\tusage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens\n\t\t\tusage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens\n\t\t\tusage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens\n\t\tcase stop = <-stopChan:\n\t\t}\n\t}\n\tif len(xunfeiResponse.Payload.Choices.Text) == 0 {\n\t\treturn openai.ErrorWrapper(errors.New(\"xunfei empty response detected\"), \"xunfei_empty_response_detected\", http.StatusInternalServerError), nil\n\t}\n\txunfeiResponse.Payload.Choices.Text[0].Content = content\n\n\tresponse := responseXunfei2OpenAI(&xunfeiResponse)\n\tjsonResponse, err := json.Marshal(response)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\t_, _ = c.Writer.Write(jsonResponse)\n\treturn nil, &usage\n}\n\nfunc xunfeiMakeRequest(textRequest model.GeneralOpenAIRequest, domain, authUrl, appId string) (chan ChatResponse, chan bool, error) {\n\td := websocket.Dialer{\n\t\tHandshakeTimeout: 5 * time.Second,\n\t}\n\tconn, resp, err := d.Dial(authUrl, nil)\n\tif err != nil || resp.StatusCode != 101 {\n\t\treturn nil, nil, err\n\t}\n\tdata := requestOpenAI2Xunfei(textRequest, appId, domain)\n\terr = conn.WriteJSON(data)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\t_, msg, err := conn.ReadMessage()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tdataChan := make(chan ChatResponse)\n\tstopChan := make(chan bool)\n\tgo func() {\n\t\tfor {\n\t\t\tif msg == nil {\n\t\t\t\t_, msg, err = conn.ReadMessage()\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"error reading stream response: \" + err.Error())\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tvar response ChatResponse\n\t\t\terr = json.Unmarshal(msg, &response)\n\t\t\tif err != nil {\n\t\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tmsg = nil\n\t\t\tdataChan <- response\n\t\t\tif response.Payload.Choices.Status == 2 {\n\t\t\t\terr := conn.Close()\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"error closing websocket connection: \" + err.Error())\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tstopChan <- true\n\t}()\n\n\treturn dataChan, stopChan, nil\n}\n"
  },
  {
    "path": "relay/adaptor/xunfei/model.go",
    "content": "package xunfei\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\ntype Message struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype Functions struct {\n\tText []model.Function `json:\"text,omitempty\"`\n}\n\ntype ChatRequest struct {\n\tHeader struct {\n\t\tAppId string `json:\"app_id\"`\n\t} `json:\"header\"`\n\tParameter struct {\n\t\tChat struct {\n\t\t\tDomain      string   `json:\"domain,omitempty\"`\n\t\t\tTemperature *float64 `json:\"temperature,omitempty\"`\n\t\t\tTopK        int      `json:\"top_k,omitempty\"`\n\t\t\tMaxTokens   int      `json:\"max_tokens,omitempty\"`\n\t\t\tAuditing    bool     `json:\"auditing,omitempty\"`\n\t\t} `json:\"chat\"`\n\t} `json:\"parameter\"`\n\tPayload struct {\n\t\tMessage struct {\n\t\t\tText []Message `json:\"text\"`\n\t\t} `json:\"message\"`\n\t\tFunctions *Functions `json:\"functions,omitempty\"`\n\t} `json:\"payload\"`\n}\n\ntype ChatResponseTextItem struct {\n\tContent      string          `json:\"content\"`\n\tRole         string          `json:\"role\"`\n\tIndex        int             `json:\"index\"`\n\tContentType  string          `json:\"content_type\"`\n\tFunctionCall *model.Function `json:\"function_call\"`\n}\n\ntype ChatResponse struct {\n\tHeader struct {\n\t\tCode    int    `json:\"code\"`\n\t\tMessage string `json:\"message\"`\n\t\tSid     string `json:\"sid\"`\n\t\tStatus  int    `json:\"status\"`\n\t} `json:\"header\"`\n\tPayload struct {\n\t\tChoices struct {\n\t\t\tStatus int                    `json:\"status\"`\n\t\t\tSeq    int                    `json:\"seq\"`\n\t\t\tText   []ChatResponseTextItem `json:\"text\"`\n\t\t} `json:\"choices\"`\n\t\tUsage struct {\n\t\t\t//Text struct {\n\t\t\t//\tQuestionTokens   string `json:\"question_tokens\"`\n\t\t\t//\tPromptTokens     string `json:\"prompt_tokens\"`\n\t\t\t//\tCompletionTokens string `json:\"completion_tokens\"`\n\t\t\t//\tTotalTokens      string `json:\"total_tokens\"`\n\t\t\t//} `json:\"text\"`\n\t\t\tText model.Usage `json:\"text\"`\n\t\t} `json:\"usage\"`\n\t} `json:\"payload\"`\n}\n"
  },
  {
    "path": "relay/adaptor/xunfeiv2/constants.go",
    "content": "package xunfeiv2\n\n// https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html#_3-%E8%AF%B7%E6%B1%82%E8%AF%B4%E6%98%8E\n\nvar ModelList = []string{\n\t\"lite\",\n\t\"generalv3\",\n\t\"pro-128k\",\n\t\"generalv3.5\",\n\t\"max-32k\",\n\t\"4.0Ultra\",\n}\n"
  },
  {
    "path": "relay/adaptor/zhipu/adaptor.go",
    "content": "package zhipu\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\ntype Adaptor struct {\n\tAPIVersion string\n}\n\nfunc (a *Adaptor) Init(meta *meta.Meta) {\n\n}\n\nfunc (a *Adaptor) SetVersionByModeName(modelName string) {\n\tif strings.HasPrefix(modelName, \"glm-\") {\n\t\ta.APIVersion = \"v4\"\n\t} else {\n\t\ta.APIVersion = \"v3\"\n\t}\n}\n\nfunc (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {\n\tswitch meta.Mode {\n\tcase relaymode.ImagesGenerations:\n\t\treturn fmt.Sprintf(\"%s/api/paas/v4/images/generations\", meta.BaseURL), nil\n\tcase relaymode.Embeddings:\n\t\treturn fmt.Sprintf(\"%s/api/paas/v4/embeddings\", meta.BaseURL), nil\n\t}\n\ta.SetVersionByModeName(meta.ActualModelName)\n\tif a.APIVersion == \"v4\" {\n\t\treturn fmt.Sprintf(\"%s/api/paas/v4/chat/completions\", meta.BaseURL), nil\n\t}\n\tmethod := \"invoke\"\n\tif meta.IsStream {\n\t\tmethod = \"sse-invoke\"\n\t}\n\treturn fmt.Sprintf(\"%s/api/paas/v3/model-api/%s/%s\", meta.BaseURL, meta.ActualModelName, method), nil\n}\n\nfunc (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Request, meta *meta.Meta) error {\n\tadaptor.SetupCommonRequestHeader(c, req, meta)\n\ttoken := GetToken(meta.APIKey)\n\treq.Header.Set(\"Authorization\", token)\n\treturn nil\n}\n\nfunc (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.GeneralOpenAIRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Embeddings:\n\t\tbaiduEmbeddingRequest, err := ConvertEmbeddingRequest(*request)\n\t\treturn baiduEmbeddingRequest, err\n\tdefault:\n\t\t// TopP [0.0, 1.0]\n\t\trequest.TopP = helper.Float64PtrMax(request.TopP, 1)\n\t\trequest.TopP = helper.Float64PtrMin(request.TopP, 0)\n\n\t\t// Temperature [0.0, 1.0]\n\t\trequest.Temperature = helper.Float64PtrMax(request.Temperature, 1)\n\t\trequest.Temperature = helper.Float64PtrMin(request.Temperature, 0)\n\t\ta.SetVersionByModeName(request.Model)\n\t\tif a.APIVersion == \"v4\" {\n\t\t\treturn request, nil\n\t\t}\n\t\treturn ConvertRequest(*request), nil\n\t}\n}\n\nfunc (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {\n\tif request == nil {\n\t\treturn nil, errors.New(\"request is nil\")\n\t}\n\tnewRequest := ImageRequest{\n\t\tModel:  request.Model,\n\t\tPrompt: request.Prompt,\n\t\tUserId: request.User,\n\t}\n\treturn newRequest, nil\n}\n\nfunc (a *Adaptor) DoRequest(c *gin.Context, meta *meta.Meta, requestBody io.Reader) (*http.Response, error) {\n\treturn adaptor.DoRequestHelper(a, c, meta, requestBody)\n}\n\nfunc (a *Adaptor) DoResponseV4(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tif meta.IsStream {\n\t\terr, _, usage = openai.StreamHandler(c, resp, meta.Mode)\n\t} else {\n\t\terr, usage = openai.Handler(c, resp, meta.PromptTokens, meta.ActualModelName)\n\t}\n\treturn\n}\n\nfunc (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Meta) (usage *model.Usage, err *model.ErrorWithStatusCode) {\n\tswitch meta.Mode {\n\tcase relaymode.Embeddings:\n\t\terr, usage = EmbeddingsHandler(c, resp)\n\t\treturn\n\tcase relaymode.ImagesGenerations:\n\t\terr, usage = openai.ImageHandler(c, resp)\n\t\treturn\n\t}\n\tif a.APIVersion == \"v4\" {\n\t\treturn a.DoResponseV4(c, resp, meta)\n\t}\n\tif meta.IsStream {\n\t\terr, usage = StreamHandler(c, resp)\n\t} else {\n\t\tif meta.Mode == relaymode.Embeddings {\n\t\t\terr, usage = EmbeddingsHandler(c, resp)\n\t\t} else {\n\t\t\terr, usage = Handler(c, resp)\n\t\t}\n\t}\n\treturn\n}\n\nfunc ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) (*EmbeddingRequest, error) {\n\tinputs := request.ParseInput()\n\tif len(inputs) != 1 {\n\t\treturn nil, errors.New(\"invalid input length, zhipu only support one input\")\n\t}\n\treturn &EmbeddingRequest{\n\t\tModel: request.Model,\n\t\tInput: inputs[0],\n\t}, nil\n}\n\nfunc (a *Adaptor) GetModelList() []string {\n\treturn ModelList\n}\n\nfunc (a *Adaptor) GetChannelName() string {\n\treturn \"zhipu\"\n}\n"
  },
  {
    "path": "relay/adaptor/zhipu/constants.go",
    "content": "package zhipu\n\n// https://open.bigmodel.cn/pricing\n\nvar ModelList = []string{\n\t\"glm-zero-preview\", \"glm-4-plus\", \"glm-4-0520\", \"glm-4-airx\",\n\t\"glm-4-air\", \"glm-4-long\", \"glm-4-flashx\", \"glm-4-flash\",\n\t\"glm-4\", \"glm-3-turbo\",\n\t\"glm-4v-plus\", \"glm-4v\", \"glm-4v-flash\",\n\t\"cogview-3-plus\", \"cogview-3\", \"cogview-3-flash\",\n\t\"cogviewx\", \"cogviewx-flash\",\n\t\"charglm-4\", \"emohaa\", \"codegeex-4\",\n\t\"embedding-2\", \"embedding-3\",\n}\n"
  },
  {
    "path": "relay/adaptor/zhipu/main.go",
    "content": "package zhipu\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"github.com/songquanpeng/one-api/common/render\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/golang-jwt/jwt\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/constant\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// https://open.bigmodel.cn/doc/api#chatglm_std\n// chatglm_std, chatglm_lite\n// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke\n// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke\n\nvar zhipuTokens sync.Map\nvar expSeconds int64 = 24 * 3600\n\nfunc GetToken(apikey string) string {\n\tdata, ok := zhipuTokens.Load(apikey)\n\tif ok {\n\t\ttokenData := data.(tokenData)\n\t\tif time.Now().Before(tokenData.ExpiryTime) {\n\t\t\treturn tokenData.Token\n\t\t}\n\t}\n\n\tsplit := strings.Split(apikey, \".\")\n\tif len(split) != 2 {\n\t\tlogger.SysError(\"invalid zhipu key: \" + apikey)\n\t\treturn \"\"\n\t}\n\n\tid := split[0]\n\tsecret := split[1]\n\n\texpMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6\n\texpiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second)\n\n\ttimestamp := time.Now().UnixNano() / 1e6\n\n\tpayload := jwt.MapClaims{\n\t\t\"api_key\":   id,\n\t\t\"exp\":       expMillis,\n\t\t\"timestamp\": timestamp,\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)\n\n\ttoken.Header[\"alg\"] = \"HS256\"\n\ttoken.Header[\"sign_type\"] = \"SIGN\"\n\n\ttokenString, err := token.SignedString([]byte(secret))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tzhipuTokens.Store(apikey, tokenData{\n\t\tToken:      tokenString,\n\t\tExpiryTime: expiryTime,\n\t})\n\n\treturn tokenString\n}\n\nfunc ConvertRequest(request model.GeneralOpenAIRequest) *Request {\n\tmessages := make([]Message, 0, len(request.Messages))\n\tfor _, message := range request.Messages {\n\t\tmessages = append(messages, Message{\n\t\t\tRole:    message.Role,\n\t\t\tContent: message.StringContent(),\n\t\t})\n\t}\n\treturn &Request{\n\t\tPrompt:      messages,\n\t\tTemperature: request.Temperature,\n\t\tTopP:        request.TopP,\n\t\tIncremental: false,\n\t}\n}\n\nfunc responseZhipu2OpenAI(response *Response) *openai.TextResponse {\n\tfullTextResponse := openai.TextResponse{\n\t\tId:      response.Data.TaskId,\n\t\tObject:  \"chat.completion\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tChoices: make([]openai.TextResponseChoice, 0, len(response.Data.Choices)),\n\t\tUsage:   response.Data.Usage,\n\t}\n\tfor i, choice := range response.Data.Choices {\n\t\topenaiChoice := openai.TextResponseChoice{\n\t\t\tIndex: i,\n\t\t\tMessage: model.Message{\n\t\t\t\tRole:    choice.Role,\n\t\t\t\tContent: strings.Trim(choice.Content, \"\\\"\"),\n\t\t\t},\n\t\t\tFinishReason: \"\",\n\t\t}\n\t\tif i == len(response.Data.Choices)-1 {\n\t\t\topenaiChoice.FinishReason = \"stop\"\n\t\t}\n\t\tfullTextResponse.Choices = append(fullTextResponse.Choices, openaiChoice)\n\t}\n\treturn &fullTextResponse\n}\n\nfunc streamResponseZhipu2OpenAI(zhipuResponse string) *openai.ChatCompletionsStreamResponse {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = zhipuResponse\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   \"chatglm\",\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &response\n}\n\nfunc streamMetaResponseZhipu2OpenAI(zhipuResponse *StreamMetaResponse) (*openai.ChatCompletionsStreamResponse, *model.Usage) {\n\tvar choice openai.ChatCompletionsStreamResponseChoice\n\tchoice.Delta.Content = \"\"\n\tchoice.FinishReason = &constant.StopFinishReason\n\tresponse := openai.ChatCompletionsStreamResponse{\n\t\tId:      zhipuResponse.RequestId,\n\t\tObject:  \"chat.completion.chunk\",\n\t\tCreated: helper.GetTimestamp(),\n\t\tModel:   \"chatglm\",\n\t\tChoices: []openai.ChatCompletionsStreamResponseChoice{choice},\n\t}\n\treturn &response, &zhipuResponse.Usage\n}\n\nfunc StreamHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar usage *model.Usage\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {\n\t\tif atEOF && len(data) == 0 {\n\t\t\treturn 0, nil, nil\n\t\t}\n\t\tif i := strings.Index(string(data), \"\\n\\n\"); i >= 0 && strings.Index(string(data), \":\") >= 0 {\n\t\t\treturn i + 2, data[0:i], nil\n\t\t}\n\t\tif atEOF {\n\t\t\treturn len(data), data, nil\n\t\t}\n\t\treturn 0, nil, nil\n\t})\n\n\tcommon.SetEventStreamHeaders(c)\n\n\tfor scanner.Scan() {\n\t\tdata := scanner.Text()\n\t\tlines := strings.Split(data, \"\\n\")\n\t\tfor i, line := range lines {\n\t\t\tif len(line) < 5 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.HasPrefix(line, \"data:\") {\n\t\t\t\tdataSegment := line[5:]\n\t\t\t\tif i != len(lines)-1 {\n\t\t\t\t\tdataSegment += \"\\n\"\n\t\t\t\t}\n\t\t\t\tresponse := streamResponseZhipu2OpenAI(dataSegment)\n\t\t\t\terr := render.ObjectData(c, response)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"error marshalling stream response: \" + err.Error())\n\t\t\t\t}\n\t\t\t} else if strings.HasPrefix(line, \"meta:\") {\n\t\t\t\tmetaSegment := line[5:]\n\t\t\t\tvar zhipuResponse StreamMetaResponse\n\t\t\t\terr := json.Unmarshal([]byte(metaSegment), &zhipuResponse)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"error unmarshalling stream response: \" + err.Error())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tresponse, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse)\n\t\t\t\terr = render.ObjectData(c, response)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.SysError(\"error marshalling stream response: \" + err.Error())\n\t\t\t\t}\n\t\t\t\tusage = zhipuUsage\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.SysError(\"error reading stream: \" + err.Error())\n\t}\n\n\trender.Done(c)\n\n\terr := resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\n\treturn nil, usage\n}\n\nfunc Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar zhipuResponse Response\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &zhipuResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tif !zhipuResponse.Success {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tError: model.Error{\n\t\t\t\tMessage: zhipuResponse.Msg,\n\t\t\t\tType:    \"zhipu_error\",\n\t\t\t\tParam:   \"\",\n\t\t\t\tCode:    zhipuResponse.Code,\n\t\t\t},\n\t\t\tStatusCode: resp.StatusCode,\n\t\t}, nil\n\t}\n\tfullTextResponse := responseZhipu2OpenAI(&zhipuResponse)\n\tfullTextResponse.Model = \"chatglm\"\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc EmbeddingsHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {\n\tvar zhipuResponse EmbeddingResponse\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\terr = json.Unmarshal(responseBody, &zhipuResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"unmarshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tfullTextResponse := embeddingResponseZhipu2OpenAI(&zhipuResponse)\n\tjsonResponse, err := json.Marshal(fullTextResponse)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"marshal_response_body_failed\", http.StatusInternalServerError), nil\n\t}\n\tc.Writer.Header().Set(\"Content-Type\", \"application/json\")\n\tc.Writer.WriteHeader(resp.StatusCode)\n\t_, err = c.Writer.Write(jsonResponse)\n\treturn nil, &fullTextResponse.Usage\n}\n\nfunc embeddingResponseZhipu2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {\n\topenAIEmbeddingResponse := openai.EmbeddingResponse{\n\t\tObject: \"list\",\n\t\tData:   make([]openai.EmbeddingResponseItem, 0, len(response.Embeddings)),\n\t\tModel:  response.Model,\n\t\tUsage: model.Usage{\n\t\t\tPromptTokens:     response.PromptTokens,\n\t\t\tCompletionTokens: response.CompletionTokens,\n\t\t\tTotalTokens:      response.Usage.TotalTokens,\n\t\t},\n\t}\n\n\tfor _, item := range response.Embeddings {\n\t\topenAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{\n\t\t\tObject:    `embedding`,\n\t\t\tIndex:     item.Index,\n\t\t\tEmbedding: item.Embedding,\n\t\t})\n\t}\n\treturn &openAIEmbeddingResponse\n}\n"
  },
  {
    "path": "relay/adaptor/zhipu/model.go",
    "content": "package zhipu\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"time\"\n)\n\ntype Message struct {\n\tRole    string `json:\"role\"`\n\tContent string `json:\"content\"`\n}\n\ntype Request struct {\n\tPrompt      []Message `json:\"prompt\"`\n\tTemperature *float64  `json:\"temperature,omitempty\"`\n\tTopP        *float64  `json:\"top_p,omitempty\"`\n\tRequestId   string    `json:\"request_id,omitempty\"`\n\tIncremental bool      `json:\"incremental,omitempty\"`\n}\n\ntype ResponseData struct {\n\tTaskId      string    `json:\"task_id\"`\n\tRequestId   string    `json:\"request_id\"`\n\tTaskStatus  string    `json:\"task_status\"`\n\tChoices     []Message `json:\"choices\"`\n\tmodel.Usage `json:\"usage\"`\n}\n\ntype Response struct {\n\tCode    int          `json:\"code\"`\n\tMsg     string       `json:\"msg\"`\n\tSuccess bool         `json:\"success\"`\n\tData    ResponseData `json:\"data\"`\n}\n\ntype StreamMetaResponse struct {\n\tRequestId   string `json:\"request_id\"`\n\tTaskId      string `json:\"task_id\"`\n\tTaskStatus  string `json:\"task_status\"`\n\tmodel.Usage `json:\"usage\"`\n}\n\ntype tokenData struct {\n\tToken      string\n\tExpiryTime time.Time\n}\n\ntype EmbeddingRequest struct {\n\tModel string `json:\"model\"`\n\tInput string `json:\"input\"`\n}\n\ntype EmbeddingResponse struct {\n\tModel       string          `json:\"model\"`\n\tObject      string          `json:\"object\"`\n\tEmbeddings  []EmbeddingData `json:\"data\"`\n\tmodel.Usage `json:\"usage\"`\n}\n\ntype EmbeddingData struct {\n\tIndex     int       `json:\"index\"`\n\tObject    string    `json:\"object\"`\n\tEmbedding []float64 `json:\"embedding\"`\n}\n\ntype ImageRequest struct {\n\tModel  string `json:\"model\"`\n\tPrompt string `json:\"prompt\"`\n\tUserId string `json:\"user_id,omitempty\"`\n}\n"
  },
  {
    "path": "relay/adaptor.go",
    "content": "package relay\n\nimport (\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aiproxy\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/ali\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/anthropic\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/aws\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/baidu\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/cloudflare\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/cohere\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/coze\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/deepl\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/gemini\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/ollama\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/palm\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/proxy\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/replicate\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/tencent\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/vertexai\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/xunfei\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/zhipu\"\n\t\"github.com/songquanpeng/one-api/relay/apitype\"\n)\n\nfunc GetAdaptor(apiType int) adaptor.Adaptor {\n\tswitch apiType {\n\tcase apitype.AIProxyLibrary:\n\t\treturn &aiproxy.Adaptor{}\n\tcase apitype.Ali:\n\t\treturn &ali.Adaptor{}\n\tcase apitype.Anthropic:\n\t\treturn &anthropic.Adaptor{}\n\tcase apitype.AwsClaude:\n\t\treturn &aws.Adaptor{}\n\tcase apitype.Baidu:\n\t\treturn &baidu.Adaptor{}\n\tcase apitype.Gemini:\n\t\treturn &gemini.Adaptor{}\n\tcase apitype.OpenAI:\n\t\treturn &openai.Adaptor{}\n\tcase apitype.PaLM:\n\t\treturn &palm.Adaptor{}\n\tcase apitype.Tencent:\n\t\treturn &tencent.Adaptor{}\n\tcase apitype.Xunfei:\n\t\treturn &xunfei.Adaptor{}\n\tcase apitype.Zhipu:\n\t\treturn &zhipu.Adaptor{}\n\tcase apitype.Ollama:\n\t\treturn &ollama.Adaptor{}\n\tcase apitype.Coze:\n\t\treturn &coze.Adaptor{}\n\tcase apitype.Cohere:\n\t\treturn &cohere.Adaptor{}\n\tcase apitype.Cloudflare:\n\t\treturn &cloudflare.Adaptor{}\n\tcase apitype.DeepL:\n\t\treturn &deepl.Adaptor{}\n\tcase apitype.VertexAI:\n\t\treturn &vertexai.Adaptor{}\n\tcase apitype.Proxy:\n\t\treturn &proxy.Adaptor{}\n\tcase apitype.Replicate:\n\t\treturn &replicate.Adaptor{}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "relay/adaptor_test.go",
    "content": "package relay\n\nimport (\n\t. \"github.com/smartystreets/goconvey/convey\"\n\t\"github.com/songquanpeng/one-api/relay/apitype\"\n\t\"testing\"\n)\n\nfunc TestGetAdaptor(t *testing.T) {\n\tConvey(\"get adaptor\", t, func() {\n\t\tfor i := 0; i < apitype.Dummy; i++ {\n\t\t\ta := GetAdaptor(i)\n\t\t\tSo(a, ShouldNotBeNil)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "relay/apitype/define.go",
    "content": "package apitype\n\nconst (\n\tOpenAI = iota\n\tAnthropic\n\tPaLM\n\tBaidu\n\tZhipu\n\tAli\n\tXunfei\n\tAIProxyLibrary\n\tTencent\n\tGemini\n\tOllama\n\tAwsClaude\n\tCoze\n\tCohere\n\tCloudflare\n\tDeepL\n\tVertexAI\n\tProxy\n\tReplicate\n\n\tDummy // this one is only for count, do not add any channel after this\n)\n"
  },
  {
    "path": "relay/billing/billing.go",
    "content": "package billing\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/model\"\n)\n\nfunc ReturnPreConsumedQuota(ctx context.Context, preConsumedQuota int64, tokenId int) {\n\tif preConsumedQuota != 0 {\n\t\tgo func(ctx context.Context) {\n\t\t\t// return pre-consumed quota\n\t\t\terr := model.PostConsumeTokenQuota(tokenId, -preConsumedQuota)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Error(ctx, \"error return pre-consumed quota: \"+err.Error())\n\t\t\t}\n\t\t}(ctx)\n\t}\n}\n\nfunc PostConsumeQuota(ctx context.Context, tokenId int, quotaDelta int64, totalQuota int64, userId int, channelId int, modelRatio float64, groupRatio float64, modelName string, tokenName string) {\n\t// quotaDelta is remaining quota to be consumed\n\terr := model.PostConsumeTokenQuota(tokenId, quotaDelta)\n\tif err != nil {\n\t\tlogger.SysError(\"error consuming token remain quota: \" + err.Error())\n\t}\n\terr = model.CacheUpdateUserQuota(ctx, userId)\n\tif err != nil {\n\t\tlogger.SysError(\"error update user quota cache: \" + err.Error())\n\t}\n\t// totalQuota is total quota consumed\n\tif totalQuota != 0 {\n\t\tlogContent := fmt.Sprintf(\"倍率：%.2f × %.2f\", modelRatio, groupRatio)\n\t\tmodel.RecordConsumeLog(ctx, &model.Log{\n\t\t\tUserId:           userId,\n\t\t\tChannelId:        channelId,\n\t\t\tPromptTokens:     int(totalQuota),\n\t\t\tCompletionTokens: 0,\n\t\t\tModelName:        modelName,\n\t\t\tTokenName:        tokenName,\n\t\t\tQuota:            int(totalQuota),\n\t\t\tContent:          logContent,\n\t\t})\n\t\tmodel.UpdateUserUsedQuotaAndRequestCount(userId, totalQuota)\n\t\tmodel.UpdateChannelUsedQuota(channelId, totalQuota)\n\t}\n\tif totalQuota <= 0 {\n\t\tlogger.Error(ctx, fmt.Sprintf(\"totalQuota consumed is %d, something is wrong\", totalQuota))\n\t}\n}\n"
  },
  {
    "path": "relay/billing/ratio/group.go",
    "content": "package ratio\n\nimport (\n\t\"encoding/json\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"sync\"\n)\n\nvar groupRatioLock sync.RWMutex\nvar GroupRatio = map[string]float64{\n\t\"default\": 1,\n\t\"vip\":     1,\n\t\"svip\":    1,\n}\n\nfunc GroupRatio2JSONString() string {\n\tjsonBytes, err := json.Marshal(GroupRatio)\n\tif err != nil {\n\t\tlogger.SysError(\"error marshalling model ratio: \" + err.Error())\n\t}\n\treturn string(jsonBytes)\n}\n\nfunc UpdateGroupRatioByJSONString(jsonStr string) error {\n\tgroupRatioLock.Lock()\n\tdefer groupRatioLock.Unlock()\n\tGroupRatio = make(map[string]float64)\n\treturn json.Unmarshal([]byte(jsonStr), &GroupRatio)\n}\n\nfunc GetGroupRatio(name string) float64 {\n\tgroupRatioLock.RLock()\n\tdefer groupRatioLock.RUnlock()\n\tratio, ok := GroupRatio[name]\n\tif !ok {\n\t\tlogger.SysError(\"group ratio not found: \" + name)\n\t\treturn 1\n\t}\n\treturn ratio\n}\n"
  },
  {
    "path": "relay/billing/ratio/image.go",
    "content": "package ratio\n\nvar ImageSizeRatios = map[string]map[string]float64{\n\t\"dall-e-2\": {\n\t\t\"256x256\":   1,\n\t\t\"512x512\":   1.125,\n\t\t\"1024x1024\": 1.25,\n\t},\n\t\"dall-e-3\": {\n\t\t\"1024x1024\": 1,\n\t\t\"1024x1792\": 2,\n\t\t\"1792x1024\": 2,\n\t},\n\t\"ali-stable-diffusion-xl\": {\n\t\t\"512x1024\":  1,\n\t\t\"1024x768\":  1,\n\t\t\"1024x1024\": 1,\n\t\t\"576x1024\":  1,\n\t\t\"1024x576\":  1,\n\t},\n\t\"ali-stable-diffusion-v1.5\": {\n\t\t\"512x1024\":  1,\n\t\t\"1024x768\":  1,\n\t\t\"1024x1024\": 1,\n\t\t\"576x1024\":  1,\n\t\t\"1024x576\":  1,\n\t},\n\t\"wanx-v1\": {\n\t\t\"1024x1024\": 1,\n\t\t\"720x1280\":  1,\n\t\t\"1280x720\":  1,\n\t},\n\t\"step-1x-medium\": {\n\t\t\"256x256\":   1,\n\t\t\"512x512\":   1,\n\t\t\"768x768\":   1,\n\t\t\"1024x1024\": 1,\n\t\t\"1280x800\":  1,\n\t\t\"800x1280\":  1,\n\t},\n}\n\nvar ImageGenerationAmounts = map[string][2]int{\n\t\"dall-e-2\":                  {1, 10},\n\t\"dall-e-3\":                  {1, 1}, // OpenAI allows n=1 currently.\n\t\"ali-stable-diffusion-xl\":   {1, 4}, // Ali\n\t\"ali-stable-diffusion-v1.5\": {1, 4}, // Ali\n\t\"wanx-v1\":                   {1, 4}, // Ali\n\t\"cogview-3\":                 {1, 1},\n\t\"step-1x-medium\":            {1, 1},\n}\n\nvar ImagePromptLengthLimitations = map[string]int{\n\t\"dall-e-2\":                  1000,\n\t\"dall-e-3\":                  4000,\n\t\"ali-stable-diffusion-xl\":   4000,\n\t\"ali-stable-diffusion-v1.5\": 4000,\n\t\"wanx-v1\":                   4000,\n\t\"cogview-3\":                 833,\n\t\"step-1x-medium\":            4000,\n}\n\nvar ImageOriginModelName = map[string]string{\n\t\"ali-stable-diffusion-xl\":   \"stable-diffusion-xl\",\n\t\"ali-stable-diffusion-v1.5\": \"stable-diffusion-v1.5\",\n}\n"
  },
  {
    "path": "relay/billing/ratio/model.go",
    "content": "package ratio\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/songquanpeng/one-api/common/logger\"\n)\n\nconst (\n\tUSD2RMB   = 7\n\tUSD       = 500 // $0.002 = 1 -> $1 = 500\n\tMILLI_USD = 1.0 / 1000 * USD\n\tRMB       = USD / USD2RMB\n)\n\nvar modelRatioLock sync.RWMutex\n\n// ModelRatio\n// https://platform.openai.com/docs/models/model-endpoint-compatibility\n// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf\n// https://openai.com/pricing\n// 1 === $0.002 / 1K tokens\n// 1 === ￥0.014 / 1k tokens\nvar ModelRatio = map[string]float64{\n\t// https://openai.com/pricing\n\t\"gpt-4\":                   15,\n\t\"gpt-4-0314\":              15,\n\t\"gpt-4-0613\":              15,\n\t\"gpt-4-32k\":               30,\n\t\"gpt-4-32k-0314\":          30,\n\t\"gpt-4-32k-0613\":          30,\n\t\"gpt-4-1106-preview\":      5,     // $0.01 / 1K tokens\n\t\"gpt-4-0125-preview\":      5,     // $0.01 / 1K tokens\n\t\"gpt-4-turbo-preview\":     5,     // $0.01 / 1K tokens\n\t\"gpt-4-turbo\":             5,     // $0.01 / 1K tokens\n\t\"gpt-4-turbo-2024-04-09\":  5,     // $0.01 / 1K tokens\n\t\"gpt-4o\":                  2.5,   // $0.005 / 1K tokens\n\t\"chatgpt-4o-latest\":       2.5,   // $0.005 / 1K tokens\n\t\"gpt-4o-2024-05-13\":       2.5,   // $0.005 / 1K tokens\n\t\"gpt-4o-2024-08-06\":       1.25,  // $0.0025 / 1K tokens\n\t\"gpt-4o-2024-11-20\":       1.25,  // $0.0025 / 1K tokens\n\t\"gpt-4o-mini\":             0.075, // $0.00015 / 1K tokens\n\t\"gpt-4o-mini-2024-07-18\":  0.075, // $0.00015 / 1K tokens\n\t\"gpt-4-vision-preview\":    5,     // $0.01 / 1K tokens\n\t\"gpt-3.5-turbo\":           0.25,  // $0.0005 / 1K tokens\n\t\"gpt-3.5-turbo-0301\":      0.75,\n\t\"gpt-3.5-turbo-0613\":      0.75,\n\t\"gpt-3.5-turbo-16k\":       1.5, // $0.003 / 1K tokens\n\t\"gpt-3.5-turbo-16k-0613\":  1.5,\n\t\"gpt-3.5-turbo-instruct\":  0.75, // $0.0015 / 1K tokens\n\t\"gpt-3.5-turbo-1106\":      0.5,  // $0.001 / 1K tokens\n\t\"gpt-3.5-turbo-0125\":      0.25, // $0.0005 / 1K tokens\n\t\"o1\":                      7.5,  // $15.00 / 1M input tokens\n\t\"o1-2024-12-17\":           7.5,\n\t\"o1-preview\":              7.5, // $15.00 / 1M input tokens\n\t\"o1-preview-2024-09-12\":   7.5,\n\t\"o1-mini\":                 1.5, // $3.00 / 1M input tokens\n\t\"o1-mini-2024-09-12\":      1.5,\n\t\"o3-mini\":                 1.5, // $3.00 / 1M input tokens\n\t\"o3-mini-2025-01-31\":      1.5,\n\t\"davinci-002\":             1,   // $0.002 / 1K tokens\n\t\"babbage-002\":             0.2, // $0.0004 / 1K tokens\n\t\"text-ada-001\":            0.2,\n\t\"text-babbage-001\":        0.25,\n\t\"text-curie-001\":          1,\n\t\"text-davinci-002\":        10,\n\t\"text-davinci-003\":        10,\n\t\"text-davinci-edit-001\":   10,\n\t\"code-davinci-edit-001\":   10,\n\t\"whisper-1\":               15,  // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens\n\t\"tts-1\":                   7.5, // $0.015 / 1K characters\n\t\"tts-1-1106\":              7.5,\n\t\"tts-1-hd\":                15, // $0.030 / 1K characters\n\t\"tts-1-hd-1106\":           15,\n\t\"davinci\":                 10,\n\t\"curie\":                   10,\n\t\"babbage\":                 10,\n\t\"ada\":                     10,\n\t\"text-embedding-ada-002\":  0.05,\n\t\"text-embedding-3-small\":  0.01,\n\t\"text-embedding-3-large\":  0.065,\n\t\"text-search-ada-doc-001\": 10,\n\t\"text-moderation-stable\":  0.1,\n\t\"text-moderation-latest\":  0.1,\n\t\"dall-e-2\":                0.02 * USD, // $0.016 - $0.020 / image\n\t\"dall-e-3\":                0.04 * USD, // $0.040 - $0.120 / image\n\t// https://docs.anthropic.com/en/docs/about-claude/models\n\t\"claude-instant-1.2\":         0.8 / 1000 * USD,\n\t\"claude-2.0\":                 8.0 / 1000 * USD,\n\t\"claude-2.1\":                 8.0 / 1000 * USD,\n\t\"claude-3-haiku-20240307\":    0.25 / 1000 * USD,\n\t\"claude-3-5-haiku-20241022\":  1.0 / 1000 * USD,\n\t\"claude-3-5-haiku-latest\":    1.0 / 1000 * USD,\n\t\"claude-3-sonnet-20240229\":   3.0 / 1000 * USD,\n\t\"claude-3-5-sonnet-20240620\": 3.0 / 1000 * USD,\n\t\"claude-3-5-sonnet-20241022\": 3.0 / 1000 * USD,\n\t\"claude-3-5-sonnet-latest\":   3.0 / 1000 * USD,\n\t\"claude-3-opus-20240229\":     15.0 / 1000 * USD,\n\t// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/hlrk4akp7\n\t\"ERNIE-4.0-8K\":       0.120 * RMB,\n\t\"ERNIE-3.5-8K\":       0.012 * RMB,\n\t\"ERNIE-3.5-8K-0205\":  0.024 * RMB,\n\t\"ERNIE-3.5-8K-1222\":  0.012 * RMB,\n\t\"ERNIE-Bot-8K\":       0.024 * RMB,\n\t\"ERNIE-3.5-4K-0205\":  0.012 * RMB,\n\t\"ERNIE-Speed-8K\":     0.004 * RMB,\n\t\"ERNIE-Speed-128K\":   0.004 * RMB,\n\t\"ERNIE-Lite-8K-0922\": 0.008 * RMB,\n\t\"ERNIE-Lite-8K-0308\": 0.003 * RMB,\n\t\"ERNIE-Tiny-8K\":      0.001 * RMB,\n\t\"BLOOMZ-7B\":          0.004 * RMB,\n\t\"Embedding-V1\":       0.002 * RMB,\n\t\"bge-large-zh\":       0.002 * RMB,\n\t\"bge-large-en\":       0.002 * RMB,\n\t\"tao-8k\":             0.002 * RMB,\n\t// https://ai.google.dev/pricing\n\t// https://cloud.google.com/vertex-ai/generative-ai/pricing\n\t// \"gemma-2-2b-it\":                       0,\n\t// \"gemma-2-9b-it\":                       0,\n\t// \"gemma-2-27b-it\":                      0,\n\t\"gemini-pro\":                          0.25 * MILLI_USD, // $0.00025 / 1k characters -> $0.001 / 1k tokens\n\t\"gemini-1.0-pro\":                      0.125 * MILLI_USD,\n\t\"gemini-1.5-pro\":                      1.25 * MILLI_USD,\n\t\"gemini-1.5-pro-001\":                  1.25 * MILLI_USD,\n\t\"gemini-1.5-pro-experimental\":         1.25 * MILLI_USD,\n\t\"gemini-1.5-flash\":                    0.075 * MILLI_USD,\n\t\"gemini-1.5-flash-001\":                0.075 * MILLI_USD,\n\t\"gemini-1.5-flash-8b\":                 0.0375 * MILLI_USD,\n\t\"gemini-2.0-flash-exp\":                0.075 * MILLI_USD,\n\t\"gemini-2.0-flash\":                    0.15 * MILLI_USD,\n\t\"gemini-2.0-flash-001\":                0.15 * MILLI_USD,\n\t\"gemini-2.0-flash-lite-preview-02-05\": 0.075 * MILLI_USD,\n\t\"gemini-2.0-flash-thinking-exp-01-21\": 0.075 * MILLI_USD,\n\t\"gemini-2.0-pro-exp-02-05\":            1.25 * MILLI_USD,\n\t\"aqa\":                                 1,\n\t// https://open.bigmodel.cn/pricing\n\t\"glm-zero-preview\": 0.01 * RMB,\n\t\"glm-4-plus\":       0.05 * RMB,\n\t\"glm-4-0520\":       0.1 * RMB,\n\t\"glm-4-airx\":       0.01 * RMB,\n\t\"glm-4-air\":        0.0005 * RMB,\n\t\"glm-4-long\":       0.001 * RMB,\n\t\"glm-4-flashx\":     0.0001 * RMB,\n\t\"glm-4-flash\":      0,\n\t\"glm-4\":            0.1 * RMB,   // deprecated model, available until 2025/06\n\t\"glm-3-turbo\":      0.001 * RMB, // deprecated model, available until 2025/06\n\t\"glm-4v-plus\":      0.004 * RMB,\n\t\"glm-4v\":           0.05 * RMB,\n\t\"glm-4v-flash\":     0,\n\t\"cogview-3-plus\":   0.06 * RMB,\n\t\"cogview-3\":        0.1 * RMB,\n\t\"cogview-3-flash\":  0,\n\t\"cogviewx\":         0.5 * RMB,\n\t\"cogviewx-flash\":   0,\n\t\"charglm-4\":        0.001 * RMB,\n\t\"emohaa\":           0.015 * RMB,\n\t\"codegeex-4\":       0.0001 * RMB,\n\t\"embedding-2\":      0.0005 * RMB,\n\t\"embedding-3\":      0.0005 * RMB,\n\t// https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-thousand-questions-metering-and-billing\n\t\"qwen-turbo\":                    0.0003 * RMB,\n\t\"qwen-turbo-latest\":             0.0003 * RMB,\n\t\"qwen-plus\":                     0.0008 * RMB,\n\t\"qwen-plus-latest\":              0.0008 * RMB,\n\t\"qwen-max\":                      0.0024 * RMB,\n\t\"qwen-max-latest\":               0.0024 * RMB,\n\t\"qwen-max-longcontext\":          0.0005 * RMB,\n\t\"qwen-vl-max\":                   0.003 * RMB,\n\t\"qwen-vl-max-latest\":            0.003 * RMB,\n\t\"qwen-vl-plus\":                  0.0015 * RMB,\n\t\"qwen-vl-plus-latest\":           0.0015 * RMB,\n\t\"qwen-vl-ocr\":                   0.005 * RMB,\n\t\"qwen-vl-ocr-latest\":            0.005 * RMB,\n\t\"qwen-audio-turbo\":              1.4286,\n\t\"qwen-math-plus\":                0.004 * RMB,\n\t\"qwen-math-plus-latest\":         0.004 * RMB,\n\t\"qwen-math-turbo\":               0.002 * RMB,\n\t\"qwen-math-turbo-latest\":        0.002 * RMB,\n\t\"qwen-coder-plus\":               0.0035 * RMB,\n\t\"qwen-coder-plus-latest\":        0.0035 * RMB,\n\t\"qwen-coder-turbo\":              0.002 * RMB,\n\t\"qwen-coder-turbo-latest\":       0.002 * RMB,\n\t\"qwen-mt-plus\":                  0.015 * RMB,\n\t\"qwen-mt-turbo\":                 0.001 * RMB,\n\t\"qwq-32b-preview\":               0.002 * RMB,\n\t\"qwen2.5-72b-instruct\":          0.004 * RMB,\n\t\"qwen2.5-32b-instruct\":          0.03 * RMB,\n\t\"qwen2.5-14b-instruct\":          0.001 * RMB,\n\t\"qwen2.5-7b-instruct\":           0.0005 * RMB,\n\t\"qwen2.5-3b-instruct\":           0.006 * RMB,\n\t\"qwen2.5-1.5b-instruct\":         0.0003 * RMB,\n\t\"qwen2.5-0.5b-instruct\":         0.0003 * RMB,\n\t\"qwen2-72b-instruct\":            0.004 * RMB,\n\t\"qwen2-57b-a14b-instruct\":       0.0035 * RMB,\n\t\"qwen2-7b-instruct\":             0.001 * RMB,\n\t\"qwen2-1.5b-instruct\":           0.001 * RMB,\n\t\"qwen2-0.5b-instruct\":           0.001 * RMB,\n\t\"qwen1.5-110b-chat\":             0.007 * RMB,\n\t\"qwen1.5-72b-chat\":              0.005 * RMB,\n\t\"qwen1.5-32b-chat\":              0.0035 * RMB,\n\t\"qwen1.5-14b-chat\":              0.002 * RMB,\n\t\"qwen1.5-7b-chat\":               0.001 * RMB,\n\t\"qwen1.5-1.8b-chat\":             0.001 * RMB,\n\t\"qwen1.5-0.5b-chat\":             0.001 * RMB,\n\t\"qwen-72b-chat\":                 0.02 * RMB,\n\t\"qwen-14b-chat\":                 0.008 * RMB,\n\t\"qwen-7b-chat\":                  0.006 * RMB,\n\t\"qwen-1.8b-chat\":                0.006 * RMB,\n\t\"qwen-1.8b-longcontext-chat\":    0.006 * RMB,\n\t\"qvq-72b-preview\":               0.012 * RMB,\n\t\"qwen2.5-vl-72b-instruct\":       0.016 * RMB,\n\t\"qwen2.5-vl-7b-instruct\":        0.002 * RMB,\n\t\"qwen2.5-vl-3b-instruct\":        0.0012 * RMB,\n\t\"qwen2-vl-7b-instruct\":          0.016 * RMB,\n\t\"qwen2-vl-2b-instruct\":          0.002 * RMB,\n\t\"qwen-vl-v1\":                    0.002 * RMB,\n\t\"qwen-vl-chat-v1\":               0.002 * RMB,\n\t\"qwen2-audio-instruct\":          0.002 * RMB,\n\t\"qwen-audio-chat\":               0.002 * RMB,\n\t\"qwen2.5-math-72b-instruct\":     0.004 * RMB,\n\t\"qwen2.5-math-7b-instruct\":      0.001 * RMB,\n\t\"qwen2.5-math-1.5b-instruct\":    0.001 * RMB,\n\t\"qwen2-math-72b-instruct\":       0.004 * RMB,\n\t\"qwen2-math-7b-instruct\":        0.001 * RMB,\n\t\"qwen2-math-1.5b-instruct\":      0.001 * RMB,\n\t\"qwen2.5-coder-32b-instruct\":    0.002 * RMB,\n\t\"qwen2.5-coder-14b-instruct\":    0.002 * RMB,\n\t\"qwen2.5-coder-7b-instruct\":     0.001 * RMB,\n\t\"qwen2.5-coder-3b-instruct\":     0.001 * RMB,\n\t\"qwen2.5-coder-1.5b-instruct\":   0.001 * RMB,\n\t\"qwen2.5-coder-0.5b-instruct\":   0.001 * RMB,\n\t\"text-embedding-v1\":             0.0007 * RMB, // ￥0.0007 / 1k tokens\n\t\"text-embedding-v3\":             0.0007 * RMB,\n\t\"text-embedding-v2\":             0.0007 * RMB,\n\t\"text-embedding-async-v2\":       0.0007 * RMB,\n\t\"text-embedding-async-v1\":       0.0007 * RMB,\n\t\"ali-stable-diffusion-xl\":       8.00,\n\t\"ali-stable-diffusion-v1.5\":     8.00,\n\t\"wanx-v1\":                       8.00,\n\t\"deepseek-r1\":                   0.002 * RMB,\n\t\"deepseek-v3\":                   0.001 * RMB,\n\t\"deepseek-r1-distill-qwen-1.5b\": 0.001 * RMB,\n\t\"deepseek-r1-distill-qwen-7b\":   0.0005 * RMB,\n\t\"deepseek-r1-distill-qwen-14b\":  0.001 * RMB,\n\t\"deepseek-r1-distill-qwen-32b\":  0.002 * RMB,\n\t\"deepseek-r1-distill-llama-8b\":  0.0005 * RMB,\n\t\"deepseek-r1-distill-llama-70b\": 0.004 * RMB,\n\t\"SparkDesk\":                     1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v1.1\":                1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v2.1\":                1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v3.1\":                1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v3.1-128K\":           1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v3.5\":                1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v3.5-32K\":            1.2858, // ￥0.018 / 1k tokens\n\t\"SparkDesk-v4.0\":                1.2858, // ￥0.018 / 1k tokens\n\t\"360GPT_S2_V9\":                  0.8572, // ¥0.012 / 1k tokens\n\t\"embedding-bert-512-v1\":         0.0715, // ¥0.001 / 1k tokens\n\t\"embedding_s1_v1\":               0.0715, // ¥0.001 / 1k tokens\n\t\"semantic_similarity_s1_v1\":     0.0715, // ¥0.001 / 1k tokens\n\t// https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0\n\t\"hunyuan-turbo\":             0.015 * RMB,\n\t\"hunyuan-large\":             0.004 * RMB,\n\t\"hunyuan-large-longcontext\": 0.006 * RMB,\n\t\"hunyuan-standard\":          0.0008 * RMB,\n\t\"hunyuan-standard-256K\":     0.0005 * RMB,\n\t\"hunyuan-translation-lite\":  0.005 * RMB,\n\t\"hunyuan-role\":              0.004 * RMB,\n\t\"hunyuan-functioncall\":      0.004 * RMB,\n\t\"hunyuan-code\":              0.004 * RMB,\n\t\"hunyuan-turbo-vision\":      0.08 * RMB,\n\t\"hunyuan-vision\":            0.018 * RMB,\n\t\"hunyuan-embedding\":         0.0007 * RMB,\n\t// https://platform.moonshot.cn/pricing\n\t\"moonshot-v1-8k\":   0.012 * RMB,\n\t\"moonshot-v1-32k\":  0.024 * RMB,\n\t\"moonshot-v1-128k\": 0.06 * RMB,\n\t// https://platform.baichuan-ai.com/price\n\t\"Baichuan2-Turbo\":      0.008 * RMB,\n\t\"Baichuan2-Turbo-192k\": 0.016 * RMB,\n\t\"Baichuan2-53B\":        0.02 * RMB,\n\t// https://api.minimax.chat/document/price\n\t\"abab6.5-chat\":  0.03 * RMB,\n\t\"abab6.5s-chat\": 0.01 * RMB,\n\t\"abab6-chat\":    0.1 * RMB,\n\t\"abab5.5-chat\":  0.015 * RMB,\n\t\"abab5.5s-chat\": 0.005 * RMB,\n\t// https://docs.mistral.ai/platform/pricing/\n\t\"open-mistral-7b\":       0.25 / 1000 * USD,\n\t\"open-mixtral-8x7b\":     0.7 / 1000 * USD,\n\t\"mistral-small-latest\":  2.0 / 1000 * USD,\n\t\"mistral-medium-latest\": 2.7 / 1000 * USD,\n\t\"mistral-large-latest\":  8.0 / 1000 * USD,\n\t\"mistral-embed\":         0.1 / 1000 * USD,\n\t// https://wow.groq.com/#:~:text=inquiries%C2%A0here.-,Model,-Current%20Speed\n\t\"gemma-7b-it\":                           0.07 / 1000000 * USD,\n\t\"gemma2-9b-it\":                          0.20 / 1000000 * USD,\n\t\"llama-3.1-70b-versatile\":               0.59 / 1000000 * USD,\n\t\"llama-3.1-8b-instant\":                  0.05 / 1000000 * USD,\n\t\"llama-3.2-11b-text-preview\":            0.05 / 1000000 * USD,\n\t\"llama-3.2-11b-vision-preview\":          0.05 / 1000000 * USD,\n\t\"llama-3.2-1b-preview\":                  0.05 / 1000000 * USD,\n\t\"llama-3.2-3b-preview\":                  0.05 / 1000000 * USD,\n\t\"llama-3.2-90b-text-preview\":            0.59 / 1000000 * USD,\n\t\"llama-guard-3-8b\":                      0.05 / 1000000 * USD,\n\t\"llama3-70b-8192\":                       0.59 / 1000000 * USD,\n\t\"llama3-8b-8192\":                        0.05 / 1000000 * USD,\n\t\"llama3-groq-70b-8192-tool-use-preview\": 0.89 / 1000000 * USD,\n\t\"llama3-groq-8b-8192-tool-use-preview\":  0.19 / 1000000 * USD,\n\t\"mixtral-8x7b-32768\":                    0.24 / 1000000 * USD,\n\n\t// https://platform.lingyiwanwu.com/docs#-计费单元\n\t\"yi-34b-chat-0205\": 2.5 / 1000 * RMB,\n\t\"yi-34b-chat-200k\": 12.0 / 1000 * RMB,\n\t\"yi-vl-plus\":       6.0 / 1000 * RMB,\n\t// https://platform.stepfun.com/docs/pricing/details\n\t\"step-1-8k\":    0.005 / 1000 * RMB,\n\t\"step-1-32k\":   0.015 / 1000 * RMB,\n\t\"step-1-128k\":  0.040 / 1000 * RMB,\n\t\"step-1-256k\":  0.095 / 1000 * RMB,\n\t\"step-1-flash\": 0.001 / 1000 * RMB,\n\t\"step-2-16k\":   0.038 / 1000 * RMB,\n\t\"step-1v-8k\":   0.005 / 1000 * RMB,\n\t\"step-1v-32k\":  0.015 / 1000 * RMB,\n\t// aws llama3 https://aws.amazon.com/cn/bedrock/pricing/\n\t\"llama3-8b-8192(33)\":  0.0003 / 0.002,  // $0.0003 / 1K tokens\n\t\"llama3-70b-8192(33)\": 0.00265 / 0.002, // $0.00265 / 1K tokens\n\t// https://cohere.com/pricing\n\t\"command\":               0.5,\n\t\"command-nightly\":       0.5,\n\t\"command-light\":         0.5,\n\t\"command-light-nightly\": 0.5,\n\t\"command-r\":             0.5 / 1000 * USD,\n\t\"command-r-plus\":        3.0 / 1000 * USD,\n\t// https://platform.deepseek.com/api-docs/pricing/\n\t\"deepseek-chat\":     0.14 * MILLI_USD,\n\t\"deepseek-reasoner\": 0.55 * MILLI_USD,\n\t// https://www.deepl.com/pro?cta=header-prices\n\t\"deepl-zh\": 25.0 / 1000 * USD,\n\t\"deepl-en\": 25.0 / 1000 * USD,\n\t\"deepl-ja\": 25.0 / 1000 * USD,\n\t// https://console.x.ai/\n\t\"grok-beta\": 5.0 / 1000 * USD,\n\t// replicate charges based on the number of generated images\n\t// https://replicate.com/pricing\n\t\"black-forest-labs/flux-1.1-pro\":                0.04 * USD,\n\t\"black-forest-labs/flux-1.1-pro-ultra\":          0.06 * USD,\n\t\"black-forest-labs/flux-canny-dev\":              0.025 * USD,\n\t\"black-forest-labs/flux-canny-pro\":              0.05 * USD,\n\t\"black-forest-labs/flux-depth-dev\":              0.025 * USD,\n\t\"black-forest-labs/flux-depth-pro\":              0.05 * USD,\n\t\"black-forest-labs/flux-dev\":                    0.025 * USD,\n\t\"black-forest-labs/flux-dev-lora\":               0.032 * USD,\n\t\"black-forest-labs/flux-fill-dev\":               0.04 * USD,\n\t\"black-forest-labs/flux-fill-pro\":               0.05 * USD,\n\t\"black-forest-labs/flux-pro\":                    0.055 * USD,\n\t\"black-forest-labs/flux-redux-dev\":              0.025 * USD,\n\t\"black-forest-labs/flux-redux-schnell\":          0.003 * USD,\n\t\"black-forest-labs/flux-schnell\":                0.003 * USD,\n\t\"black-forest-labs/flux-schnell-lora\":           0.02 * USD,\n\t\"ideogram-ai/ideogram-v2\":                       0.08 * USD,\n\t\"ideogram-ai/ideogram-v2-turbo\":                 0.05 * USD,\n\t\"recraft-ai/recraft-v3\":                         0.04 * USD,\n\t\"recraft-ai/recraft-v3-svg\":                     0.08 * USD,\n\t\"stability-ai/stable-diffusion-3\":               0.035 * USD,\n\t\"stability-ai/stable-diffusion-3.5-large\":       0.065 * USD,\n\t\"stability-ai/stable-diffusion-3.5-large-turbo\": 0.04 * USD,\n\t\"stability-ai/stable-diffusion-3.5-medium\":      0.035 * USD,\n\t// replicate chat models\n\t\"ibm-granite/granite-20b-code-instruct-8k\":  0.100 * USD,\n\t\"ibm-granite/granite-3.0-2b-instruct\":       0.030 * USD,\n\t\"ibm-granite/granite-3.0-8b-instruct\":       0.050 * USD,\n\t\"ibm-granite/granite-8b-code-instruct-128k\": 0.050 * USD,\n\t\"meta/llama-2-13b\":                          0.100 * USD,\n\t\"meta/llama-2-13b-chat\":                     0.100 * USD,\n\t\"meta/llama-2-70b\":                          0.650 * USD,\n\t\"meta/llama-2-70b-chat\":                     0.650 * USD,\n\t\"meta/llama-2-7b\":                           0.050 * USD,\n\t\"meta/llama-2-7b-chat\":                      0.050 * USD,\n\t\"meta/meta-llama-3.1-405b-instruct\":         9.500 * USD,\n\t\"meta/meta-llama-3-70b\":                     0.650 * USD,\n\t\"meta/meta-llama-3-70b-instruct\":            0.650 * USD,\n\t\"meta/meta-llama-3-8b\":                      0.050 * USD,\n\t\"meta/meta-llama-3-8b-instruct\":             0.050 * USD,\n\t\"mistralai/mistral-7b-instruct-v0.2\":        0.050 * USD,\n\t\"mistralai/mistral-7b-v0.1\":                 0.050 * USD,\n\t\"mistralai/mixtral-8x7b-instruct-v0.1\":      0.300 * USD,\n\t//https://openrouter.ai/models\n\t\"01-ai/yi-large\":                                  1.5,\n\t\"aetherwiing/mn-starcannon-12b\":                   0.6,\n\t\"ai21/jamba-1-5-large\":                            4.0,\n\t\"ai21/jamba-1-5-mini\":                             0.2,\n\t\"ai21/jamba-instruct\":                             0.35,\n\t\"aion-labs/aion-1.0\":                              6.0,\n\t\"aion-labs/aion-1.0-mini\":                         1.2,\n\t\"aion-labs/aion-rp-llama-3.1-8b\":                  0.1,\n\t\"allenai/llama-3.1-tulu-3-405b\":                   5.0,\n\t\"alpindale/goliath-120b\":                          4.6875,\n\t\"alpindale/magnum-72b\":                            1.125,\n\t\"amazon/nova-lite-v1\":                             0.12,\n\t\"amazon/nova-micro-v1\":                            0.07,\n\t\"amazon/nova-pro-v1\":                              1.6,\n\t\"anthracite-org/magnum-v2-72b\":                    1.5,\n\t\"anthracite-org/magnum-v4-72b\":                    1.125,\n\t\"anthropic/claude-2\":                              12.0,\n\t\"anthropic/claude-2.0\":                            12.0,\n\t\"anthropic/claude-2.0:beta\":                       12.0,\n\t\"anthropic/claude-2.1\":                            12.0,\n\t\"anthropic/claude-2.1:beta\":                       12.0,\n\t\"anthropic/claude-2:beta\":                         12.0,\n\t\"anthropic/claude-3-haiku\":                        0.625,\n\t\"anthropic/claude-3-haiku:beta\":                   0.625,\n\t\"anthropic/claude-3-opus\":                         37.5,\n\t\"anthropic/claude-3-opus:beta\":                    37.5,\n\t\"anthropic/claude-3-sonnet\":                       7.5,\n\t\"anthropic/claude-3-sonnet:beta\":                  7.5,\n\t\"anthropic/claude-3.5-haiku\":                      2.0,\n\t\"anthropic/claude-3.5-haiku-20241022\":             2.0,\n\t\"anthropic/claude-3.5-haiku-20241022:beta\":        2.0,\n\t\"anthropic/claude-3.5-haiku:beta\":                 2.0,\n\t\"anthropic/claude-3.5-sonnet\":                     7.5,\n\t\"anthropic/claude-3.5-sonnet-20240620\":            7.5,\n\t\"anthropic/claude-3.5-sonnet-20240620:beta\":       7.5,\n\t\"anthropic/claude-3.5-sonnet:beta\":                7.5,\n\t\"cognitivecomputations/dolphin-mixtral-8x22b\":     0.45,\n\t\"cognitivecomputations/dolphin-mixtral-8x7b\":      0.25,\n\t\"cohere/command\":                                  0.95,\n\t\"cohere/command-r\":                                0.7125,\n\t\"cohere/command-r-03-2024\":                        0.7125,\n\t\"cohere/command-r-08-2024\":                        0.285,\n\t\"cohere/command-r-plus\":                           7.125,\n\t\"cohere/command-r-plus-04-2024\":                   7.125,\n\t\"cohere/command-r-plus-08-2024\":                   4.75,\n\t\"cohere/command-r7b-12-2024\":                      0.075,\n\t\"databricks/dbrx-instruct\":                        0.6,\n\t\"deepseek/deepseek-chat\":                          0.445,\n\t\"deepseek/deepseek-chat-v2.5\":                     1.0,\n\t\"deepseek/deepseek-chat:free\":                     0.0,\n\t\"deepseek/deepseek-r1\":                            1.2,\n\t\"deepseek/deepseek-r1-distill-llama-70b\":          0.345,\n\t\"deepseek/deepseek-r1-distill-llama-70b:free\":     0.0,\n\t\"deepseek/deepseek-r1-distill-llama-8b\":           0.02,\n\t\"deepseek/deepseek-r1-distill-qwen-1.5b\":          0.09,\n\t\"deepseek/deepseek-r1-distill-qwen-14b\":           0.075,\n\t\"deepseek/deepseek-r1-distill-qwen-32b\":           0.09,\n\t\"deepseek/deepseek-r1:free\":                       0.0,\n\t\"eva-unit-01/eva-llama-3.33-70b\":                  3.0,\n\t\"eva-unit-01/eva-qwen-2.5-32b\":                    1.7,\n\t\"eva-unit-01/eva-qwen-2.5-72b\":                    3.0,\n\t\"google/gemini-2.0-flash-001\":                     0.2,\n\t\"google/gemini-2.0-flash-exp:free\":                0.0,\n\t\"google/gemini-2.0-flash-lite-preview-02-05:free\": 0.0,\n\t\"google/gemini-2.0-flash-thinking-exp-1219:free\":  0.0,\n\t\"google/gemini-2.0-flash-thinking-exp:free\":       0.0,\n\t\"google/gemini-2.0-pro-exp-02-05:free\":            0.0,\n\t\"google/gemini-exp-1206:free\":                     0.0,\n\t\"google/gemini-flash-1.5\":                         0.15,\n\t\"google/gemini-flash-1.5-8b\":                      0.075,\n\t\"google/gemini-flash-1.5-8b-exp\":                  0.0,\n\t\"google/gemini-pro\":                               0.75,\n\t\"google/gemini-pro-1.5\":                           2.5,\n\t\"google/gemini-pro-vision\":                        0.75,\n\t\"google/gemma-2-27b-it\":                           0.135,\n\t\"google/gemma-2-9b-it\":                            0.03,\n\t\"google/gemma-2-9b-it:free\":                       0.0,\n\t\"google/gemma-7b-it\":                              0.075,\n\t\"google/learnlm-1.5-pro-experimental:free\":        0.0,\n\t\"google/palm-2-chat-bison\":                        1.0,\n\t\"google/palm-2-chat-bison-32k\":                    1.0,\n\t\"google/palm-2-codechat-bison\":                    1.0,\n\t\"google/palm-2-codechat-bison-32k\":                1.0,\n\t\"gryphe/mythomax-l2-13b\":                          0.0325,\n\t\"gryphe/mythomax-l2-13b:free\":                     0.0,\n\t\"huggingfaceh4/zephyr-7b-beta:free\":               0.0,\n\t\"infermatic/mn-inferor-12b\":                       0.6,\n\t\"inflection/inflection-3-pi\":                      5.0,\n\t\"inflection/inflection-3-productivity\":            5.0,\n\t\"jondurbin/airoboros-l2-70b\":                      0.25,\n\t\"liquid/lfm-3b\":                                   0.01,\n\t\"liquid/lfm-40b\":                                  0.075,\n\t\"liquid/lfm-7b\":                                   0.005,\n\t\"mancer/weaver\":                                   1.125,\n\t\"meta-llama/llama-2-13b-chat\":                     0.11,\n\t\"meta-llama/llama-2-70b-chat\":                     0.45,\n\t\"meta-llama/llama-3-70b-instruct\":                 0.2,\n\t\"meta-llama/llama-3-8b-instruct\":                  0.03,\n\t\"meta-llama/llama-3-8b-instruct:free\":             0.0,\n\t\"meta-llama/llama-3.1-405b\":                       1.0,\n\t\"meta-llama/llama-3.1-405b-instruct\":              0.4,\n\t\"meta-llama/llama-3.1-70b-instruct\":               0.15,\n\t\"meta-llama/llama-3.1-8b-instruct\":                0.025,\n\t\"meta-llama/llama-3.2-11b-vision-instruct\":        0.0275,\n\t\"meta-llama/llama-3.2-11b-vision-instruct:free\":   0.0,\n\t\"meta-llama/llama-3.2-1b-instruct\":                0.005,\n\t\"meta-llama/llama-3.2-3b-instruct\":                0.0125,\n\t\"meta-llama/llama-3.2-90b-vision-instruct\":        0.8,\n\t\"meta-llama/llama-3.3-70b-instruct\":               0.15,\n\t\"meta-llama/llama-3.3-70b-instruct:free\":          0.0,\n\t\"meta-llama/llama-guard-2-8b\":                     0.1,\n\t\"microsoft/phi-3-medium-128k-instruct\":            0.5,\n\t\"microsoft/phi-3-medium-128k-instruct:free\":       0.0,\n\t\"microsoft/phi-3-mini-128k-instruct\":              0.05,\n\t\"microsoft/phi-3-mini-128k-instruct:free\":         0.0,\n\t\"microsoft/phi-3.5-mini-128k-instruct\":            0.05,\n\t\"microsoft/phi-4\":                                 0.07,\n\t\"microsoft/wizardlm-2-7b\":                         0.035,\n\t\"microsoft/wizardlm-2-8x22b\":                      0.25,\n\t\"minimax/minimax-01\":                              0.55,\n\t\"mistralai/codestral-2501\":                        0.45,\n\t\"mistralai/codestral-mamba\":                       0.125,\n\t\"mistralai/ministral-3b\":                          0.02,\n\t\"mistralai/ministral-8b\":                          0.05,\n\t\"mistralai/mistral-7b-instruct\":                   0.0275,\n\t\"mistralai/mistral-7b-instruct-v0.1\":              0.1,\n\t\"mistralai/mistral-7b-instruct-v0.3\":              0.0275,\n\t\"mistralai/mistral-7b-instruct:free\":              0.0,\n\t\"mistralai/mistral-large\":                         3.0,\n\t\"mistralai/mistral-large-2407\":                    3.0,\n\t\"mistralai/mistral-large-2411\":                    3.0,\n\t\"mistralai/mistral-medium\":                        4.05,\n\t\"mistralai/mistral-nemo\":                          0.04,\n\t\"mistralai/mistral-nemo:free\":                     0.0,\n\t\"mistralai/mistral-small\":                         0.3,\n\t\"mistralai/mistral-small-24b-instruct-2501\":       0.07,\n\t\"mistralai/mistral-small-24b-instruct-2501:free\":  0.0,\n\t\"mistralai/mistral-tiny\":                          0.125,\n\t\"mistralai/mixtral-8x22b-instruct\":                0.45,\n\t\"mistralai/mixtral-8x7b\":                          0.3,\n\t\"mistralai/mixtral-8x7b-instruct\":                 0.12,\n\t\"mistralai/pixtral-12b\":                           0.05,\n\t\"mistralai/pixtral-large-2411\":                    3.0,\n\t\"neversleep/llama-3-lumimaid-70b\":                 2.25,\n\t\"neversleep/llama-3-lumimaid-8b\":                  0.5625,\n\t\"neversleep/llama-3-lumimaid-8b:extended\":         0.5625,\n\t\"neversleep/llama-3.1-lumimaid-70b\":               2.25,\n\t\"neversleep/llama-3.1-lumimaid-8b\":                0.5625,\n\t\"neversleep/noromaid-20b\":                         1.125,\n\t\"nothingiisreal/mn-celeste-12b\":                   0.6,\n\t\"nousresearch/hermes-2-pro-llama-3-8b\":            0.02,\n\t\"nousresearch/hermes-3-llama-3.1-405b\":            0.4,\n\t\"nousresearch/hermes-3-llama-3.1-70b\":             0.15,\n\t\"nousresearch/nous-hermes-2-mixtral-8x7b-dpo\":     0.3,\n\t\"nousresearch/nous-hermes-llama2-13b\":             0.085,\n\t\"nvidia/llama-3.1-nemotron-70b-instruct\":          0.15,\n\t\"nvidia/llama-3.1-nemotron-70b-instruct:free\":     0.0,\n\t\"openai/chatgpt-4o-latest\":                        7.5,\n\t\"openai/gpt-3.5-turbo\":                            0.75,\n\t\"openai/gpt-3.5-turbo-0125\":                       0.75,\n\t\"openai/gpt-3.5-turbo-0613\":                       1.0,\n\t\"openai/gpt-3.5-turbo-1106\":                       1.0,\n\t\"openai/gpt-3.5-turbo-16k\":                        2.0,\n\t\"openai/gpt-3.5-turbo-instruct\":                   1.0,\n\t\"openai/gpt-4\":                                    30.0,\n\t\"openai/gpt-4-0314\":                               30.0,\n\t\"openai/gpt-4-1106-preview\":                       15.0,\n\t\"openai/gpt-4-32k\":                                60.0,\n\t\"openai/gpt-4-32k-0314\":                           60.0,\n\t\"openai/gpt-4-turbo\":                              15.0,\n\t\"openai/gpt-4-turbo-preview\":                      15.0,\n\t\"openai/gpt-4o\":                                   5.0,\n\t\"openai/gpt-4o-2024-05-13\":                        7.5,\n\t\"openai/gpt-4o-2024-08-06\":                        5.0,\n\t\"openai/gpt-4o-2024-11-20\":                        5.0,\n\t\"openai/gpt-4o-mini\":                              0.3,\n\t\"openai/gpt-4o-mini-2024-07-18\":                   0.3,\n\t\"openai/gpt-4o:extended\":                          9.0,\n\t\"openai/o1\":                                       30.0,\n\t\"openai/o1-mini\":                                  2.2,\n\t\"openai/o1-mini-2024-09-12\":                       2.2,\n\t\"openai/o1-preview\":                               30.0,\n\t\"openai/o1-preview-2024-09-12\":                    30.0,\n\t\"openai/o3-mini\":                                  2.2,\n\t\"openai/o3-mini-high\":                             2.2,\n\t\"openchat/openchat-7b\":                            0.0275,\n\t\"openchat/openchat-7b:free\":                       0.0,\n\t\"openrouter/auto\":                                 -500000.0,\n\t\"perplexity/llama-3.1-sonar-huge-128k-online\":     2.5,\n\t\"perplexity/llama-3.1-sonar-large-128k-chat\":      0.5,\n\t\"perplexity/llama-3.1-sonar-large-128k-online\":    0.5,\n\t\"perplexity/llama-3.1-sonar-small-128k-chat\":      0.1,\n\t\"perplexity/llama-3.1-sonar-small-128k-online\":    0.1,\n\t\"perplexity/sonar\":                                0.5,\n\t\"perplexity/sonar-reasoning\":                      2.5,\n\t\"pygmalionai/mythalion-13b\":                       0.6,\n\t\"qwen/qvq-72b-preview\":                            0.25,\n\t\"qwen/qwen-2-72b-instruct\":                        0.45,\n\t\"qwen/qwen-2-7b-instruct\":                         0.027,\n\t\"qwen/qwen-2-7b-instruct:free\":                    0.0,\n\t\"qwen/qwen-2-vl-72b-instruct\":                     0.2,\n\t\"qwen/qwen-2-vl-7b-instruct\":                      0.05,\n\t\"qwen/qwen-2.5-72b-instruct\":                      0.2,\n\t\"qwen/qwen-2.5-7b-instruct\":                       0.025,\n\t\"qwen/qwen-2.5-coder-32b-instruct\":                0.08,\n\t\"qwen/qwen-max\":                                   3.2,\n\t\"qwen/qwen-plus\":                                  0.6,\n\t\"qwen/qwen-turbo\":                                 0.1,\n\t\"qwen/qwen-vl-plus:free\":                          0.0,\n\t\"qwen/qwen2.5-vl-72b-instruct:free\":               0.0,\n\t\"qwen/qwq-32b-preview\":                            0.09,\n\t\"raifle/sorcererlm-8x22b\":                         2.25,\n\t\"sao10k/fimbulvetr-11b-v2\":                        0.6,\n\t\"sao10k/l3-euryale-70b\":                           0.4,\n\t\"sao10k/l3-lunaris-8b\":                            0.03,\n\t\"sao10k/l3.1-70b-hanami-x1\":                       1.5,\n\t\"sao10k/l3.1-euryale-70b\":                         0.4,\n\t\"sao10k/l3.3-euryale-70b\":                         0.4,\n\t\"sophosympatheia/midnight-rose-70b\":               0.4,\n\t\"sophosympatheia/rogue-rose-103b-v0.2:free\":       0.0,\n\t\"teknium/openhermes-2.5-mistral-7b\":               0.085,\n\t\"thedrummer/rocinante-12b\":                        0.25,\n\t\"thedrummer/unslopnemo-12b\":                       0.25,\n\t\"undi95/remm-slerp-l2-13b\":                        0.6,\n\t\"undi95/toppy-m-7b\":                               0.035,\n\t\"undi95/toppy-m-7b:free\":                          0.0,\n\t\"x-ai/grok-2-1212\":                                5.0,\n\t\"x-ai/grok-2-vision-1212\":                         5.0,\n\t\"x-ai/grok-beta\":                                  7.5,\n\t\"x-ai/grok-vision-beta\":                           7.5,\n\t\"xwin-lm/xwin-lm-70b\":                             1.875,\n}\n\nvar CompletionRatio = map[string]float64{\n\t// aws llama3\n\t\"llama3-8b-8192(33)\":  0.0006 / 0.0003,\n\t\"llama3-70b-8192(33)\": 0.0035 / 0.00265,\n\t// whisper\n\t\"whisper-1\": 0, // only count input tokens\n\t// deepseek\n\t\"deepseek-chat\":     0.28 / 0.14,\n\t\"deepseek-reasoner\": 2.19 / 0.55,\n}\n\nvar (\n\tDefaultModelRatio      map[string]float64\n\tDefaultCompletionRatio map[string]float64\n)\n\nfunc init() {\n\tDefaultModelRatio = make(map[string]float64)\n\tfor k, v := range ModelRatio {\n\t\tDefaultModelRatio[k] = v\n\t}\n\tDefaultCompletionRatio = make(map[string]float64)\n\tfor k, v := range CompletionRatio {\n\t\tDefaultCompletionRatio[k] = v\n\t}\n}\n\nfunc AddNewMissingRatio(oldRatio string) string {\n\tnewRatio := make(map[string]float64)\n\terr := json.Unmarshal([]byte(oldRatio), &newRatio)\n\tif err != nil {\n\t\tlogger.SysError(\"error unmarshalling old ratio: \" + err.Error())\n\t\treturn oldRatio\n\t}\n\tfor k, v := range DefaultModelRatio {\n\t\tif _, ok := newRatio[k]; !ok {\n\t\t\tnewRatio[k] = v\n\t\t}\n\t}\n\tjsonBytes, err := json.Marshal(newRatio)\n\tif err != nil {\n\t\tlogger.SysError(\"error marshalling new ratio: \" + err.Error())\n\t\treturn oldRatio\n\t}\n\treturn string(jsonBytes)\n}\n\nfunc ModelRatio2JSONString() string {\n\tjsonBytes, err := json.Marshal(ModelRatio)\n\tif err != nil {\n\t\tlogger.SysError(\"error marshalling model ratio: \" + err.Error())\n\t}\n\treturn string(jsonBytes)\n}\n\nfunc UpdateModelRatioByJSONString(jsonStr string) error {\n\tmodelRatioLock.Lock()\n\tdefer modelRatioLock.Unlock()\n\tModelRatio = make(map[string]float64)\n\treturn json.Unmarshal([]byte(jsonStr), &ModelRatio)\n}\n\nfunc GetModelRatio(name string, channelType int) float64 {\n\tmodelRatioLock.RLock()\n\tdefer modelRatioLock.RUnlock()\n\tif strings.HasPrefix(name, \"qwen-\") && strings.HasSuffix(name, \"-internet\") {\n\t\tname = strings.TrimSuffix(name, \"-internet\")\n\t}\n\tif strings.HasPrefix(name, \"command-\") && strings.HasSuffix(name, \"-internet\") {\n\t\tname = strings.TrimSuffix(name, \"-internet\")\n\t}\n\tmodel := fmt.Sprintf(\"%s(%d)\", name, channelType)\n\tif ratio, ok := ModelRatio[model]; ok {\n\t\treturn ratio\n\t}\n\tif ratio, ok := DefaultModelRatio[model]; ok {\n\t\treturn ratio\n\t}\n\tif ratio, ok := ModelRatio[name]; ok {\n\t\treturn ratio\n\t}\n\tif ratio, ok := DefaultModelRatio[name]; ok {\n\t\treturn ratio\n\t}\n\tlogger.SysError(\"model ratio not found: \" + name)\n\treturn 30\n}\n\nfunc CompletionRatio2JSONString() string {\n\tjsonBytes, err := json.Marshal(CompletionRatio)\n\tif err != nil {\n\t\tlogger.SysError(\"error marshalling completion ratio: \" + err.Error())\n\t}\n\treturn string(jsonBytes)\n}\n\nfunc UpdateCompletionRatioByJSONString(jsonStr string) error {\n\tCompletionRatio = make(map[string]float64)\n\treturn json.Unmarshal([]byte(jsonStr), &CompletionRatio)\n}\n\nfunc GetCompletionRatio(name string, channelType int) float64 {\n\tif strings.HasPrefix(name, \"qwen-\") && strings.HasSuffix(name, \"-internet\") {\n\t\tname = strings.TrimSuffix(name, \"-internet\")\n\t}\n\tmodel := fmt.Sprintf(\"%s(%d)\", name, channelType)\n\tif ratio, ok := CompletionRatio[model]; ok {\n\t\treturn ratio\n\t}\n\tif ratio, ok := DefaultCompletionRatio[model]; ok {\n\t\treturn ratio\n\t}\n\tif ratio, ok := CompletionRatio[name]; ok {\n\t\treturn ratio\n\t}\n\tif ratio, ok := DefaultCompletionRatio[name]; ok {\n\t\treturn ratio\n\t}\n\tif strings.HasPrefix(name, \"gpt-3.5\") {\n\t\tif name == \"gpt-3.5-turbo\" || strings.HasSuffix(name, \"0125\") {\n\t\t\t// https://openai.com/blog/new-embedding-models-and-api-updates\n\t\t\t// Updated GPT-3.5 Turbo model and lower pricing\n\t\t\treturn 3\n\t\t}\n\t\tif strings.HasSuffix(name, \"1106\") {\n\t\t\treturn 2\n\t\t}\n\t\treturn 4.0 / 3.0\n\t}\n\tif strings.HasPrefix(name, \"gpt-4\") {\n\t\tif strings.HasPrefix(name, \"gpt-4o\") {\n\t\t\tif name == \"gpt-4o-2024-05-13\" {\n\t\t\t\treturn 3\n\t\t\t}\n\t\t\treturn 4\n\t\t}\n\t\tif strings.HasPrefix(name, \"gpt-4-turbo\") ||\n\t\t\tstrings.HasSuffix(name, \"preview\") {\n\t\t\treturn 3\n\t\t}\n\t\treturn 2\n\t}\n\t// including o1, o1-preview, o1-mini\n\tif strings.HasPrefix(name, \"o1\") {\n\t\treturn 4\n\t}\n\tif name == \"chatgpt-4o-latest\" {\n\t\treturn 3\n\t}\n\tif strings.HasPrefix(name, \"claude-3\") {\n\t\treturn 5\n\t}\n\tif strings.HasPrefix(name, \"claude-\") {\n\t\treturn 3\n\t}\n\tif strings.HasPrefix(name, \"mistral-\") {\n\t\treturn 3\n\t}\n\tif strings.HasPrefix(name, \"gemini-\") {\n\t\treturn 3\n\t}\n\tif strings.HasPrefix(name, \"deepseek-\") {\n\t\treturn 2\n\t}\n\n\tswitch name {\n\tcase \"llama2-70b-4096\":\n\t\treturn 0.8 / 0.64\n\tcase \"llama3-8b-8192\":\n\t\treturn 2\n\tcase \"llama3-70b-8192\":\n\t\treturn 0.79 / 0.59\n\tcase \"command\", \"command-light\", \"command-nightly\", \"command-light-nightly\":\n\t\treturn 2\n\tcase \"command-r\":\n\t\treturn 3\n\tcase \"command-r-plus\":\n\t\treturn 5\n\tcase \"grok-beta\":\n\t\treturn 3\n\t// Replicate Models\n\t// https://replicate.com/pricing\n\tcase \"ibm-granite/granite-20b-code-instruct-8k\":\n\t\treturn 5\n\tcase \"ibm-granite/granite-3.0-2b-instruct\":\n\t\treturn 8.333333333333334\n\tcase \"ibm-granite/granite-3.0-8b-instruct\",\n\t\t\"ibm-granite/granite-8b-code-instruct-128k\":\n\t\treturn 5\n\tcase \"meta/llama-2-13b\",\n\t\t\"meta/llama-2-13b-chat\",\n\t\t\"meta/llama-2-7b\",\n\t\t\"meta/llama-2-7b-chat\",\n\t\t\"meta/meta-llama-3-8b\",\n\t\t\"meta/meta-llama-3-8b-instruct\":\n\t\treturn 5\n\tcase \"meta/llama-2-70b\",\n\t\t\"meta/llama-2-70b-chat\",\n\t\t\"meta/meta-llama-3-70b\",\n\t\t\"meta/meta-llama-3-70b-instruct\":\n\t\treturn 2.750 / 0.650 // ≈4.230769\n\tcase \"meta/meta-llama-3.1-405b-instruct\":\n\t\treturn 1\n\tcase \"mistralai/mistral-7b-instruct-v0.2\",\n\t\t\"mistralai/mistral-7b-v0.1\":\n\t\treturn 5\n\tcase \"mistralai/mixtral-8x7b-instruct-v0.1\":\n\t\treturn 1.000 / 0.300 // ≈3.333333\n\t}\n\n\treturn 1\n}\n"
  },
  {
    "path": "relay/channeltype/define.go",
    "content": "package channeltype\n\nconst (\n\tUnknown = iota\n\tOpenAI\n\tAPI2D\n\tAzure\n\tCloseAI\n\tOpenAISB\n\tOpenAIMax\n\tOhMyGPT\n\tCustom\n\tAils\n\tAIProxy\n\tPaLM\n\tAPI2GPT\n\tAIGC2D\n\tAnthropic\n\tBaidu\n\tZhipu\n\tAli\n\tXunfei\n\tAI360\n\tOpenRouter\n\tAIProxyLibrary\n\tFastGPT\n\tTencent\n\tGemini\n\tMoonshot\n\tBaichuan\n\tMinimax\n\tMistral\n\tGroq\n\tOllama\n\tLingYiWanWu\n\tStepFun\n\tAwsClaude\n\tCoze\n\tCohere\n\tDeepSeek\n\tCloudflare\n\tDeepL\n\tTogetherAI\n\tDoubao\n\tNovita\n\tVertextAI\n\tProxy\n\tSiliconFlow\n\tXAI\n\tReplicate\n\tBaiduV2\n\tXunfeiV2\n\tAliBailian\n\tOpenAICompatible\n\tGeminiOpenAICompatible\n\tDummy\n)\n"
  },
  {
    "path": "relay/channeltype/helper.go",
    "content": "package channeltype\n\nimport \"github.com/songquanpeng/one-api/relay/apitype\"\n\nfunc ToAPIType(channelType int) int {\n\tapiType := apitype.OpenAI\n\tswitch channelType {\n\tcase Anthropic:\n\t\tapiType = apitype.Anthropic\n\tcase Baidu:\n\t\tapiType = apitype.Baidu\n\tcase PaLM:\n\t\tapiType = apitype.PaLM\n\tcase Zhipu:\n\t\tapiType = apitype.Zhipu\n\tcase Ali:\n\t\tapiType = apitype.Ali\n\tcase Xunfei:\n\t\tapiType = apitype.Xunfei\n\tcase AIProxyLibrary:\n\t\tapiType = apitype.AIProxyLibrary\n\tcase Tencent:\n\t\tapiType = apitype.Tencent\n\tcase Gemini:\n\t\tapiType = apitype.Gemini\n\tcase Ollama:\n\t\tapiType = apitype.Ollama\n\tcase AwsClaude:\n\t\tapiType = apitype.AwsClaude\n\tcase Coze:\n\t\tapiType = apitype.Coze\n\tcase Cohere:\n\t\tapiType = apitype.Cohere\n\tcase Cloudflare:\n\t\tapiType = apitype.Cloudflare\n\tcase DeepL:\n\t\tapiType = apitype.DeepL\n\tcase VertextAI:\n\t\tapiType = apitype.VertexAI\n\tcase Replicate:\n\t\tapiType = apitype.Replicate\n\tcase Proxy:\n\t\tapiType = apitype.Proxy\n\t}\n\n\treturn apiType\n}\n"
  },
  {
    "path": "relay/channeltype/url.go",
    "content": "package channeltype\n\nvar ChannelBaseURLs = []string{\n\t\"\",                              // 0\n\t\"https://api.openai.com\",        // 1\n\t\"https://oa.api2d.net\",          // 2\n\t\"\",                              // 3\n\t\"https://api.closeai-proxy.xyz\", // 4\n\t\"https://api.openai-sb.com\",     // 5\n\t\"https://api.openaimax.com\",     // 6\n\t\"https://api.ohmygpt.com\",       // 7\n\t\"\",                              // 8\n\t\"https://api.caipacity.com\",     // 9\n\t\"https://api.aiproxy.io\",        // 10\n\t\"https://generativelanguage.googleapis.com\", // 11\n\t\"https://api.api2gpt.com\",                   // 12\n\t\"https://api.aigc2d.com\",                    // 13\n\t\"https://api.anthropic.com\",                 // 14\n\t\"https://aip.baidubce.com\",                  // 15\n\t\"https://open.bigmodel.cn\",                  // 16\n\t\"https://dashscope.aliyuncs.com\",            // 17\n\t\"\",                                          // 18\n\t\"https://ai.360.cn\",                         // 19\n\t\"https://openrouter.ai/api\",                 // 20\n\t\"https://api.aiproxy.io\",                    // 21\n\t\"https://fastgpt.run/api/openapi\",           // 22\n\t\"https://hunyuan.tencentcloudapi.com\",       // 23\n\t\"https://generativelanguage.googleapis.com\", // 24\n\t\"https://api.moonshot.cn\",                   // 25\n\t\"https://api.baichuan-ai.com\",               // 26\n\t\"https://api.minimax.chat\",                  // 27\n\t\"https://api.mistral.ai\",                    // 28\n\t\"https://api.groq.com/openai\",               // 29\n\t\"http://localhost:11434\",                    // 30\n\t\"https://api.lingyiwanwu.com\",               // 31\n\t\"https://api.stepfun.com\",                   // 32\n\t\"\",                                          // 33\n\t\"https://api.coze.com\",                      // 34\n\t\"https://api.cohere.ai\",                     // 35\n\t\"https://api.deepseek.com\",                  // 36\n\t\"https://api.cloudflare.com\",                // 37\n\t\"https://api-free.deepl.com\",                // 38\n\t\"https://api.together.xyz\",                  // 39\n\t\"https://ark.cn-beijing.volces.com\",         // 40\n\t\"https://api.novita.ai/v3/openai\",           // 41\n\t\"\",                                          // 42\n\t\"\",                                          // 43\n\t\"https://api.siliconflow.cn\",                // 44\n\t\"https://api.x.ai\",                          // 45\n\t\"https://api.replicate.com/v1/models/\",      // 46\n\t\"https://qianfan.baidubce.com\",              // 47\n\t\"https://spark-api-open.xf-yun.com\",         // 48\n\t\"https://dashscope.aliyuncs.com\",            // 49\n\t\"\",                                          // 50\n\n\t\"https://generativelanguage.googleapis.com/v1beta/openai/\", // 51\n}\n\nfunc init() {\n\tif len(ChannelBaseURLs) != Dummy {\n\t\tpanic(\"channel base urls length not match\")\n\t}\n}\n"
  },
  {
    "path": "relay/channeltype/url_test.go",
    "content": "package channeltype\n\nimport (\n\t. \"github.com/smartystreets/goconvey/convey\"\n\t\"testing\"\n)\n\nfunc TestChannelBaseURLs(t *testing.T) {\n\tConvey(\"channel base urls\", t, func() {\n\t\tSo(len(ChannelBaseURLs), ShouldEqual, Dummy)\n\t})\n}\n"
  },
  {
    "path": "relay/constant/common.go",
    "content": "package constant\n\nvar StopFinishReason = \"stop\"\nvar StreamObject = \"chat.completion.chunk\"\nvar NonStreamObject = \"chat.completion\"\n"
  },
  {
    "path": "relay/constant/finishreason/define.go",
    "content": "package finishreason\n\nconst (\n\tStop = \"stop\"\n)\n"
  },
  {
    "path": "relay/constant/role/define.go",
    "content": "package role\n\nconst (\n\tSystem    = \"system\"\n\tAssistant = \"assistant\"\n)\n"
  },
  {
    "path": "relay/controller/audio.go",
    "content": "package controller\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/client\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/billing\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc RelayAudioHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatusCode {\n\tctx := c.Request.Context()\n\tmeta := meta.GetByContext(c)\n\taudioModel := \"whisper-1\"\n\n\ttokenId := c.GetInt(ctxkey.TokenId)\n\tchannelType := c.GetInt(ctxkey.Channel)\n\tchannelId := c.GetInt(ctxkey.ChannelId)\n\tuserId := c.GetInt(ctxkey.Id)\n\tgroup := c.GetString(ctxkey.Group)\n\ttokenName := c.GetString(ctxkey.TokenName)\n\n\tvar ttsRequest openai.TextToSpeechRequest\n\tif relayMode == relaymode.AudioSpeech {\n\t\t// Read JSON\n\t\terr := common.UnmarshalBodyReusable(c, &ttsRequest)\n\t\t// Check if JSON is valid\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"invalid_json\", http.StatusBadRequest)\n\t\t}\n\t\taudioModel = ttsRequest.Model\n\t\t// Check if text is too long 4096\n\t\tif len(ttsRequest.Input) > 4096 {\n\t\t\treturn openai.ErrorWrapper(errors.New(\"input is too long (over 4096 characters)\"), \"text_too_long\", http.StatusBadRequest)\n\t\t}\n\t}\n\n\tmodelRatio := billingratio.GetModelRatio(audioModel, channelType)\n\tgroupRatio := billingratio.GetGroupRatio(group)\n\tratio := modelRatio * groupRatio\n\tvar quota int64\n\tvar preConsumedQuota int64\n\tswitch relayMode {\n\tcase relaymode.AudioSpeech:\n\t\tpreConsumedQuota = int64(float64(len(ttsRequest.Input)) * ratio)\n\t\tquota = preConsumedQuota\n\tdefault:\n\t\tpreConsumedQuota = int64(float64(config.PreConsumedQuota) * ratio)\n\t}\n\tuserQuota, err := model.CacheGetUserQuota(ctx, userId)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"get_user_quota_failed\", http.StatusInternalServerError)\n\t}\n\n\t// Check if user quota is enough\n\tif userQuota-preConsumedQuota < 0 {\n\t\treturn openai.ErrorWrapper(errors.New(\"user quota is not enough\"), \"insufficient_user_quota\", http.StatusForbidden)\n\t}\n\terr = model.CacheDecreaseUserQuota(userId, preConsumedQuota)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"decrease_user_quota_failed\", http.StatusInternalServerError)\n\t}\n\tif userQuota > 100*preConsumedQuota {\n\t\t// in this case, we do not pre-consume quota\n\t\t// because the user has enough quota\n\t\tpreConsumedQuota = 0\n\t}\n\tif preConsumedQuota > 0 {\n\t\terr := model.PreConsumeTokenQuota(tokenId, preConsumedQuota)\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"pre_consume_token_quota_failed\", http.StatusForbidden)\n\t\t}\n\t}\n\tsucceed := false\n\tdefer func() {\n\t\tif succeed {\n\t\t\treturn\n\t\t}\n\t\tif preConsumedQuota > 0 {\n\t\t\t// we need to roll back the pre-consumed quota\n\t\t\tdefer func(ctx context.Context) {\n\t\t\t\tgo func() {\n\t\t\t\t\t// negative means add quota back for token & user\n\t\t\t\t\terr := model.PostConsumeTokenQuota(tokenId, -preConsumedQuota)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Error(ctx, fmt.Sprintf(\"error rollback pre-consumed quota: %s\", err.Error()))\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}(c.Request.Context())\n\t\t}\n\t}()\n\n\t// map model name\n\tmodelMapping := c.GetStringMapString(ctxkey.ModelMapping)\n\tif modelMapping != nil && modelMapping[audioModel] != \"\" {\n\t\taudioModel = modelMapping[audioModel]\n\t}\n\n\tbaseURL := channeltype.ChannelBaseURLs[channelType]\n\trequestURL := c.Request.URL.String()\n\tif c.GetString(ctxkey.BaseURL) != \"\" {\n\t\tbaseURL = c.GetString(ctxkey.BaseURL)\n\t}\n\n\tfullRequestURL := openai.GetFullRequestURL(baseURL, requestURL, channelType)\n\tif channelType == channeltype.Azure {\n\t\tapiVersion := meta.Config.APIVersion\n\t\tif relayMode == relaymode.AudioTranscription {\n\t\t\t// https://learn.microsoft.com/en-us/azure/ai-services/openai/whisper-quickstart?tabs=command-line#rest-api\n\t\t\tfullRequestURL = fmt.Sprintf(\"%s/openai/deployments/%s/audio/transcriptions?api-version=%s\", baseURL, audioModel, apiVersion)\n\t\t} else if relayMode == relaymode.AudioSpeech {\n\t\t\t// https://learn.microsoft.com/en-us/azure/ai-services/openai/text-to-speech-quickstart?tabs=command-line#rest-api\n\t\t\tfullRequestURL = fmt.Sprintf(\"%s/openai/deployments/%s/audio/speech?api-version=%s\", baseURL, audioModel, apiVersion)\n\t\t}\n\t}\n\n\trequestBody := &bytes.Buffer{}\n\t_, err = io.Copy(requestBody, c.Request.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"new_request_body_failed\", http.StatusInternalServerError)\n\t}\n\tc.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody.Bytes()))\n\tresponseFormat := c.DefaultPostForm(\"response_format\", \"json\")\n\n\treq, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"new_request_failed\", http.StatusInternalServerError)\n\t}\n\n\tif (relayMode == relaymode.AudioTranscription || relayMode == relaymode.AudioSpeech) && channelType == channeltype.Azure {\n\t\t// https://learn.microsoft.com/en-us/azure/ai-services/openai/whisper-quickstart?tabs=command-line#rest-api\n\t\tapiKey := c.Request.Header.Get(\"Authorization\")\n\t\tapiKey = strings.TrimPrefix(apiKey, \"Bearer \")\n\t\treq.Header.Set(\"api-key\", apiKey)\n\t\treq.ContentLength = c.Request.ContentLength\n\t} else {\n\t\treq.Header.Set(\"Authorization\", c.Request.Header.Get(\"Authorization\"))\n\t}\n\treq.Header.Set(\"Content-Type\", c.Request.Header.Get(\"Content-Type\"))\n\treq.Header.Set(\"Accept\", c.Request.Header.Get(\"Accept\"))\n\n\tresp, err := client.HTTPClient.Do(req)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"do_request_failed\", http.StatusInternalServerError)\n\t}\n\n\terr = req.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_request_body_failed\", http.StatusInternalServerError)\n\t}\n\terr = c.Request.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_request_body_failed\", http.StatusInternalServerError)\n\t}\n\n\tif relayMode != relaymode.AudioSpeech {\n\t\tresponseBody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"read_response_body_failed\", http.StatusInternalServerError)\n\t\t}\n\t\terr = resp.Body.Close()\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError)\n\t\t}\n\n\t\tvar openAIErr openai.SlimTextResponse\n\t\tif err = json.Unmarshal(responseBody, &openAIErr); err == nil {\n\t\t\tif openAIErr.Error.Message != \"\" {\n\t\t\t\treturn openai.ErrorWrapper(fmt.Errorf(\"type %s, code %v, message %s\", openAIErr.Error.Type, openAIErr.Error.Code, openAIErr.Error.Message), \"request_error\", http.StatusInternalServerError)\n\t\t\t}\n\t\t}\n\n\t\tvar text string\n\t\tswitch responseFormat {\n\t\tcase \"json\":\n\t\t\ttext, err = getTextFromJSON(responseBody)\n\t\tcase \"text\":\n\t\t\ttext, err = getTextFromText(responseBody)\n\t\tcase \"srt\":\n\t\t\ttext, err = getTextFromSRT(responseBody)\n\t\tcase \"verbose_json\":\n\t\t\ttext, err = getTextFromVerboseJSON(responseBody)\n\t\tcase \"vtt\":\n\t\t\ttext, err = getTextFromVTT(responseBody)\n\t\tdefault:\n\t\t\treturn openai.ErrorWrapper(errors.New(\"unexpected_response_format\"), \"unexpected_response_format\", http.StatusInternalServerError)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"get_text_from_body_err\", http.StatusInternalServerError)\n\t\t}\n\t\tquota = int64(openai.CountTokenText(text, audioModel))\n\t\tresp.Body = io.NopCloser(bytes.NewBuffer(responseBody))\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn RelayErrorHandler(resp)\n\t}\n\tsucceed = true\n\tquotaDelta := quota - preConsumedQuota\n\tdefer func(ctx context.Context) {\n\t\tgo billing.PostConsumeQuota(ctx, tokenId, quotaDelta, quota, userId, channelId, modelRatio, groupRatio, audioModel, tokenName)\n\t}(c.Request.Context())\n\n\tfor k, v := range resp.Header {\n\t\tc.Writer.Header().Set(k, v[0])\n\t}\n\tc.Writer.WriteHeader(resp.StatusCode)\n\n\t_, err = io.Copy(c.Writer, resp.Body)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"copy_response_body_failed\", http.StatusInternalServerError)\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"close_response_body_failed\", http.StatusInternalServerError)\n\t}\n\treturn nil\n}\n\nfunc getTextFromVTT(body []byte) (string, error) {\n\treturn getTextFromSRT(body)\n}\n\nfunc getTextFromVerboseJSON(body []byte) (string, error) {\n\tvar whisperResponse openai.WhisperVerboseJSONResponse\n\tif err := json.Unmarshal(body, &whisperResponse); err != nil {\n\t\treturn \"\", fmt.Errorf(\"unmarshal_response_body_failed err :%w\", err)\n\t}\n\treturn whisperResponse.Text, nil\n}\n\nfunc getTextFromSRT(body []byte) (string, error) {\n\tscanner := bufio.NewScanner(strings.NewReader(string(body)))\n\tvar builder strings.Builder\n\tvar textLine bool\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif textLine {\n\t\t\tbuilder.WriteString(line)\n\t\t\ttextLine = false\n\t\t\tcontinue\n\t\t} else if strings.Contains(line, \"-->\") {\n\t\t\ttextLine = true\n\t\t\tcontinue\n\t\t}\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn builder.String(), nil\n}\n\nfunc getTextFromText(body []byte) (string, error) {\n\treturn strings.TrimSuffix(string(body), \"\\n\"), nil\n}\n\nfunc getTextFromJSON(body []byte) (string, error) {\n\tvar whisperResponse openai.WhisperJSONResponse\n\tif err := json.Unmarshal(body, &whisperResponse); err != nil {\n\t\treturn \"\", fmt.Errorf(\"unmarshal_response_body_failed err :%w\", err)\n\t}\n\treturn whisperResponse.Text, nil\n}\n"
  },
  {
    "path": "relay/controller/error.go",
    "content": "package controller\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n)\n\ntype GeneralErrorResponse struct {\n\tError    model.Error `json:\"error\"`\n\tMessage  string      `json:\"message\"`\n\tMsg      string      `json:\"msg\"`\n\tErr      string      `json:\"err\"`\n\tErrorMsg string      `json:\"error_msg\"`\n\tHeader   struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"header\"`\n\tResponse struct {\n\t\tError struct {\n\t\t\tMessage string `json:\"message\"`\n\t\t} `json:\"error\"`\n\t} `json:\"response\"`\n}\n\nfunc (e GeneralErrorResponse) ToMessage() string {\n\tif e.Error.Message != \"\" {\n\t\treturn e.Error.Message\n\t}\n\tif e.Message != \"\" {\n\t\treturn e.Message\n\t}\n\tif e.Msg != \"\" {\n\t\treturn e.Msg\n\t}\n\tif e.Err != \"\" {\n\t\treturn e.Err\n\t}\n\tif e.ErrorMsg != \"\" {\n\t\treturn e.ErrorMsg\n\t}\n\tif e.Header.Message != \"\" {\n\t\treturn e.Header.Message\n\t}\n\tif e.Response.Error.Message != \"\" {\n\t\treturn e.Response.Error.Message\n\t}\n\treturn \"\"\n}\n\nfunc RelayErrorHandler(resp *http.Response) (ErrorWithStatusCode *model.ErrorWithStatusCode) {\n\tif resp == nil {\n\t\treturn &model.ErrorWithStatusCode{\n\t\t\tStatusCode: 500,\n\t\t\tError: model.Error{\n\t\t\t\tMessage: \"resp is nil\",\n\t\t\t\tType:    \"upstream_error\",\n\t\t\t\tCode:    \"bad_response\",\n\t\t\t},\n\t\t}\n\t}\n\tErrorWithStatusCode = &model.ErrorWithStatusCode{\n\t\tStatusCode: resp.StatusCode,\n\t\tError: model.Error{\n\t\t\tMessage: \"\",\n\t\t\tType:    \"upstream_error\",\n\t\t\tCode:    \"bad_response_status_code\",\n\t\t\tParam:   strconv.Itoa(resp.StatusCode),\n\t\t},\n\t}\n\tresponseBody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn\n\t}\n\tif config.DebugEnabled {\n\t\tlogger.SysLog(fmt.Sprintf(\"error happened, status code: %d, response: \\n%s\", resp.StatusCode, string(responseBody)))\n\t}\n\terr = resp.Body.Close()\n\tif err != nil {\n\t\treturn\n\t}\n\tvar errResponse GeneralErrorResponse\n\terr = json.Unmarshal(responseBody, &errResponse)\n\tif err != nil {\n\t\treturn\n\t}\n\tif errResponse.Error.Message != \"\" {\n\t\t// OpenAI format error, so we override the default one\n\t\tErrorWithStatusCode.Error = errResponse.Error\n\t} else {\n\t\tErrorWithStatusCode.Error.Message = errResponse.ToMessage()\n\t}\n\tif ErrorWithStatusCode.Error.Message == \"\" {\n\t\tErrorWithStatusCode.Error.Message = fmt.Sprintf(\"bad response status code %d\", resp.StatusCode)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "relay/controller/helper.go",
    "content": "package controller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/songquanpeng/one-api/common/helper\"\n\t\"github.com/songquanpeng/one-api/relay/constant/role\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/controller/validator\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\nfunc getAndValidateTextRequest(c *gin.Context, relayMode int) (*relaymodel.GeneralOpenAIRequest, error) {\n\ttextRequest := &relaymodel.GeneralOpenAIRequest{}\n\terr := common.UnmarshalBodyReusable(c, textRequest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif relayMode == relaymode.Moderations && textRequest.Model == \"\" {\n\t\ttextRequest.Model = \"text-moderation-latest\"\n\t}\n\tif relayMode == relaymode.Embeddings && textRequest.Model == \"\" {\n\t\ttextRequest.Model = c.Param(\"model\")\n\t}\n\terr = validator.ValidateTextRequest(textRequest, relayMode)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn textRequest, nil\n}\n\nfunc getPromptTokens(textRequest *relaymodel.GeneralOpenAIRequest, relayMode int) int {\n\tswitch relayMode {\n\tcase relaymode.ChatCompletions:\n\t\treturn openai.CountTokenMessages(textRequest.Messages, textRequest.Model)\n\tcase relaymode.Completions:\n\t\treturn openai.CountTokenInput(textRequest.Prompt, textRequest.Model)\n\tcase relaymode.Moderations:\n\t\treturn openai.CountTokenInput(textRequest.Input, textRequest.Model)\n\t}\n\treturn 0\n}\n\nfunc getPreConsumedQuota(textRequest *relaymodel.GeneralOpenAIRequest, promptTokens int, ratio float64) int64 {\n\tpreConsumedTokens := config.PreConsumedQuota + int64(promptTokens)\n\tif textRequest.MaxTokens != 0 {\n\t\tpreConsumedTokens += int64(textRequest.MaxTokens)\n\t}\n\treturn int64(float64(preConsumedTokens) * ratio)\n}\n\nfunc preConsumeQuota(ctx context.Context, textRequest *relaymodel.GeneralOpenAIRequest, promptTokens int, ratio float64, meta *meta.Meta) (int64, *relaymodel.ErrorWithStatusCode) {\n\tpreConsumedQuota := getPreConsumedQuota(textRequest, promptTokens, ratio)\n\n\tuserQuota, err := model.CacheGetUserQuota(ctx, meta.UserId)\n\tif err != nil {\n\t\treturn preConsumedQuota, openai.ErrorWrapper(err, \"get_user_quota_failed\", http.StatusInternalServerError)\n\t}\n\tif userQuota-preConsumedQuota < 0 {\n\t\treturn preConsumedQuota, openai.ErrorWrapper(errors.New(\"user quota is not enough\"), \"insufficient_user_quota\", http.StatusForbidden)\n\t}\n\terr = model.CacheDecreaseUserQuota(meta.UserId, preConsumedQuota)\n\tif err != nil {\n\t\treturn preConsumedQuota, openai.ErrorWrapper(err, \"decrease_user_quota_failed\", http.StatusInternalServerError)\n\t}\n\tif userQuota > 100*preConsumedQuota {\n\t\t// in this case, we do not pre-consume quota\n\t\t// because the user has enough quota\n\t\tpreConsumedQuota = 0\n\t\tlogger.Info(ctx, fmt.Sprintf(\"user %d has enough quota %d, trusted and no need to pre-consume\", meta.UserId, userQuota))\n\t}\n\tif preConsumedQuota > 0 {\n\t\terr := model.PreConsumeTokenQuota(meta.TokenId, preConsumedQuota)\n\t\tif err != nil {\n\t\t\treturn preConsumedQuota, openai.ErrorWrapper(err, \"pre_consume_token_quota_failed\", http.StatusForbidden)\n\t\t}\n\t}\n\treturn preConsumedQuota, nil\n}\n\nfunc postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.Meta, textRequest *relaymodel.GeneralOpenAIRequest, ratio float64, preConsumedQuota int64, modelRatio float64, groupRatio float64, systemPromptReset bool) {\n\tif usage == nil {\n\t\tlogger.Error(ctx, \"usage is nil, which is unexpected\")\n\t\treturn\n\t}\n\tvar quota int64\n\tcompletionRatio := billingratio.GetCompletionRatio(textRequest.Model, meta.ChannelType)\n\tpromptTokens := usage.PromptTokens\n\tcompletionTokens := usage.CompletionTokens\n\tquota = int64(math.Ceil((float64(promptTokens) + float64(completionTokens)*completionRatio) * ratio))\n\tif ratio != 0 && quota <= 0 {\n\t\tquota = 1\n\t}\n\ttotalTokens := promptTokens + completionTokens\n\tif totalTokens == 0 {\n\t\t// in this case, must be some error happened\n\t\t// we cannot just return, because we may have to return the pre-consumed quota\n\t\tquota = 0\n\t}\n\tquotaDelta := quota - preConsumedQuota\n\terr := model.PostConsumeTokenQuota(meta.TokenId, quotaDelta)\n\tif err != nil {\n\t\tlogger.Error(ctx, \"error consuming token remain quota: \"+err.Error())\n\t}\n\terr = model.CacheUpdateUserQuota(ctx, meta.UserId)\n\tif err != nil {\n\t\tlogger.Error(ctx, \"error update user quota cache: \"+err.Error())\n\t}\n\tlogContent := fmt.Sprintf(\"倍率：%.2f × %.2f × %.2f\", modelRatio, groupRatio, completionRatio)\n\tmodel.RecordConsumeLog(ctx, &model.Log{\n\t\tUserId:            meta.UserId,\n\t\tChannelId:         meta.ChannelId,\n\t\tPromptTokens:      promptTokens,\n\t\tCompletionTokens:  completionTokens,\n\t\tModelName:         textRequest.Model,\n\t\tTokenName:         meta.TokenName,\n\t\tQuota:             int(quota),\n\t\tContent:           logContent,\n\t\tIsStream:          meta.IsStream,\n\t\tElapsedTime:       helper.CalcElapsedTime(meta.StartTime),\n\t\tSystemPromptReset: systemPromptReset,\n\t})\n\tmodel.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)\n\tmodel.UpdateChannelUsedQuota(meta.ChannelId, quota)\n}\n\nfunc getMappedModelName(modelName string, mapping map[string]string) (string, bool) {\n\tif mapping == nil {\n\t\treturn modelName, false\n\t}\n\tmappedModelName := mapping[modelName]\n\tif mappedModelName != \"\" {\n\t\treturn mappedModelName, true\n\t}\n\treturn modelName, false\n}\n\nfunc isErrorHappened(meta *meta.Meta, resp *http.Response) bool {\n\tif resp == nil {\n\t\tif meta.ChannelType == channeltype.AwsClaude {\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t}\n\tif resp.StatusCode != http.StatusOK &&\n\t\t// replicate return 201 to create a task\n\t\tresp.StatusCode != http.StatusCreated {\n\t\treturn true\n\t}\n\tif meta.ChannelType == channeltype.DeepL {\n\t\t// skip stream check for deepl\n\t\treturn false\n\t}\n\n\tif meta.IsStream && strings.HasPrefix(resp.Header.Get(\"Content-Type\"), \"application/json\") &&\n\t\t// Even if stream mode is enabled, replicate will first return a task info in JSON format,\n\t\t// requiring the client to request the stream endpoint in the task info\n\t\tmeta.ChannelType != channeltype.Replicate {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc setSystemPrompt(ctx context.Context, request *relaymodel.GeneralOpenAIRequest, prompt string) (reset bool) {\n\tif prompt == \"\" {\n\t\treturn false\n\t}\n\tif len(request.Messages) == 0 {\n\t\treturn false\n\t}\n\tif request.Messages[0].Role == role.System {\n\t\trequest.Messages[0].Content = prompt\n\t\tlogger.Infof(ctx, \"rewrite system prompt\")\n\t\treturn true\n\t}\n\trequest.Messages = append([]relaymodel.Message{{\n\t\tRole:    role.System,\n\t\tContent: prompt,\n\t}}, request.Messages...)\n\tlogger.Infof(ctx, \"add system prompt\")\n\treturn true\n}\n"
  },
  {
    "path": "relay/controller/image.go",
    "content": "package controller\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/relay\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc getImageRequest(c *gin.Context, _ int) (*relaymodel.ImageRequest, error) {\n\timageRequest := &relaymodel.ImageRequest{}\n\terr := common.UnmarshalBodyReusable(c, imageRequest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif imageRequest.N == 0 {\n\t\timageRequest.N = 1\n\t}\n\tif imageRequest.Size == \"\" {\n\t\timageRequest.Size = \"1024x1024\"\n\t}\n\tif imageRequest.Model == \"\" {\n\t\timageRequest.Model = \"dall-e-2\"\n\t}\n\treturn imageRequest, nil\n}\n\nfunc isValidImageSize(model string, size string) bool {\n\tif model == \"cogview-3\" || billingratio.ImageSizeRatios[model] == nil {\n\t\treturn true\n\t}\n\t_, ok := billingratio.ImageSizeRatios[model][size]\n\treturn ok\n}\n\nfunc isValidImagePromptLength(model string, promptLength int) bool {\n\tmaxPromptLength, ok := billingratio.ImagePromptLengthLimitations[model]\n\treturn !ok || promptLength <= maxPromptLength\n}\n\nfunc isWithinRange(element string, value int) bool {\n\tamounts, ok := billingratio.ImageGenerationAmounts[element]\n\treturn !ok || (value >= amounts[0] && value <= amounts[1])\n}\n\nfunc getImageSizeRatio(model string, size string) float64 {\n\tif ratio, ok := billingratio.ImageSizeRatios[model][size]; ok {\n\t\treturn ratio\n\t}\n\treturn 1\n}\n\nfunc validateImageRequest(imageRequest *relaymodel.ImageRequest, _ *meta.Meta) *relaymodel.ErrorWithStatusCode {\n\t// check prompt length\n\tif imageRequest.Prompt == \"\" {\n\t\treturn openai.ErrorWrapper(errors.New(\"prompt is required\"), \"prompt_missing\", http.StatusBadRequest)\n\t}\n\n\t// model validation\n\tif !isValidImageSize(imageRequest.Model, imageRequest.Size) {\n\t\treturn openai.ErrorWrapper(errors.New(\"size not supported for this image model\"), \"size_not_supported\", http.StatusBadRequest)\n\t}\n\n\tif !isValidImagePromptLength(imageRequest.Model, len(imageRequest.Prompt)) {\n\t\treturn openai.ErrorWrapper(errors.New(\"prompt is too long\"), \"prompt_too_long\", http.StatusBadRequest)\n\t}\n\n\t// Number of generated images validation\n\tif !isWithinRange(imageRequest.Model, imageRequest.N) {\n\t\treturn openai.ErrorWrapper(errors.New(\"invalid value of n\"), \"n_not_within_range\", http.StatusBadRequest)\n\t}\n\treturn nil\n}\n\nfunc getImageCostRatio(imageRequest *relaymodel.ImageRequest) (float64, error) {\n\tif imageRequest == nil {\n\t\treturn 0, errors.New(\"imageRequest is nil\")\n\t}\n\timageCostRatio := getImageSizeRatio(imageRequest.Model, imageRequest.Size)\n\tif imageRequest.Quality == \"hd\" && imageRequest.Model == \"dall-e-3\" {\n\t\tif imageRequest.Size == \"1024x1024\" {\n\t\t\timageCostRatio *= 2\n\t\t} else {\n\t\t\timageCostRatio *= 1.5\n\t\t}\n\t}\n\treturn imageCostRatio, nil\n}\n\nfunc RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatusCode {\n\tctx := c.Request.Context()\n\tmeta := meta.GetByContext(c)\n\timageRequest, err := getImageRequest(c, meta.Mode)\n\tif err != nil {\n\t\tlogger.Errorf(ctx, \"getImageRequest failed: %s\", err.Error())\n\t\treturn openai.ErrorWrapper(err, \"invalid_image_request\", http.StatusBadRequest)\n\t}\n\n\t// map model name\n\tvar isModelMapped bool\n\tmeta.OriginModelName = imageRequest.Model\n\timageRequest.Model, isModelMapped = getMappedModelName(imageRequest.Model, meta.ModelMapping)\n\tmeta.ActualModelName = imageRequest.Model\n\n\t// model validation\n\tbizErr := validateImageRequest(imageRequest, meta)\n\tif bizErr != nil {\n\t\treturn bizErr\n\t}\n\n\timageCostRatio, err := getImageCostRatio(imageRequest)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"get_image_cost_ratio_failed\", http.StatusInternalServerError)\n\t}\n\n\timageModel := imageRequest.Model\n\t// Convert the original image model\n\timageRequest.Model, _ = getMappedModelName(imageRequest.Model, billingratio.ImageOriginModelName)\n\tc.Set(\"response_format\", imageRequest.ResponseFormat)\n\n\tvar requestBody io.Reader\n\tif isModelMapped || meta.ChannelType == channeltype.Azure { // make Azure channel request body\n\t\tjsonStr, err := json.Marshal(imageRequest)\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"marshal_image_request_failed\", http.StatusInternalServerError)\n\t\t}\n\t\trequestBody = bytes.NewBuffer(jsonStr)\n\t} else {\n\t\trequestBody = c.Request.Body\n\t}\n\n\tadaptor := relay.GetAdaptor(meta.APIType)\n\tif adaptor == nil {\n\t\treturn openai.ErrorWrapper(fmt.Errorf(\"invalid api type: %d\", meta.APIType), \"invalid_api_type\", http.StatusBadRequest)\n\t}\n\tadaptor.Init(meta)\n\n\t// these adaptors need to convert the request\n\tswitch meta.ChannelType {\n\tcase channeltype.Zhipu,\n\t\tchanneltype.Ali,\n\t\tchanneltype.Replicate,\n\t\tchanneltype.Baidu:\n\t\tfinalRequest, err := adaptor.ConvertImageRequest(imageRequest)\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"convert_image_request_failed\", http.StatusInternalServerError)\n\t\t}\n\t\tjsonStr, err := json.Marshal(finalRequest)\n\t\tif err != nil {\n\t\t\treturn openai.ErrorWrapper(err, \"marshal_image_request_failed\", http.StatusInternalServerError)\n\t\t}\n\t\trequestBody = bytes.NewBuffer(jsonStr)\n\t}\n\n\tmodelRatio := billingratio.GetModelRatio(imageModel, meta.ChannelType)\n\tgroupRatio := billingratio.GetGroupRatio(meta.Group)\n\tratio := modelRatio * groupRatio\n\tuserQuota, err := model.CacheGetUserQuota(ctx, meta.UserId)\n\n\tvar quota int64\n\tswitch meta.ChannelType {\n\tcase channeltype.Replicate:\n\t\t// replicate always return 1 image\n\t\tquota = int64(ratio * imageCostRatio * 1000)\n\tdefault:\n\t\tquota = int64(ratio*imageCostRatio*1000) * int64(imageRequest.N)\n\t}\n\n\tif userQuota-quota < 0 {\n\t\treturn openai.ErrorWrapper(errors.New(\"user quota is not enough\"), \"insufficient_user_quota\", http.StatusForbidden)\n\t}\n\n\t// do request\n\tresp, err := adaptor.DoRequest(c, meta, requestBody)\n\tif err != nil {\n\t\tlogger.Errorf(ctx, \"DoRequest failed: %s\", err.Error())\n\t\treturn openai.ErrorWrapper(err, \"do_request_failed\", http.StatusInternalServerError)\n\t}\n\n\tdefer func(ctx context.Context) {\n\t\tif resp != nil &&\n\t\t\tresp.StatusCode != http.StatusCreated && // replicate returns 201\n\t\t\tresp.StatusCode != http.StatusOK {\n\t\t\treturn\n\t\t}\n\n\t\terr := model.PostConsumeTokenQuota(meta.TokenId, quota)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error consuming token remain quota: \" + err.Error())\n\t\t}\n\t\terr = model.CacheUpdateUserQuota(ctx, meta.UserId)\n\t\tif err != nil {\n\t\t\tlogger.SysError(\"error update user quota cache: \" + err.Error())\n\t\t}\n\t\tif quota != 0 {\n\t\t\ttokenName := c.GetString(ctxkey.TokenName)\n\t\t\tlogContent := fmt.Sprintf(\"倍率：%.2f × %.2f\", modelRatio, groupRatio)\n\t\t\tmodel.RecordConsumeLog(ctx, &model.Log{\n\t\t\t\tUserId:           meta.UserId,\n\t\t\t\tChannelId:        meta.ChannelId,\n\t\t\t\tPromptTokens:     0,\n\t\t\t\tCompletionTokens: 0,\n\t\t\t\tModelName:        imageRequest.Model,\n\t\t\t\tTokenName:        tokenName,\n\t\t\t\tQuota:            int(quota),\n\t\t\t\tContent:          logContent,\n\t\t\t})\n\t\t\tmodel.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)\n\t\t\tchannelId := c.GetInt(ctxkey.ChannelId)\n\t\t\tmodel.UpdateChannelUsedQuota(channelId, quota)\n\t\t}\n\t}(c.Request.Context())\n\n\t// do response\n\t_, respErr := adaptor.DoResponse(c, resp, meta)\n\tif respErr != nil {\n\t\tlogger.Errorf(ctx, \"respErr is not nil: %+v\", respErr)\n\t\treturn respErr\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "relay/controller/proxy.go",
    "content": "// Package controller is a package for handling the relay controller\npackage controller\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\trelaymodel \"github.com/songquanpeng/one-api/relay/model\"\n)\n\n// RelayProxyHelper is a helper function to proxy the request to the upstream service\nfunc RelayProxyHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatusCode {\n\tctx := c.Request.Context()\n\tmeta := meta.GetByContext(c)\n\n\tadaptor := relay.GetAdaptor(meta.APIType)\n\tif adaptor == nil {\n\t\treturn openai.ErrorWrapper(fmt.Errorf(\"invalid api type: %d\", meta.APIType), \"invalid_api_type\", http.StatusBadRequest)\n\t}\n\tadaptor.Init(meta)\n\n\tresp, err := adaptor.DoRequest(c, meta, c.Request.Body)\n\tif err != nil {\n\t\tlogger.Errorf(ctx, \"DoRequest failed: %s\", err.Error())\n\t\treturn openai.ErrorWrapper(err, \"do_request_failed\", http.StatusInternalServerError)\n\t}\n\n\t// do response\n\t_, respErr := adaptor.DoResponse(c, resp, meta)\n\tif respErr != nil {\n\t\tlogger.Errorf(ctx, \"respErr is not nil: %+v\", respErr)\n\t\treturn respErr\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "relay/controller/text.go",
    "content": "package controller\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"github.com/songquanpeng/one-api/relay\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor\"\n\t\"github.com/songquanpeng/one-api/relay/adaptor/openai\"\n\t\"github.com/songquanpeng/one-api/relay/apitype\"\n\t\"github.com/songquanpeng/one-api/relay/billing\"\n\tbillingratio \"github.com/songquanpeng/one-api/relay/billing/ratio\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/meta\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n)\n\nfunc RelayTextHelper(c *gin.Context) *model.ErrorWithStatusCode {\n\tctx := c.Request.Context()\n\tmeta := meta.GetByContext(c)\n\t// get & validate textRequest\n\ttextRequest, err := getAndValidateTextRequest(c, meta.Mode)\n\tif err != nil {\n\t\tlogger.Errorf(ctx, \"getAndValidateTextRequest failed: %s\", err.Error())\n\t\treturn openai.ErrorWrapper(err, \"invalid_text_request\", http.StatusBadRequest)\n\t}\n\tmeta.IsStream = textRequest.Stream\n\n\t// map model name\n\tmeta.OriginModelName = textRequest.Model\n\ttextRequest.Model, _ = getMappedModelName(textRequest.Model, meta.ModelMapping)\n\tmeta.ActualModelName = textRequest.Model\n\t// set system prompt if not empty\n\tsystemPromptReset := setSystemPrompt(ctx, textRequest, meta.ForcedSystemPrompt)\n\t// get model ratio & group ratio\n\tmodelRatio := billingratio.GetModelRatio(textRequest.Model, meta.ChannelType)\n\tgroupRatio := billingratio.GetGroupRatio(meta.Group)\n\tratio := modelRatio * groupRatio\n\t// pre-consume quota\n\tpromptTokens := getPromptTokens(textRequest, meta.Mode)\n\tmeta.PromptTokens = promptTokens\n\tpreConsumedQuota, bizErr := preConsumeQuota(ctx, textRequest, promptTokens, ratio, meta)\n\tif bizErr != nil {\n\t\tlogger.Warnf(ctx, \"preConsumeQuota failed: %+v\", *bizErr)\n\t\treturn bizErr\n\t}\n\n\tadaptor := relay.GetAdaptor(meta.APIType)\n\tif adaptor == nil {\n\t\treturn openai.ErrorWrapper(fmt.Errorf(\"invalid api type: %d\", meta.APIType), \"invalid_api_type\", http.StatusBadRequest)\n\t}\n\tadaptor.Init(meta)\n\n\t// get request body\n\trequestBody, err := getRequestBody(c, meta, textRequest, adaptor)\n\tif err != nil {\n\t\treturn openai.ErrorWrapper(err, \"convert_request_failed\", http.StatusInternalServerError)\n\t}\n\n\t// do request\n\tresp, err := adaptor.DoRequest(c, meta, requestBody)\n\tif err != nil {\n\t\tlogger.Errorf(ctx, \"DoRequest failed: %s\", err.Error())\n\t\treturn openai.ErrorWrapper(err, \"do_request_failed\", http.StatusInternalServerError)\n\t}\n\tif isErrorHappened(meta, resp) {\n\t\tbilling.ReturnPreConsumedQuota(ctx, preConsumedQuota, meta.TokenId)\n\t\treturn RelayErrorHandler(resp)\n\t}\n\n\t// do response\n\tusage, respErr := adaptor.DoResponse(c, resp, meta)\n\tif respErr != nil {\n\t\tlogger.Errorf(ctx, \"respErr is not nil: %+v\", respErr)\n\t\tbilling.ReturnPreConsumedQuota(ctx, preConsumedQuota, meta.TokenId)\n\t\treturn respErr\n\t}\n\t// post-consume quota\n\tgo postConsumeQuota(ctx, usage, meta, textRequest, ratio, preConsumedQuota, modelRatio, groupRatio, systemPromptReset)\n\treturn nil\n}\n\nfunc getRequestBody(c *gin.Context, meta *meta.Meta, textRequest *model.GeneralOpenAIRequest, adaptor adaptor.Adaptor) (io.Reader, error) {\n\tif !config.EnforceIncludeUsage &&\n\t\tmeta.APIType == apitype.OpenAI &&\n\t\tmeta.OriginModelName == meta.ActualModelName &&\n\t\tmeta.ChannelType != channeltype.Baichuan &&\n\t\tmeta.ForcedSystemPrompt == \"\" {\n\t\t// no need to convert request for openai\n\t\treturn c.Request.Body, nil\n\t}\n\n\t// get request body\n\tvar requestBody io.Reader\n\tconvertedRequest, err := adaptor.ConvertRequest(c, meta.Mode, textRequest)\n\tif err != nil {\n\t\tlogger.Debugf(c.Request.Context(), \"converted request failed: %s\\n\", err.Error())\n\t\treturn nil, err\n\t}\n\tjsonData, err := json.Marshal(convertedRequest)\n\tif err != nil {\n\t\tlogger.Debugf(c.Request.Context(), \"converted request json_marshal_failed: %s\\n\", err.Error())\n\t\treturn nil, err\n\t}\n\tlogger.Debugf(c.Request.Context(), \"converted request: \\n%s\", string(jsonData))\n\trequestBody = bytes.NewBuffer(jsonData)\n\treturn requestBody, nil\n}\n"
  },
  {
    "path": "relay/controller/validator/validation.go",
    "content": "package validator\n\nimport (\n\t\"errors\"\n\t\"github.com/songquanpeng/one-api/relay/model\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n\t\"math\"\n)\n\nfunc ValidateTextRequest(textRequest *model.GeneralOpenAIRequest, relayMode int) error {\n\tif textRequest.MaxTokens < 0 || textRequest.MaxTokens > math.MaxInt32/2 {\n\t\treturn errors.New(\"max_tokens is invalid\")\n\t}\n\tif textRequest.Model == \"\" {\n\t\treturn errors.New(\"model is required\")\n\t}\n\tswitch relayMode {\n\tcase relaymode.Completions:\n\t\tif textRequest.Prompt == \"\" {\n\t\t\treturn errors.New(\"field prompt is required\")\n\t\t}\n\tcase relaymode.ChatCompletions:\n\t\tif textRequest.Messages == nil || len(textRequest.Messages) == 0 {\n\t\t\treturn errors.New(\"field messages is required\")\n\t\t}\n\tcase relaymode.Embeddings:\n\tcase relaymode.Moderations:\n\t\tif textRequest.Input == \"\" {\n\t\t\treturn errors.New(\"field input is required\")\n\t\t}\n\tcase relaymode.Edits:\n\t\tif textRequest.Instruction == \"\" {\n\t\t\treturn errors.New(\"field instruction is required\")\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "relay/meta/relay_meta.go",
    "content": "package meta\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/songquanpeng/one-api/common/ctxkey\"\n\t\"github.com/songquanpeng/one-api/model\"\n\t\"github.com/songquanpeng/one-api/relay/channeltype\"\n\t\"github.com/songquanpeng/one-api/relay/relaymode\"\n)\n\ntype Meta struct {\n\tMode         int\n\tChannelType  int\n\tChannelId    int\n\tTokenId      int\n\tTokenName    string\n\tUserId       int\n\tGroup        string\n\tModelMapping map[string]string\n\t// BaseURL is the proxy url set in the channel config\n\tBaseURL  string\n\tAPIKey   string\n\tAPIType  int\n\tConfig   model.ChannelConfig\n\tIsStream bool\n\t// OriginModelName is the model name from the raw user request\n\tOriginModelName string\n\t// ActualModelName is the model name after mapping\n\tActualModelName    string\n\tRequestURLPath     string\n\tPromptTokens       int // only for DoResponse\n\tForcedSystemPrompt string\n\tStartTime          time.Time\n}\n\nfunc GetByContext(c *gin.Context) *Meta {\n\tmeta := Meta{\n\t\tMode:               relaymode.GetByPath(c.Request.URL.Path),\n\t\tChannelType:        c.GetInt(ctxkey.Channel),\n\t\tChannelId:          c.GetInt(ctxkey.ChannelId),\n\t\tTokenId:            c.GetInt(ctxkey.TokenId),\n\t\tTokenName:          c.GetString(ctxkey.TokenName),\n\t\tUserId:             c.GetInt(ctxkey.Id),\n\t\tGroup:              c.GetString(ctxkey.Group),\n\t\tModelMapping:       c.GetStringMapString(ctxkey.ModelMapping),\n\t\tOriginModelName:    c.GetString(ctxkey.RequestModel),\n\t\tBaseURL:            c.GetString(ctxkey.BaseURL),\n\t\tAPIKey:             strings.TrimPrefix(c.Request.Header.Get(\"Authorization\"), \"Bearer \"),\n\t\tRequestURLPath:     c.Request.URL.String(),\n\t\tForcedSystemPrompt: c.GetString(ctxkey.SystemPrompt),\n\t\tStartTime:          time.Now(),\n\t}\n\tcfg, ok := c.Get(ctxkey.Config)\n\tif ok {\n\t\tmeta.Config = cfg.(model.ChannelConfig)\n\t}\n\tif meta.BaseURL == \"\" {\n\t\tmeta.BaseURL = channeltype.ChannelBaseURLs[meta.ChannelType]\n\t}\n\tmeta.APIType = channeltype.ToAPIType(meta.ChannelType)\n\treturn &meta\n}\n"
  },
  {
    "path": "relay/model/constant.go",
    "content": "package model\n\nconst (\n\tContentTypeText       = \"text\"\n\tContentTypeImageURL   = \"image_url\"\n\tContentTypeInputAudio = \"input_audio\"\n)\n"
  },
  {
    "path": "relay/model/general.go",
    "content": "package model\n\ntype ResponseFormat struct {\n\tType       string      `json:\"type,omitempty\"`\n\tJsonSchema *JSONSchema `json:\"json_schema,omitempty\"`\n}\n\ntype JSONSchema struct {\n\tDescription string                 `json:\"description,omitempty\"`\n\tName        string                 `json:\"name\"`\n\tSchema      map[string]interface{} `json:\"schema,omitempty\"`\n\tStrict      *bool                  `json:\"strict,omitempty\"`\n}\n\ntype Audio struct {\n\tVoice  string `json:\"voice,omitempty\"`\n\tFormat string `json:\"format,omitempty\"`\n}\n\ntype StreamOptions struct {\n\tIncludeUsage bool `json:\"include_usage,omitempty\"`\n}\n\ntype GeneralOpenAIRequest struct {\n\t// https://platform.openai.com/docs/api-reference/chat/create\n\tMessages            []Message       `json:\"messages,omitempty\"`\n\tModel               string          `json:\"model,omitempty\"`\n\tStore               *bool           `json:\"store,omitempty\"`\n\tReasoningEffort     *string         `json:\"reasoning_effort,omitempty\"`\n\tMetadata            any             `json:\"metadata,omitempty\"`\n\tFrequencyPenalty    *float64        `json:\"frequency_penalty,omitempty\"`\n\tLogitBias           any             `json:\"logit_bias,omitempty\"`\n\tLogprobs            *bool           `json:\"logprobs,omitempty\"`\n\tTopLogprobs         *int            `json:\"top_logprobs,omitempty\"`\n\tMaxTokens           int             `json:\"max_tokens,omitempty\"`\n\tMaxCompletionTokens *int            `json:\"max_completion_tokens,omitempty\"`\n\tN                   int             `json:\"n,omitempty\"`\n\tModalities          []string        `json:\"modalities,omitempty\"`\n\tPrediction          any             `json:\"prediction,omitempty\"`\n\tAudio               *Audio          `json:\"audio,omitempty\"`\n\tPresencePenalty     *float64        `json:\"presence_penalty,omitempty\"`\n\tResponseFormat      *ResponseFormat `json:\"response_format,omitempty\"`\n\tSeed                float64         `json:\"seed,omitempty\"`\n\tServiceTier         *string         `json:\"service_tier,omitempty\"`\n\tStop                any             `json:\"stop,omitempty\"`\n\tStream              bool            `json:\"stream,omitempty\"`\n\tStreamOptions       *StreamOptions  `json:\"stream_options,omitempty\"`\n\tTemperature         *float64        `json:\"temperature,omitempty\"`\n\tTopP                *float64        `json:\"top_p,omitempty\"`\n\tTopK                int             `json:\"top_k,omitempty\"`\n\tTools               []Tool          `json:\"tools,omitempty\"`\n\tToolChoice          any             `json:\"tool_choice,omitempty\"`\n\tParallelTooCalls    *bool           `json:\"parallel_tool_calls,omitempty\"`\n\tUser                string          `json:\"user,omitempty\"`\n\tFunctionCall        any             `json:\"function_call,omitempty\"`\n\tFunctions           any             `json:\"functions,omitempty\"`\n\t// https://platform.openai.com/docs/api-reference/embeddings/create\n\tInput          any    `json:\"input,omitempty\"`\n\tEncodingFormat string `json:\"encoding_format,omitempty\"`\n\tDimensions     int    `json:\"dimensions,omitempty\"`\n\t// https://platform.openai.com/docs/api-reference/images/create\n\tPrompt  any     `json:\"prompt,omitempty\"`\n\tQuality *string `json:\"quality,omitempty\"`\n\tSize    string  `json:\"size,omitempty\"`\n\tStyle   *string `json:\"style,omitempty\"`\n\t// Others\n\tInstruction string `json:\"instruction,omitempty\"`\n\tNumCtx      int    `json:\"num_ctx,omitempty\"`\n}\n\nfunc (r GeneralOpenAIRequest) ParseInput() []string {\n\tif r.Input == nil {\n\t\treturn nil\n\t}\n\tvar input []string\n\tswitch r.Input.(type) {\n\tcase string:\n\t\tinput = []string{r.Input.(string)}\n\tcase []any:\n\t\tinput = make([]string, 0, len(r.Input.([]any)))\n\t\tfor _, item := range r.Input.([]any) {\n\t\t\tif str, ok := item.(string); ok {\n\t\t\t\tinput = append(input, str)\n\t\t\t}\n\t\t}\n\t}\n\treturn input\n}\n"
  },
  {
    "path": "relay/model/image.go",
    "content": "package model\n\ntype ImageRequest struct {\n\tModel          string `json:\"model\"`\n\tPrompt         string `json:\"prompt\" binding:\"required\"`\n\tN              int    `json:\"n,omitempty\"`\n\tSize           string `json:\"size,omitempty\"`\n\tQuality        string `json:\"quality,omitempty\"`\n\tResponseFormat string `json:\"response_format,omitempty\"`\n\tStyle          string `json:\"style,omitempty\"`\n\tUser           string `json:\"user,omitempty\"`\n}\n"
  },
  {
    "path": "relay/model/message.go",
    "content": "package model\n\ntype Message struct {\n\tRole             string  `json:\"role,omitempty\"`\n\tContent          any     `json:\"content,omitempty\"`\n\tReasoningContent any     `json:\"reasoning_content,omitempty\"`\n\tName             *string `json:\"name,omitempty\"`\n\tToolCalls        []Tool  `json:\"tool_calls,omitempty\"`\n\tToolCallId       string  `json:\"tool_call_id,omitempty\"`\n}\n\nfunc (m Message) IsStringContent() bool {\n\t_, ok := m.Content.(string)\n\treturn ok\n}\n\nfunc (m Message) StringContent() string {\n\tcontent, ok := m.Content.(string)\n\tif ok {\n\t\treturn content\n\t}\n\tcontentList, ok := m.Content.([]any)\n\tif ok {\n\t\tvar contentStr string\n\t\tfor _, contentItem := range contentList {\n\t\t\tcontentMap, ok := contentItem.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif contentMap[\"type\"] == ContentTypeText {\n\t\t\t\tif subStr, ok := contentMap[\"text\"].(string); ok {\n\t\t\t\t\tcontentStr += subStr\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn contentStr\n\t}\n\treturn \"\"\n}\n\nfunc (m Message) ParseContent() []MessageContent {\n\tvar contentList []MessageContent\n\tcontent, ok := m.Content.(string)\n\tif ok {\n\t\tcontentList = append(contentList, MessageContent{\n\t\t\tType: ContentTypeText,\n\t\t\tText: content,\n\t\t})\n\t\treturn contentList\n\t}\n\tanyList, ok := m.Content.([]any)\n\tif ok {\n\t\tfor _, contentItem := range anyList {\n\t\t\tcontentMap, ok := contentItem.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch contentMap[\"type\"] {\n\t\t\tcase ContentTypeText:\n\t\t\t\tif subStr, ok := contentMap[\"text\"].(string); ok {\n\t\t\t\t\tcontentList = append(contentList, MessageContent{\n\t\t\t\t\t\tType: ContentTypeText,\n\t\t\t\t\t\tText: subStr,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\tcase ContentTypeImageURL:\n\t\t\t\tif subObj, ok := contentMap[\"image_url\"].(map[string]any); ok {\n\t\t\t\t\tcontentList = append(contentList, MessageContent{\n\t\t\t\t\t\tType: ContentTypeImageURL,\n\t\t\t\t\t\tImageURL: &ImageURL{\n\t\t\t\t\t\t\tUrl: subObj[\"url\"].(string),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn contentList\n\t}\n\treturn nil\n}\n\ntype ImageURL struct {\n\tUrl    string `json:\"url,omitempty\"`\n\tDetail string `json:\"detail,omitempty\"`\n}\n\ntype MessageContent struct {\n\tType     string    `json:\"type,omitempty\"`\n\tText     string    `json:\"text\"`\n\tImageURL *ImageURL `json:\"image_url,omitempty\"`\n}\n"
  },
  {
    "path": "relay/model/misc.go",
    "content": "package model\n\ntype Usage struct {\n\tPromptTokens     int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens      int `json:\"total_tokens\"`\n\n\tCompletionTokensDetails *CompletionTokensDetails `json:\"completion_tokens_details,omitempty\"`\n}\n\ntype CompletionTokensDetails struct {\n\tReasoningTokens          int `json:\"reasoning_tokens\"`\n\tAcceptedPredictionTokens int `json:\"accepted_prediction_tokens\"`\n\tRejectedPredictionTokens int `json:\"rejected_prediction_tokens\"`\n}\n\ntype Error struct {\n\tMessage string `json:\"message\"`\n\tType    string `json:\"type\"`\n\tParam   string `json:\"param\"`\n\tCode    any    `json:\"code\"`\n}\n\ntype ErrorWithStatusCode struct {\n\tError\n\tStatusCode int `json:\"status_code\"`\n}\n"
  },
  {
    "path": "relay/model/tool.go",
    "content": "package model\n\ntype Tool struct {\n\tId       string   `json:\"id,omitempty\"`\n\tType     string   `json:\"type,omitempty\"` // when splicing claude tools stream messages, it is empty\n\tFunction Function `json:\"function\"`\n}\n\ntype Function struct {\n\tDescription string `json:\"description,omitempty\"`\n\tName        string `json:\"name,omitempty\"`       // when splicing claude tools stream messages, it is empty\n\tParameters  any    `json:\"parameters,omitempty\"` // request\n\tArguments   any    `json:\"arguments,omitempty\"`  // response\n}\n"
  },
  {
    "path": "relay/relaymode/define.go",
    "content": "package relaymode\n\nconst (\n\tUnknown = iota\n\tChatCompletions\n\tCompletions\n\tEmbeddings\n\tModerations\n\tImagesGenerations\n\tEdits\n\tAudioSpeech\n\tAudioTranscription\n\tAudioTranslation\n\t// Proxy is a special relay mode for proxying requests to custom upstream\n\tProxy\n)\n"
  },
  {
    "path": "relay/relaymode/helper.go",
    "content": "package relaymode\n\nimport \"strings\"\n\nfunc GetByPath(path string) int {\n\trelayMode := Unknown\n\tif strings.HasPrefix(path, \"/v1/chat/completions\") {\n\t\trelayMode = ChatCompletions\n\t} else if strings.HasPrefix(path, \"/v1/completions\") {\n\t\trelayMode = Completions\n\t} else if strings.HasPrefix(path, \"/v1/embeddings\") {\n\t\trelayMode = Embeddings\n\t} else if strings.HasSuffix(path, \"embeddings\") {\n\t\trelayMode = Embeddings\n\t} else if strings.HasPrefix(path, \"/v1/moderations\") {\n\t\trelayMode = Moderations\n\t} else if strings.HasPrefix(path, \"/v1/images/generations\") {\n\t\trelayMode = ImagesGenerations\n\t} else if strings.HasPrefix(path, \"/v1/edits\") {\n\t\trelayMode = Edits\n\t} else if strings.HasPrefix(path, \"/v1/audio/speech\") {\n\t\trelayMode = AudioSpeech\n\t} else if strings.HasPrefix(path, \"/v1/audio/transcriptions\") {\n\t\trelayMode = AudioTranscription\n\t} else if strings.HasPrefix(path, \"/v1/audio/translations\") {\n\t\trelayMode = AudioTranslation\n\t} else if strings.HasPrefix(path, \"/v1/oneapi/proxy\") {\n\t\trelayMode = Proxy\n\t}\n\treturn relayMode\n}\n"
  },
  {
    "path": "router/api.go",
    "content": "package router\n\nimport (\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/controller/auth\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc SetApiRouter(router *gin.Engine) {\n\tapiRouter := router.Group(\"/api\")\n\tapiRouter.Use(gzip.Gzip(gzip.DefaultCompression))\n\tapiRouter.Use(middleware.GlobalAPIRateLimit())\n\t{\n\t\tapiRouter.GET(\"/status\", controller.GetStatus)\n\t\tapiRouter.GET(\"/models\", middleware.UserAuth(), controller.DashboardListModels)\n\t\tapiRouter.GET(\"/notice\", controller.GetNotice)\n\t\tapiRouter.GET(\"/about\", controller.GetAbout)\n\t\tapiRouter.GET(\"/home_page_content\", controller.GetHomePageContent)\n\t\tapiRouter.GET(\"/verification\", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)\n\t\tapiRouter.GET(\"/reset_password\", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)\n\t\tapiRouter.POST(\"/user/reset\", middleware.CriticalRateLimit(), controller.ResetPassword)\n\t\tapiRouter.GET(\"/oauth/github\", middleware.CriticalRateLimit(), auth.GitHubOAuth)\n\t\tapiRouter.GET(\"/oauth/oidc\", middleware.CriticalRateLimit(), auth.OidcAuth)\n\t\tapiRouter.GET(\"/oauth/lark\", middleware.CriticalRateLimit(), auth.LarkOAuth)\n\t\tapiRouter.GET(\"/oauth/state\", middleware.CriticalRateLimit(), auth.GenerateOAuthCode)\n\t\tapiRouter.GET(\"/oauth/wechat\", middleware.CriticalRateLimit(), auth.WeChatAuth)\n\t\tapiRouter.GET(\"/oauth/wechat/bind\", middleware.CriticalRateLimit(), middleware.UserAuth(), auth.WeChatBind)\n\t\tapiRouter.GET(\"/oauth/email/bind\", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)\n\t\tapiRouter.POST(\"/topup\", middleware.AdminAuth(), controller.AdminTopUp)\n\n\t\tuserRoute := apiRouter.Group(\"/user\")\n\t\t{\n\t\t\tuserRoute.POST(\"/register\", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)\n\t\t\tuserRoute.POST(\"/login\", middleware.CriticalRateLimit(), controller.Login)\n\t\t\tuserRoute.GET(\"/logout\", controller.Logout)\n\n\t\t\tselfRoute := userRoute.Group(\"/\")\n\t\t\tselfRoute.Use(middleware.UserAuth())\n\t\t\t{\n\t\t\t\tselfRoute.GET(\"/dashboard\", controller.GetUserDashboard)\n\t\t\t\tselfRoute.GET(\"/self\", controller.GetSelf)\n\t\t\t\tselfRoute.PUT(\"/self\", controller.UpdateSelf)\n\t\t\t\tselfRoute.DELETE(\"/self\", controller.DeleteSelf)\n\t\t\t\tselfRoute.GET(\"/token\", controller.GenerateAccessToken)\n\t\t\t\tselfRoute.GET(\"/aff\", controller.GetAffCode)\n\t\t\t\tselfRoute.POST(\"/topup\", controller.TopUp)\n\t\t\t\tselfRoute.GET(\"/available_models\", controller.GetUserAvailableModels)\n\t\t\t}\n\n\t\t\tadminRoute := userRoute.Group(\"/\")\n\t\t\tadminRoute.Use(middleware.AdminAuth())\n\t\t\t{\n\t\t\t\tadminRoute.GET(\"/\", controller.GetAllUsers)\n\t\t\t\tadminRoute.GET(\"/search\", controller.SearchUsers)\n\t\t\t\tadminRoute.GET(\"/:id\", controller.GetUser)\n\t\t\t\tadminRoute.POST(\"/\", controller.CreateUser)\n\t\t\t\tadminRoute.POST(\"/manage\", controller.ManageUser)\n\t\t\t\tadminRoute.PUT(\"/\", controller.UpdateUser)\n\t\t\t\tadminRoute.DELETE(\"/:id\", controller.DeleteUser)\n\t\t\t}\n\t\t}\n\t\toptionRoute := apiRouter.Group(\"/option\")\n\t\toptionRoute.Use(middleware.RootAuth())\n\t\t{\n\t\t\toptionRoute.GET(\"/\", controller.GetOptions)\n\t\t\toptionRoute.PUT(\"/\", controller.UpdateOption)\n\t\t}\n\t\tchannelRoute := apiRouter.Group(\"/channel\")\n\t\tchannelRoute.Use(middleware.AdminAuth())\n\t\t{\n\t\t\tchannelRoute.GET(\"/\", controller.GetAllChannels)\n\t\t\tchannelRoute.GET(\"/search\", controller.SearchChannels)\n\t\t\tchannelRoute.GET(\"/models\", controller.ListAllModels)\n\t\t\tchannelRoute.GET(\"/:id\", controller.GetChannel)\n\t\t\tchannelRoute.GET(\"/test\", controller.TestChannels)\n\t\t\tchannelRoute.GET(\"/test/:id\", controller.TestChannel)\n\t\t\tchannelRoute.GET(\"/update_balance\", controller.UpdateAllChannelsBalance)\n\t\t\tchannelRoute.GET(\"/update_balance/:id\", controller.UpdateChannelBalance)\n\t\t\tchannelRoute.POST(\"/\", controller.AddChannel)\n\t\t\tchannelRoute.PUT(\"/\", controller.UpdateChannel)\n\t\t\tchannelRoute.DELETE(\"/disabled\", controller.DeleteDisabledChannel)\n\t\t\tchannelRoute.DELETE(\"/:id\", controller.DeleteChannel)\n\t\t}\n\t\ttokenRoute := apiRouter.Group(\"/token\")\n\t\ttokenRoute.Use(middleware.UserAuth())\n\t\t{\n\t\t\ttokenRoute.GET(\"/\", controller.GetAllTokens)\n\t\t\ttokenRoute.GET(\"/search\", controller.SearchTokens)\n\t\t\ttokenRoute.GET(\"/:id\", controller.GetToken)\n\t\t\ttokenRoute.POST(\"/\", controller.AddToken)\n\t\t\ttokenRoute.PUT(\"/\", controller.UpdateToken)\n\t\t\ttokenRoute.DELETE(\"/:id\", controller.DeleteToken)\n\t\t}\n\t\tredemptionRoute := apiRouter.Group(\"/redemption\")\n\t\tredemptionRoute.Use(middleware.AdminAuth())\n\t\t{\n\t\t\tredemptionRoute.GET(\"/\", controller.GetAllRedemptions)\n\t\t\tredemptionRoute.GET(\"/search\", controller.SearchRedemptions)\n\t\t\tredemptionRoute.GET(\"/:id\", controller.GetRedemption)\n\t\t\tredemptionRoute.POST(\"/\", controller.AddRedemption)\n\t\t\tredemptionRoute.PUT(\"/\", controller.UpdateRedemption)\n\t\t\tredemptionRoute.DELETE(\"/:id\", controller.DeleteRedemption)\n\t\t}\n\t\tlogRoute := apiRouter.Group(\"/log\")\n\t\tlogRoute.GET(\"/\", middleware.AdminAuth(), controller.GetAllLogs)\n\t\tlogRoute.DELETE(\"/\", middleware.AdminAuth(), controller.DeleteHistoryLogs)\n\t\tlogRoute.GET(\"/stat\", middleware.AdminAuth(), controller.GetLogsStat)\n\t\tlogRoute.GET(\"/self/stat\", middleware.UserAuth(), controller.GetLogsSelfStat)\n\t\tlogRoute.GET(\"/search\", middleware.AdminAuth(), controller.SearchAllLogs)\n\t\tlogRoute.GET(\"/self\", middleware.UserAuth(), controller.GetUserLogs)\n\t\tlogRoute.GET(\"/self/search\", middleware.UserAuth(), controller.SearchUserLogs)\n\t\tgroupRoute := apiRouter.Group(\"/group\")\n\t\tgroupRoute.Use(middleware.AdminAuth())\n\t\t{\n\t\t\tgroupRoute.GET(\"/\", controller.GetGroups)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "router/dashboard.go",
    "content": "package router\n\nimport (\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n)\n\nfunc SetDashboardRouter(router *gin.Engine) {\n\tapiRouter := router.Group(\"/\")\n\tapiRouter.Use(middleware.CORS())\n\tapiRouter.Use(gzip.Gzip(gzip.DefaultCompression))\n\tapiRouter.Use(middleware.GlobalAPIRateLimit())\n\tapiRouter.Use(middleware.TokenAuth())\n\t{\n\t\tapiRouter.GET(\"/dashboard/billing/subscription\", controller.GetSubscription)\n\t\tapiRouter.GET(\"/v1/dashboard/billing/subscription\", controller.GetSubscription)\n\t\tapiRouter.GET(\"/dashboard/billing/usage\", controller.GetUsage)\n\t\tapiRouter.GET(\"/v1/dashboard/billing/usage\", controller.GetUsage)\n\t}\n}\n"
  },
  {
    "path": "router/main.go",
    "content": "package router\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/common/logger\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n)\n\nfunc SetRouter(router *gin.Engine, buildFS embed.FS) {\n\tSetApiRouter(router)\n\tSetDashboardRouter(router)\n\tSetRelayRouter(router)\n\tfrontendBaseUrl := os.Getenv(\"FRONTEND_BASE_URL\")\n\tif config.IsMasterNode && frontendBaseUrl != \"\" {\n\t\tfrontendBaseUrl = \"\"\n\t\tlogger.SysLog(\"FRONTEND_BASE_URL is ignored on master node\")\n\t}\n\tif frontendBaseUrl == \"\" {\n\t\tSetWebRouter(router, buildFS)\n\t} else {\n\t\tfrontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, \"/\")\n\t\trouter.NoRoute(func(c *gin.Context) {\n\t\t\tc.Redirect(http.StatusMovedPermanently, fmt.Sprintf(\"%s%s\", frontendBaseUrl, c.Request.RequestURI))\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "router/relay.go",
    "content": "package router\n\nimport (\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc SetRelayRouter(router *gin.Engine) {\n\trouter.Use(middleware.CORS())\n\trouter.Use(middleware.GzipDecodeMiddleware())\n\t// https://platform.openai.com/docs/api-reference/introduction\n\tmodelsRouter := router.Group(\"/v1/models\")\n\tmodelsRouter.Use(middleware.TokenAuth())\n\t{\n\t\tmodelsRouter.GET(\"\", controller.ListModels)\n\t\tmodelsRouter.GET(\"/:model\", controller.RetrieveModel)\n\t}\n\trelayV1Router := router.Group(\"/v1\")\n\trelayV1Router.Use(middleware.RelayPanicRecover(), middleware.TokenAuth(), middleware.Distribute())\n\t{\n\t\trelayV1Router.Any(\"/oneapi/proxy/:channelid/*target\", controller.Relay)\n\t\trelayV1Router.POST(\"/completions\", controller.Relay)\n\t\trelayV1Router.POST(\"/chat/completions\", controller.Relay)\n\t\trelayV1Router.POST(\"/edits\", controller.Relay)\n\t\trelayV1Router.POST(\"/images/generations\", controller.Relay)\n\t\trelayV1Router.POST(\"/images/edits\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/images/variations\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/embeddings\", controller.Relay)\n\t\trelayV1Router.POST(\"/engines/:model/embeddings\", controller.Relay)\n\t\trelayV1Router.POST(\"/audio/transcriptions\", controller.Relay)\n\t\trelayV1Router.POST(\"/audio/translations\", controller.Relay)\n\t\trelayV1Router.POST(\"/audio/speech\", controller.Relay)\n\t\trelayV1Router.GET(\"/files\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/files\", controller.RelayNotImplemented)\n\t\trelayV1Router.DELETE(\"/files/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/files/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/files/:id/content\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/fine_tuning/jobs\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/fine_tuning/jobs\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/fine_tuning/jobs/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/fine_tuning/jobs/:id/cancel\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/fine_tuning/jobs/:id/events\", controller.RelayNotImplemented)\n\t\trelayV1Router.DELETE(\"/models/:model\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/moderations\", controller.Relay)\n\t\trelayV1Router.POST(\"/assistants\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/assistants/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/assistants/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.DELETE(\"/assistants/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/assistants\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/assistants/:id/files\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/assistants/:id/files/:fileId\", controller.RelayNotImplemented)\n\t\trelayV1Router.DELETE(\"/assistants/:id/files/:fileId\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/assistants/:id/files\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.DELETE(\"/threads/:id\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id/messages\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/messages/:messageId\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id/messages/:messageId\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/messages/:messageId/files/:filesId\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/messages/:messageId/files\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id/runs\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/runs/:runsId\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id/runs/:runsId\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/runs\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id/runs/:runsId/submit_tool_outputs\", controller.RelayNotImplemented)\n\t\trelayV1Router.POST(\"/threads/:id/runs/:runsId/cancel\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/runs/:runsId/steps/:stepId\", controller.RelayNotImplemented)\n\t\trelayV1Router.GET(\"/threads/:id/runs/:runsId/steps\", controller.RelayNotImplemented)\n\t}\n}\n"
  },
  {
    "path": "router/web.go",
    "content": "package router\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-contrib/static\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/songquanpeng/one-api/common\"\n\t\"github.com/songquanpeng/one-api/common/config\"\n\t\"github.com/songquanpeng/one-api/controller\"\n\t\"github.com/songquanpeng/one-api/middleware\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc SetWebRouter(router *gin.Engine, buildFS embed.FS) {\n\tindexPageData, _ := buildFS.ReadFile(fmt.Sprintf(\"web/build/%s/index.html\", config.Theme))\n\trouter.Use(gzip.Gzip(gzip.DefaultCompression))\n\trouter.Use(middleware.GlobalWebRateLimit())\n\trouter.Use(middleware.Cache())\n\trouter.Use(static.Serve(\"/\", common.EmbedFolder(buildFS, fmt.Sprintf(\"web/build/%s\", config.Theme))))\n\trouter.NoRoute(func(c *gin.Context) {\n\t\tif strings.HasPrefix(c.Request.RequestURI, \"/v1\") || strings.HasPrefix(c.Request.RequestURI, \"/api\") {\n\t\t\tcontroller.RelayNotFound(c)\n\t\t\treturn\n\t\t}\n\t\tc.Header(\"Cache-Control\", \"no-cache\")\n\t\tc.Data(http.StatusOK, \"text/html; charset=utf-8\", indexPageData)\n\t})\n}\n"
  },
  {
    "path": "web/README.md",
    "content": "# One API 的前端界面\n\n> 每个文件夹代表一个主题，欢迎提交你的主题\n\n> [!WARNING]\n> 不是每一个主题都及时同步了所有功能，由于精力有限，优先更新默认主题，其他主题欢迎 & 期待 PR\n\n## 提交新的主题\n\n> 欢迎在页面底部保留你和 One API 的版权信息以及指向链接\n\n1. 在 `web` 文件夹下新建一个文件夹，文件夹名为主题名。\n2. 把你的主题文件放到这个文件夹下。\n3. 修改你的 `package.json` 文件，把 `build` 命令改为：`\"build\": \"react-scripts build && mv -f build ../build/default\"`，其中 `default` 为你的主题名。\n4. 修改 `common/config/config.go` 中的 `ValidThemes`，把你的主题名称注册进去。\n5. 修改 `web/THEMES` 文件，这里也需要同步修改。\n\n## 主题列表\n\n### 主题：default\n\n默认主题，由 [JustSong](https://github.com/songquanpeng) 开发。\n\n预览：\n|![image](https://github.com/songquanpeng/one-api/assets/39998050/ccfbc668-3a7f-4bc1-87da-7eacfd7bf371)|![image](https://github.com/songquanpeng/one-api/assets/39998050/a63ed547-44b9-45db-b43a-ecea07d60840)|\n|:---:|:---:|\n\n### 主题：berry\n\n由 [MartialBE](https://github.com/MartialBE) 开发。\n\n预览：\n|||\n|:---:|:---:|\n|![image](https://github.com/songquanpeng/one-api/assets/42402987/36aff5c6-c5ff-4a90-8e3d-33d5cff34cbf)|![image](https://github.com/songquanpeng/one-api/assets/42402987/9ac63b36-5140-4064-8fad-fc9d25821509)|\n|![image](https://github.com/songquanpeng/one-api/assets/42402987/fb2b1c64-ef24-4027-9b80-0cd9d945a47f)|![image](https://github.com/songquanpeng/one-api/assets/42402987/b6b649ec-2888-4324-8b2d-d5e11554eed6)|\n|![image](https://github.com/songquanpeng/one-api/assets/42402987/6d3b22e0-436b-4e26-8911-bcc993c6a2bd)|![image](https://github.com/songquanpeng/one-api/assets/42402987/eef1e224-7245-44d7-804e-9d1c8fa3f29c)|\n\n### 主题：air\n由 [Calon](https://github.com/Calcium-Ion) 开发。\n|![image](https://github.com/songquanpeng/songquanpeng.github.io/assets/39998050/1ddb274b-a715-4e81-858b-857d520b6ff4)|![image](https://github.com/songquanpeng/songquanpeng.github.io/assets/39998050/163b0b8e-1f73-49cb-b632-3dcb986b56d5)|\n|:---:|:---:|\n\n\n#### 开发说明\n\n请查看 [web/berry/README.md](https://github.com/songquanpeng/one-api/tree/main/web/berry/README.md)\n"
  },
  {
    "path": "web/THEMES",
    "content": "default\nberry\nair\n"
  },
  {
    "path": "web/air/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.idea\npackage-lock.json\nyarn.lock"
  },
  {
    "path": "web/air/README.md",
    "content": "# React Template\n\n## Basic Usages\n\n```shell\n# Runs the app in the development mode\nnpm start\n\n# Builds the app for production to the `build` folder\nnpm run build\n```\n\nIf you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,\nfor example: `REACT_APP_SERVER=http://your.domain.com`.\n\nBefore you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.\n\n## Reference\n\n1. https://github.com/OIerDb-ng/OIerDb\n2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example"
  },
  {
    "path": "web/air/package.json",
    "content": "{\n  \"name\": \"react-template\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@douyinfe/semi-icons\": \"^2.46.1\",\n    \"@douyinfe/semi-ui\": \"^2.46.1\",\n    \"@visactor/react-vchart\": \"~1.8.8\",\n    \"@visactor/vchart\": \"~1.8.8\",\n    \"@visactor/vchart-semi-theme\": \"~1.8.8\",\n    \"axios\": \"^0.27.2\",\n    \"history\": \"^5.3.0\",\n    \"marked\": \"^4.1.1\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dropzone\": \"^14.2.3\",\n    \"react-fireworks\": \"^1.0.4\",\n    \"react-router-dom\": \"^6.3.0\",\n    \"react-scripts\": \"5.0.1\",\n    \"react-telegram-login\": \"^1.1.2\",\n    \"react-toastify\": \"^9.0.8\",\n    \"react-turnstile\": \"^1.0.5\",\n    \"semantic-ui-css\": \"^2.5.0\",\n    \"semantic-ui-react\": \"^2.1.3\",\n    \"usehooks-ts\": \"^2.9.1\"\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"build\": \"react-scripts build && mv -f build ../build/air\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\",\n      \"react-app/jest\"\n    ]\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"prettier\": \"2.8.8\",\n    \"typescript\": \"4.4.2\"\n  },\n  \"prettier\": {\n    \"singleQuote\": true,\n    \"jsxSingleQuote\": true\n  },\n  \"proxy\": \"http://localhost:3000\"\n}\n"
  },
  {
    "path": "web/air/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"utf-8\" />\n  <link rel=\"icon\" href=\"logo.png\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"theme-color\" content=\"#ffffff\" />\n  <meta\n          name=\"description\"\n          content=\"OpenAI 接口聚合管理，支持多种渠道包括 Azure，可用于二次分发管理 key，仅单可执行文件，已打包好 Docker 镜像，一键部署，开箱即用\"\n  />\n  <title>One API</title>\n</head>\n<body>\n<noscript>You need to enable JavaScript to run this app.</noscript>\n<div id=\"root\"></div>\n</body>\n</html>\n"
  },
  {
    "path": "web/air/public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "web/air/src/App.js",
    "content": "import React, { lazy, Suspense, useContext, useEffect } from 'react';\nimport { Route, Routes } from 'react-router-dom';\nimport Loading from './components/Loading';\nimport User from './pages/User';\nimport { PrivateRoute } from './components/PrivateRoute';\nimport RegisterForm from './components/RegisterForm';\nimport LoginForm from './components/LoginForm';\nimport NotFound from './pages/NotFound';\nimport Setting from './pages/Setting';\nimport EditUser from './pages/User/EditUser';\nimport { getLogo, getSystemName } from './helpers';\nimport PasswordResetForm from './components/PasswordResetForm';\nimport GitHubOAuth from './components/GitHubOAuth';\nimport PasswordResetConfirm from './components/PasswordResetConfirm';\nimport { UserContext } from './context/User';\nimport Channel from './pages/Channel';\nimport Token from './pages/Token';\nimport EditChannel from './pages/Channel/EditChannel';\nimport Redemption from './pages/Redemption';\nimport TopUp from './pages/TopUp';\nimport Log from './pages/Log';\nimport Chat from './pages/Chat';\nimport { Layout } from '@douyinfe/semi-ui';\nimport Midjourney from './pages/Midjourney';\nimport Detail from './pages/Detail';\n\nconst Home = lazy(() => import('./pages/Home'));\nconst About = lazy(() => import('./pages/About'));\n\nfunction App() {\n  const [userState, userDispatch] = useContext(UserContext);\n  // const [statusState, statusDispatch] = useContext(StatusContext);\n\n  const loadUser = () => {\n    let user = localStorage.getItem('user');\n    if (user) {\n      let data = JSON.parse(user);\n      userDispatch({ type: 'login', payload: data });\n    }\n  };\n\n  useEffect(() => {\n    loadUser();\n    let systemName = getSystemName();\n    if (systemName) {\n      document.title = systemName;\n    }\n    let logo = getLogo();\n    if (logo) {\n      let linkElement = document.querySelector('link[rel~=\\'icon\\']');\n      if (linkElement) {\n        linkElement.href = logo;\n      }\n    }\n  }, []);\n\n  return (\n    <Layout>\n      <Layout.Content>\n        <Routes>\n          <Route\n            path=\"/\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <Home />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/channel\"\n            element={\n              <PrivateRoute>\n                <Channel />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/channel/edit/:id\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <EditChannel />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/channel/add\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <EditChannel />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/token\"\n            element={\n              <PrivateRoute>\n                <Token />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/redemption\"\n            element={\n              <PrivateRoute>\n                <Redemption />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/user\"\n            element={\n              <PrivateRoute>\n                <User />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/user/edit/:id\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <EditUser />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/user/edit\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <EditUser />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/user/reset\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <PasswordResetConfirm />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/login\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <LoginForm />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/register\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <RegisterForm />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/reset\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <PasswordResetForm />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/oauth/github\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <GitHubOAuth />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/setting\"\n            element={\n              <PrivateRoute>\n                <Suspense fallback={<Loading></Loading>}>\n                  <Setting />\n                </Suspense>\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/topup\"\n            element={\n              <PrivateRoute>\n                <Suspense fallback={<Loading></Loading>}>\n                  <TopUp />\n                </Suspense>\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/log\"\n            element={\n              <PrivateRoute>\n                <Log />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/detail\"\n            element={\n              <PrivateRoute>\n                <Detail />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/midjourney\"\n            element={\n              <PrivateRoute>\n                <Midjourney />\n              </PrivateRoute>\n            }\n          />\n          <Route\n            path=\"/about\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <About />\n              </Suspense>\n            }\n          />\n          <Route\n            path=\"/chat\"\n            element={\n              <Suspense fallback={<Loading></Loading>}>\n                <Chat />\n              </Suspense>\n            }\n          />\n          <Route path=\"*\" element={\n            <NotFound />\n          } />\n        </Routes>\n      </Layout.Content>\n    </Layout>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "web/air/src/components/ChannelsTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API, isMobile, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string } from '../helpers';\n\nimport { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';\nimport { renderGroup, renderNumberWithPoint, renderQuota } from '../helpers/render';\nimport {\n  Button,\n  Dropdown,\n  Form,\n  InputNumber,\n  Popconfirm,\n  Space,\n  SplitButtonGroup,\n  Switch,\n  Table,\n  Tag,\n  Tooltip,\n  Typography\n} from '@douyinfe/semi-ui';\nimport EditChannel from '../pages/Channel/EditChannel';\nimport { IconTreeTriangleDown } from '@douyinfe/semi-icons';\n\nfunction renderTimestamp(timestamp) {\n  return (\n    <>\n      {timestamp2string(timestamp)}\n    </>\n  );\n}\n\nlet type2label = undefined;\n\nfunction renderType(type) {\n  if (!type2label) {\n    type2label = new Map();\n    for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {\n      type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];\n    }\n    type2label[0] = { value: 0, text: '未知类型', color: 'grey' };\n  }\n  return <Tag size=\"large\" color={type2label[type]?.color}>{type2label[type]?.text}</Tag>;\n}\n\nconst ChannelsTable = () => {\n  const columns = [\n    // {\n    //     title: '',\n    //     dataIndex: 'checkbox',\n    //     className: 'checkbox',\n    // },\n    {\n      title: 'ID',\n      dataIndex: 'id'\n    },\n    {\n      title: '名称',\n      dataIndex: 'name'\n    },\n    // {\n    //   title: '分组',\n    //   dataIndex: 'group',\n    //   render: (text, record, index) => {\n    //     return (\n    //       <div>\n    //         <Space spacing={2}>\n    //           {\n    //             text.split(',').map((item, index) => {\n    //               return (renderGroup(item));\n    //             })\n    //           }\n    //         </Space>\n    //       </div>\n    //     );\n    //   }\n    // },\n    {\n      title: '类型',\n      dataIndex: 'type',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderType(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '状态',\n      dataIndex: 'status',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderStatus(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '响应时间',\n      dataIndex: 'response_time',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderResponseTime(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '已用/剩余',\n      dataIndex: 'expired_time',\n      render: (text, record, index) => {\n        return (\n          <div>\n            <Space spacing={1}>\n              <Tooltip content={'已用额度'}>\n                <Tag color=\"white\" type=\"ghost\" size=\"large\">{renderQuota(record.used_quota)}</Tag>\n              </Tooltip>\n              <Tooltip content={'剩余额度' + record.balance + '，点击更新'}>\n                <Tag color=\"white\" type=\"ghost\" size=\"large\" onClick={() => {\n                  updateChannelBalance(record);\n                }}>${renderNumberWithPoint(record.balance)}</Tag>\n              </Tooltip>\n            </Space>\n          </div>\n        );\n      }\n    },\n    {\n      title: '优先级',\n      dataIndex: 'priority',\n      render: (text, record, index) => {\n        return (\n          <div>\n            <InputNumber\n              style={{ width: 70 }}\n              name=\"priority\"\n              onBlur={e => {\n                manageChannel(record.id, 'priority', record, e.target.value);\n              }}\n              keepFocus={true}\n              innerButtons\n              defaultValue={record.priority}\n              min={-999}\n            />\n          </div>\n        );\n      }\n    },\n    // {\n    //   title: '权重',\n    //   dataIndex: 'weight',\n    //   render: (text, record, index) => {\n    //     return (\n    //       <div>\n    //         <InputNumber\n    //           style={{ width: 70 }}\n    //           name=\"weight\"\n    //           onBlur={e => {\n    //             manageChannel(record.id, 'weight', record, e.target.value);\n    //           }}\n    //           keepFocus={true}\n    //           innerButtons\n    //           defaultValue={record.weight}\n    //           min={0}\n    //         />\n    //       </div>\n    //     );\n    //   }\n    // },\n    {\n      title: '',\n      dataIndex: 'operate',\n      render: (text, record, index) => (\n        <div>\n          {/* <SplitButtonGroup style={{ marginRight: 1 }} aria-label=\"测试操作项目组\">\n            <Button theme=\"light\" onClick={() => {\n              testChannel(record, '');\n            }}>测试</Button>\n            <Dropdown trigger=\"click\" position=\"bottomRight\" menu={record.test_models}\n            >\n              <Button style={{ padding: '8px 4px' }} type=\"primary\" icon={<IconTreeTriangleDown />}></Button>\n            </Dropdown>\n          </SplitButtonGroup> */}\n          <Button theme='light' type='primary' style={{ marginRight: 1 }} onClick={() => testChannel(record)}>测试</Button>\n          <Popconfirm\n            title=\"确定是否要删除此渠道？\"\n            content=\"此修改将不可逆\"\n            okType={'danger'}\n            position={'left'}\n            onConfirm={() => {\n              manageChannel(record.id, 'delete', record).then(\n                () => {\n                  removeRecord(record.id);\n                }\n              );\n            }}\n          >\n            <Button theme=\"light\" type=\"danger\" style={{ marginRight: 1 }}>删除</Button>\n          </Popconfirm>\n          {\n            record.status === 1 ?\n              <Button theme=\"light\" type=\"warning\" style={{ marginRight: 1 }} onClick={\n                async () => {\n                  manageChannel(\n                    record.id,\n                    'disable',\n                    record\n                  );\n                }\n              }>禁用</Button> :\n              <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }} onClick={\n                async () => {\n                  manageChannel(\n                    record.id,\n                    'enable',\n                    record\n                  );\n                }\n              }>启用</Button>\n          }\n          <Button theme=\"light\" type=\"tertiary\" style={{ marginRight: 1 }} onClick={\n            () => {\n              setEditingChannel(record);\n              setShowEdit(true);\n            }\n          }>编辑</Button>\n        </div>\n      )\n    }\n  ];\n\n  const [channels, setChannels] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [idSort, setIdSort] = useState(false);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searchGroup, setSearchGroup] = useState('');\n  const [searchModel, setSearchModel] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [updatingBalance, setUpdatingBalance] = useState(false);\n  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);\n  const [showPrompt, setShowPrompt] = useState(shouldShowPrompt('channel-test'));\n  const [channelCount, setChannelCount] = useState(pageSize);\n  const [groupOptions, setGroupOptions] = useState([]);\n  const [showEdit, setShowEdit] = useState(false);\n  const [enableBatchDelete, setEnableBatchDelete] = useState(false);\n  const [editingChannel, setEditingChannel] = useState({\n    id: undefined\n  });\n  const [selectedChannels, setSelectedChannels] = useState([]);\n\n  const removeRecord = id => {\n    let newDataSource = [...channels];\n    if (id != null) {\n      let idx = newDataSource.findIndex(data => data.id === id);\n\n      if (idx > -1) {\n        newDataSource.splice(idx, 1);\n        setChannels(newDataSource);\n      }\n    }\n  };\n\n  const setChannelFormat = (channels) => {\n    for (let i = 0; i < channels.length; i++) {\n      channels[i].key = '' + channels[i].id;\n      let test_models = [];\n      channels[i].models.split(',').forEach((item, index) => {\n        test_models.push({\n          node: 'item',\n          name: item,\n          onClick: () => {\n            testChannel(channels[i], item);\n          }\n        });\n      });\n      channels[i].test_models = test_models;\n    }\n    // data.key = '' + data.id\n    setChannels(channels);\n    if (channels.length >= pageSize) {\n      setChannelCount(channels.length + pageSize);\n    } else {\n      setChannelCount(channels.length);\n    }\n  };\n\n  const loadChannels = async (startIdx, pageSize, idSort) => {\n    setLoading(true);\n    const res = await API.get(`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setChannelFormat(data);\n      } else {\n        let newChannels = [...channels];\n        newChannels.splice(startIdx * pageSize, data.length, ...data);\n        setChannelFormat(newChannels);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const refresh = async () => {\n    await loadChannels(activePage - 1, pageSize, idSort);\n  };\n\n  useEffect(() => {\n    // console.log('default effect')\n    const localIdSort = localStorage.getItem('id-sort') === 'true';\n    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;\n    setIdSort(localIdSort);\n    setPageSize(localPageSize);\n    loadChannels(0, localPageSize, localIdSort)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n    fetchGroups().then();\n  }, []);\n\n  const manageChannel = async (id, action, record, value) => {\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(`/api/channel/${id}/`);\n        break;\n      case 'enable':\n        data.status = 1;\n        res = await API.put('/api/channel/', data);\n        break;\n      case 'disable':\n        data.status = 2;\n        res = await API.put('/api/channel/', data);\n        break;\n      case 'priority':\n        if (value === '') {\n          return;\n        }\n        data.priority = parseInt(value);\n        res = await API.put('/api/channel/', data);\n        break;\n      case 'weight':\n        if (value === '') {\n          return;\n        }\n        data.weight = parseInt(value);\n        if (data.weight < 0) {\n          data.weight = 0;\n        }\n        res = await API.put('/api/channel/', data);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      let channel = res.data.data;\n      let newChannels = [...channels];\n      if (action === 'delete') {\n\n      } else {\n        record.status = channel.status;\n      }\n      setChannels(newChannels);\n    } else {\n      showError(message);\n    }\n  };\n\n  const renderStatus = (status) => {\n    switch (status) {\n      case 1:\n        return <Tag size=\"large\" color=\"green\">已启用</Tag>;\n      case 2:\n        return (\n          <Tag size=\"large\" color=\"yellow\">\n            已禁用\n          </Tag>\n        );\n      case 3:\n        return (\n          <Tag size=\"large\" color=\"yellow\">\n            自动禁用\n          </Tag>\n        );\n      default:\n        return (\n          <Tag size=\"large\" color=\"grey\">\n            未知状态\n          </Tag>\n        );\n    }\n  };\n\n  const renderResponseTime = (responseTime) => {\n    let time = responseTime / 1000;\n    time = time.toFixed(2) + ' 秒';\n    if (responseTime === 0) {\n      return <Tag size=\"large\" color=\"grey\">未测试</Tag>;\n    } else if (responseTime <= 1000) {\n      return <Tag size=\"large\" color=\"green\">{time}</Tag>;\n    } else if (responseTime <= 3000) {\n      return <Tag size=\"large\" color=\"lime\">{time}</Tag>;\n    } else if (responseTime <= 5000) {\n      return <Tag size=\"large\" color=\"yellow\">{time}</Tag>;\n    } else {\n      return <Tag size=\"large\" color=\"red\">{time}</Tag>;\n    }\n  };\n\n  const searchChannels = async (searchKeyword, searchGroup, searchModel) => {\n    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {\n      // if keyword is blank, load files instead.\n      await loadChannels(0, pageSize, idSort);\n      setActivePage(1);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setChannels(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const testChannel = async (record, model) => {\n    const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);\n    const { success, message, time } = res.data;\n    if (success) {\n      record.response_time = time * 1000;\n      record.test_time = Date.now() / 1000;\n      showInfo(`渠道 ${record.name} 测试成功，耗时 ${time.toFixed(2)} 秒。`);\n    } else {\n      showError(message);\n    }\n  };\n\n  const testChannels = async (scope) => {\n    const res = await API.get(`/api/channel/test?scope=${scope}`);\n    const { success, message } = res.data;\n    if (success) {\n      showInfo('已成功开始测试渠道，请刷新页面查看结果。');\n    } else {\n      showError(message);\n    }\n  };\n\n  const deleteAllDisabledChannels = async () => {\n    const res = await API.delete(`/api/channel/disabled`);\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`已删除所有禁用渠道，共计 ${data} 个`);\n      await refresh();\n    } else {\n      showError(message);\n    }\n  };\n\n  const updateChannelBalance = async (record) => {\n    const res = await API.get(`/api/channel/update_balance/${record.id}/`);\n    const { success, message, balance } = res.data;\n    if (success) {\n      record.balance = balance;\n      record.balance_updated_time = Date.now() / 1000;\n      showInfo(`渠道 ${record.name} 余额更新成功！`);\n    } else {\n      showError(message);\n    }\n  };\n\n  const updateAllChannelsBalance = async () => {\n    setUpdatingBalance(true);\n    const res = await API.get(`/api/channel/update_balance`);\n    const { success, message } = res.data;\n    if (success) {\n      showInfo('已更新完毕所有已启用渠道余额！');\n    } else {\n      showError(message);\n    }\n    setUpdatingBalance(false);\n  };\n\n  const batchDeleteChannels = async () => {\n    if (selectedChannels.length === 0) {\n      showError('请先选择要删除的渠道！');\n      return;\n    }\n    setLoading(true);\n    let ids = [];\n    selectedChannels.forEach((channel) => {\n      ids.push(channel.id);\n    });\n    const res = await API.post(`/api/channel/batch`, { ids: ids });\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`已删除 ${data} 个渠道！`);\n      await refresh();\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const fixChannelsAbilities = async () => {\n    const res = await API.post(`/api/channel/fix`);\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`已修复 ${data} 个渠道！`);\n      await refresh();\n    } else {\n      showError(message);\n    }\n  };\n\n  let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);\n\n  const handlePageChange = page => {\n    setActivePage(page);\n    if (page === Math.ceil(channels.length / pageSize) + 1) {\n      // In this case we have to load more data and then append them.\n      loadChannels(page - 1, pageSize, idSort).then(r => {\n      });\n    }\n  };\n\n  const handlePageSizeChange = async (size) => {\n    localStorage.setItem('page-size', size + '');\n    setPageSize(size);\n    setActivePage(1);\n    loadChannels(0, size, idSort)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  };\n\n  const fetchGroups = async () => {\n    try {\n      let res = await API.get(`/api/group/`);\n      // add 'all' option\n      // res.data.data.unshift('all');\n      setGroupOptions(res.data.data.map((group) => ({\n        label: group,\n        value: group\n      })));\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n\n  const closeEdit = () => {\n    setShowEdit(false);\n  };\n\n  const handleRow = (record, index) => {\n    if (record.status !== 1) {\n      return {\n        style: {\n          background: 'var(--semi-color-disabled-border)'\n        }\n      };\n    } else {\n      return {};\n    }\n  };\n\n\n  return (\n    <>\n      <EditChannel refresh={refresh} visible={showEdit} handleClose={closeEdit} editingChannel={editingChannel} />\n      <div style={{ display: \"flex\", placeItems: \"center\", justifyContent: \"space-between\" }}>\n        <Form onSubmit={() => {\n          searchChannels(searchKeyword, searchGroup, searchModel);\n        }} labelPosition=\"left\">\n          <div style={{ display: 'flex' }}>\n            <Space>\n              <Form.Input\n                field=\"search_keyword\"\n                label=\"搜索\"\n                placeholder=\"ID，名称和密钥 ...\"\n                value={searchKeyword}\n                loading={searching}\n                onChange={(v) => {\n                  setSearchKeyword(v.trim());\n                }}\n              />\n              {/* <Form.Input\n              field=\"search_model\"\n              label=\"模型\"\n              placeholder=\"模型关键字\"\n              value={searchModel}\n              loading={searching}\n              onChange={(v) => {\n                setSearchModel(v.trim());\n              }}\n            />\n            <Form.Select field=\"group\" label=\"分组\" optionList={groupOptions} onChange={(v) => {\n              setSearchGroup(v);\n              searchChannels(searchKeyword, v, searchModel);\n            }} /> */}\n              <Button label=\"查询\" type=\"primary\" htmlType=\"submit\" className=\"btn-margin-right\"\n                style={{ marginRight: 8 }}>查询</Button>\n            </Space>\n          </div>\n        </Form>\n        <div style={{\n          display: isMobile() ? '' : 'flex',\n          marginTop: isMobile() ? 0 : -45,\n          zIndex: 999,\n          position: 'relative',\n          pointerEvents: 'none'\n        }}>\n          <Space style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}>\n            <Button theme=\"light\" type=\"primary\" style={{ marginRight: 8 }} onClick={\n              () => {\n                setEditingChannel({\n                  id: undefined\n                });\n                setShowEdit(true);\n              }\n            }>添加新的渠道</Button>\n            <Popconfirm\n              title=\"确定？\"\n              okType={'warning'}\n              onConfirm={() => { testChannels(\"all\") }}\n              position={isMobile() ? 'top' : 'left'}\n            >\n              <Button theme=\"light\" type=\"warning\" style={{ marginRight: 8 }}>测试所有渠道</Button>\n            </Popconfirm>\n            <Popconfirm\n              title=\"确定？\"\n              okType={'warning'}\n              onConfirm={() => { testChannels(\"disabled\") }}\n              position={isMobile() ? 'top' : 'left'}\n            >\n              <Button theme=\"light\" type=\"warning\" style={{ marginRight: 8 }}>测试禁用渠道</Button>\n            </Popconfirm>\n            {/* <Popconfirm\n            title=\"确定？\"\n            okType={'secondary'}\n            onConfirm={updateAllChannelsBalance}\n          >\n            <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 8 }}>更新所有已启用渠道余额</Button>\n          </Popconfirm> */}\n            <Popconfirm\n              title=\"确定是否要删除禁用渠道？\"\n              content=\"此修改将不可逆\"\n              okType={'danger'}\n              onConfirm={deleteAllDisabledChannels}\n              position={isMobile() ? 'top' : 'left'}\n            >\n              <Button theme=\"light\" type=\"danger\" style={{ marginRight: 8 }}>删除禁用渠道</Button>\n            </Popconfirm>\n\n            <Button theme=\"light\" type=\"primary\" style={{ marginRight: 8 }} onClick={refresh}>刷新</Button>\n          </Space>\n          {/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}\n\n          {/*</div>*/}\n        </div>\n        {/* <div style={{ marginTop: 20 }}>\n          <Space>\n            <Typography.Text strong>开启批量删除</Typography.Text>\n            <Switch label=\"开启批量删除\" uncheckedText=\"关\" aria-label=\"是否开启批量删除\" onChange={(v) => {\n              setEnableBatchDelete(v);\n            }}></Switch>\n            <Popconfirm\n              title=\"确定是否要删除所选渠道？\"\n              content=\"此修改将不可逆\"\n              okType={'danger'}\n              onConfirm={batchDeleteChannels}\n              disabled={!enableBatchDelete}\n              position={'top'}\n            >\n              <Button disabled={!enableBatchDelete} theme=\"light\" type=\"danger\"\n                style={{ marginRight: 8 }}>删除所选渠道</Button>\n            </Popconfirm>\n            <Popconfirm\n              title=\"确定是否要修复数据库一致性？\"\n              content=\"进行该操作时，可能导致渠道访问错误，请仅在数据库出现问题时使用\"\n              okType={'warning'}\n              onConfirm={fixChannelsAbilities}\n              position={'top'}\n            >\n              <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 8 }}>修复数据库一致性</Button>\n            </Popconfirm>\n          </Space>\n        </div>\n        <div style={{ marginTop: 10, display: 'flex' }}>\n          <Space>\n            <Space>\n              <Typography.Text strong>使用ID排序</Typography.Text>\n              <Switch checked={idSort} label=\"使用ID排序\" uncheckedText=\"关\" aria-label=\"是否用ID排序\" onChange={(v) => {\n                localStorage.setItem('id-sort', v + '');\n                setIdSort(v);\n                loadChannels(0, pageSize, v)\n                  .then()\n                  .catch((reason) => {\n                    showError(reason);\n                  });\n              }}></Switch>\n            </Space>\n          </Space>\n        </div> */}\n      </div>\n      <Table className={'channel-table'} style={{ marginTop: 15 }} columns={columns} dataSource={pageData} pagination={{\n        currentPage: activePage,\n        pageSize: pageSize,\n        total: channelCount,\n        pageSizeOpts: [10, 20, 50, 100],\n        showSizeChanger: true,\n        formatPageText: (page) => '',\n        onPageSizeChange: (size) => {\n          handlePageSizeChange(size).then();\n        },\n        onPageChange: handlePageChange\n      }} loading={loading} onRow={handleRow} rowSelection={\n        enableBatchDelete ?\n          {\n            onChange: (selectedRowKeys, selectedRows) => {\n              // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);\n              setSelectedChannels(selectedRows);\n            }\n          } : null\n      } />\n    </>\n  );\n};\n\nexport default ChannelsTable;\n"
  },
  {
    "path": "web/air/src/components/Footer.js",
    "content": "import React, { useEffect, useState } from 'react';\n\nimport { Container, Segment } from 'semantic-ui-react';\nimport { getFooterHTML, getSystemName } from '../helpers';\n\nconst Footer = () => {\n  const systemName = getSystemName();\n  const [footer, setFooter] = useState(getFooterHTML());\n  let remainCheckTimes = 5;\n\n  const loadFooter = () => {\n    let footer_html = localStorage.getItem('footer_html');\n    if (footer_html) {\n      setFooter(footer_html);\n    }\n  };\n\n  useEffect(() => {\n    const timer = setInterval(() => {\n      if (remainCheckTimes <= 0) {\n        clearInterval(timer);\n        return;\n      }\n      remainCheckTimes--;\n      loadFooter();\n    }, 200);\n    return () => clearTimeout(timer);\n  }, []);\n\n  return (\n    <Segment vertical>\n      <Container textAlign='center'>\n        {footer ? (\n          <div\n            className='custom-footer'\n            dangerouslySetInnerHTML={{ __html: footer }}\n          ></div>\n        ) : (\n          <div className='custom-footer'>\n            <a\n              href='https://github.com/songquanpeng/one-api'\n              target='_blank'\n            >\n              {systemName} {process.env.REACT_APP_VERSION}{' '}\n            </a>\n            由{' '}\n            <a href='https://github.com/songquanpeng' target='_blank'>\n              JustSong\n            </a>{' '}\n            构建，主题 air 来自{' '}\n            <a href='https://github.com/Calcium-Ion' target='_blank'>\n              Calon\n            </a>{' '}，源代码遵循{' '}\n            <a href='https://opensource.org/licenses/mit-license.php'>\n              MIT 协议\n            </a>\n          </div>\n        )}\n      </Container>\n    </Segment>\n  );\n};\n\nexport default Footer;\n"
  },
  {
    "path": "web/air/src/components/GitHubOAuth.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { Dimmer, Loader, Segment } from 'semantic-ui-react';\nimport { useNavigate, useSearchParams } from 'react-router-dom';\nimport { API, showError, showSuccess } from '../helpers';\nimport { UserContext } from '../context/User';\n\nconst GitHubOAuth = () => {\n  const [searchParams, setSearchParams] = useSearchParams();\n\n  const [userState, userDispatch] = useContext(UserContext);\n  const [prompt, setPrompt] = useState('处理中...');\n  const [processing, setProcessing] = useState(true);\n\n  let navigate = useNavigate();\n\n  const sendCode = async (code, state, count) => {\n    const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (message === 'bind') {\n        showSuccess('绑定成功！');\n        navigate('/setting');\n      } else {\n        userDispatch({ type: 'login', payload: data });\n        localStorage.setItem('user', JSON.stringify(data));\n        showSuccess('登录成功！');\n        navigate('/');\n      }\n    } else {\n      showError(message);\n      if (count === 0) {\n        setPrompt(`操作失败，重定向至登录界面中...`);\n        navigate('/setting'); // in case this is failed to bind GitHub\n        return;\n      }\n      count++;\n      setPrompt(`出现错误，第 ${count} 次重试中...`);\n      await new Promise((resolve) => setTimeout(resolve, count * 2000));\n      await sendCode(code, state, count);\n    }\n  };\n\n  useEffect(() => {\n    let code = searchParams.get('code');\n    let state = searchParams.get('state');\n    sendCode(code, state, 0).then();\n  }, []);\n\n  return (\n    <Segment style={{ minHeight: '300px' }}>\n      <Dimmer active inverted>\n        <Loader size=\"large\">{prompt}</Loader>\n      </Dimmer>\n    </Segment>\n  );\n};\n\nexport default GitHubOAuth;\n"
  },
  {
    "path": "web/air/src/components/HeaderBar.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { Link, useNavigate } from 'react-router-dom';\nimport { UserContext } from '../context/User';\n\nimport { API, getLogo, getSystemName, showSuccess } from '../helpers';\nimport '../index.css';\n\nimport fireworks from 'react-fireworks';\n\nimport { IconHelpCircle, IconKey, IconUser } from '@douyinfe/semi-icons';\nimport { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';\nimport { stringToColor } from '../helpers/render';\n\n// HeaderBar Buttons\nlet headerButtons = [\n  {\n    text: '关于',\n    itemKey: 'about',\n    to: '/about',\n    icon: <IconHelpCircle />\n  }\n];\n\nif (localStorage.getItem('chat_link')) {\n  headerButtons.splice(1, 0, {\n    name: '聊天',\n    to: '/chat',\n    icon: 'comments'\n  });\n}\n\nconst HeaderBar = () => {\n  const [userState, userDispatch] = useContext(UserContext);\n  let navigate = useNavigate();\n\n  const [showSidebar, setShowSidebar] = useState(false);\n  const [dark, setDark] = useState(false);\n  const systemName = getSystemName();\n  const logo = getLogo();\n  var themeMode = localStorage.getItem('theme-mode');\n  const currentDate = new Date();\n  // enable fireworks on new year(1.1 and 2.9-2.24)\n  const isNewYear = (currentDate.getMonth() === 0 && currentDate.getDate() === 1) || (currentDate.getMonth() === 1 && currentDate.getDate() >= 9 && currentDate.getDate() <= 24);\n\n  async function logout() {\n    setShowSidebar(false);\n    await API.get('/api/user/logout');\n    showSuccess('注销成功!');\n    userDispatch({ type: 'logout' });\n    localStorage.removeItem('user');\n    navigate('/login');\n  }\n\n  const handleNewYearClick = () => {\n    fireworks.init('root', {});\n    fireworks.start();\n    setTimeout(() => {\n      fireworks.stop();\n      setTimeout(() => {\n        window.location.reload();\n      }, 10000);\n    }, 3000);\n  };\n\n  useEffect(() => {\n    if (themeMode === 'dark') {\n      switchMode(true);\n    }\n    if (isNewYear) {\n      console.log('Happy New Year!');\n    }\n  }, []);\n\n  const switchMode = (model) => {\n    const body = document.body;\n    if (!model) {\n      body.removeAttribute('theme-mode');\n      localStorage.setItem('theme-mode', 'light');\n    } else {\n      body.setAttribute('theme-mode', 'dark');\n      localStorage.setItem('theme-mode', 'dark');\n    }\n    setDark(model);\n  };\n  return (\n    <>\n      <Layout>\n        <div style={{ width: '100%' }}>\n          <Nav\n            mode={'horizontal'}\n            // bodyStyle={{ height: 100 }}\n            renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {\n              const routerMap = {\n                about: '/about',\n                login: '/login',\n                register: '/register'\n              };\n              return (\n                <Link\n                  style={{ textDecoration: 'none' }}\n                  to={routerMap[props.itemKey]}\n                >\n                  {itemElement}\n                </Link>\n              );\n            }}\n            selectedKeys={[]}\n            // items={headerButtons}\n            onSelect={key => {\n\n            }}\n            footer={\n              <>\n                {isNewYear &&\n                  // happy new year\n                  <Dropdown\n                    position=\"bottomRight\"\n                    render={\n                      <Dropdown.Menu>\n                        <Dropdown.Item onClick={handleNewYearClick}>Happy New Year!!!</Dropdown.Item>\n                      </Dropdown.Menu>\n                    }\n                  >\n                    <Nav.Item itemKey={'new-year'} text={'🏮'} />\n                  </Dropdown>\n                }\n                <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} />\n                <Switch checkedText=\"🌞\" size={'large'} checked={dark} uncheckedText=\"🌙\" onChange={switchMode} />\n                {userState.user ?\n                  <>\n                    <Dropdown\n                      position=\"bottomRight\"\n                      render={\n                        <Dropdown.Menu>\n                          <Dropdown.Item onClick={logout}>退出</Dropdown.Item>\n                        </Dropdown.Menu>\n                      }\n                    >\n                      <Avatar size=\"small\" color={stringToColor(userState.user.username)} style={{ margin: 4 }}>\n                        {userState.user.username[0]}\n                      </Avatar>\n                      <span>{userState.user.username}</span>\n                    </Dropdown>\n                  </>\n                  :\n                  <>\n                    <Nav.Item itemKey={'login'} text={'登录'} icon={<IconKey />} />\n                    <Nav.Item itemKey={'register'} text={'注册'} icon={<IconUser />} />\n                  </>\n                }\n              </>\n            }\n          >\n          </Nav>\n        </div>\n      </Layout>\n    </>\n  );\n};\n\nexport default HeaderBar;\n"
  },
  {
    "path": "web/air/src/components/Loading.js",
    "content": "import React from 'react';\nimport { Dimmer, Loader, Segment } from 'semantic-ui-react';\n\nconst Loading = ({ prompt: name = 'page' }) => {\n  return (\n    <Segment style={{ height: 100 }}>\n      <Dimmer active inverted>\n        <Loader indeterminate>加载{name}中...</Loader>\n      </Dimmer>\n    </Segment>\n  );\n};\n\nexport default Loading;\n"
  },
  {
    "path": "web/air/src/components/LoginForm.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { Link, useNavigate, useSearchParams } from 'react-router-dom';\nimport { UserContext } from '../context/User';\nimport { API, getLogo, showError, showInfo, showSuccess } from '../helpers';\nimport { onGitHubOAuthClicked } from './utils';\nimport Turnstile from 'react-turnstile';\nimport { Button, Card, Divider, Form, Icon, Layout, Modal } from '@douyinfe/semi-ui';\nimport Title from '@douyinfe/semi-ui/lib/es/typography/title';\nimport Text from '@douyinfe/semi-ui/lib/es/typography/text';\nimport TelegramLoginButton from 'react-telegram-login';\n\nimport { IconGithubLogo } from '@douyinfe/semi-icons';\nimport WeChatIcon from './WeChatIcon';\n\nconst LoginForm = () => {\n  const [inputs, setInputs] = useState({\n    username: '',\n    password: '',\n    wechat_verification_code: ''\n  });\n  const [searchParams, setSearchParams] = useSearchParams();\n  const [submitted, setSubmitted] = useState(false);\n  const { username, password } = inputs;\n  const [userState, userDispatch] = useContext(UserContext);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  let navigate = useNavigate();\n  const [status, setStatus] = useState({});\n  const logo = getLogo();\n\n  useEffect(() => {\n    if (searchParams.get('expired')) {\n      showError('未登录或登录已过期，请重新登录！');\n    }\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      setStatus(status);\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n  }, []);\n\n  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);\n\n  const onWeChatLoginClicked = () => {\n    setShowWeChatLoginModal(true);\n  };\n\n  const onSubmitWeChatVerificationCode = async () => {\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    const res = await API.get(\n      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      userDispatch({ type: 'login', payload: data });\n      localStorage.setItem('user', JSON.stringify(data));\n      navigate('/');\n      showSuccess('登录成功！');\n      setShowWeChatLoginModal(false);\n    } else {\n      showError(message);\n    }\n  };\n\n  function handleChange(name, value) {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  }\n\n  async function handleSubmit(e) {\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    setSubmitted(true);\n    if (username && password) {\n      const res = await API.post(`/api/user/login?turnstile=${turnstileToken}`, {\n        username,\n        password\n      });\n      const { success, message, data } = res.data;\n      if (success) {\n        userDispatch({ type: 'login', payload: data });\n        localStorage.setItem('user', JSON.stringify(data));\n        showSuccess('登录成功！');\n        if (username === 'root' && password === '123456') {\n          Modal.error({ title: '您正在使用默认密码！', content: '请立刻修改默认密码！', centered: true });\n        }\n        navigate('/token');\n      } else {\n        showError(message);\n      }\n    } else {\n      showError('请输入用户名和密码！');\n    }\n  }\n\n  // 添加Telegram登录处理函数\n  const onTelegramLoginClicked = async (response) => {\n    const fields = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash', 'lang'];\n    const params = {};\n    fields.forEach((field) => {\n      if (response[field]) {\n        params[field] = response[field];\n      }\n    });\n    const res = await API.get(`/api/oauth/telegram/login`, { params });\n    const { success, message, data } = res.data;\n    if (success) {\n      userDispatch({ type: 'login', payload: data });\n      localStorage.setItem('user', JSON.stringify(data));\n      showSuccess('登录成功！');\n      navigate('/');\n    } else {\n      showError(message);\n    }\n  };\n\n  return (\n    <div>\n      <Layout>\n        <Layout.Header>\n        </Layout.Header>\n        <Layout.Content>\n          <div style={{ justifyContent: 'center', display: 'flex', marginTop: 120 }}>\n            <div style={{ width: 500 }}>\n              <Card>\n                <Title heading={2} style={{ textAlign: 'center' }}>\n                  用户登录\n                </Title>\n                <Form>\n                  <Form.Input\n                    field={'username'}\n                    label={'用户名'}\n                    placeholder=\"用户名\"\n                    name=\"username\"\n                    onChange={(value) => handleChange('username', value)}\n                  />\n                  <Form.Input\n                    field={'password'}\n                    label={'密码'}\n                    placeholder=\"密码\"\n                    name=\"password\"\n                    type=\"password\"\n                    onChange={(value) => handleChange('password', value)}\n                  />\n\n                  <Button theme=\"solid\" style={{ width: '100%' }} type={'primary'} size=\"large\"\n                          htmlType={'submit'} onClick={handleSubmit}>\n                    登录\n                  </Button>\n                </Form>\n                <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>\n                  <Text>\n                    没有账号请先 <Link to=\"/register\">注册账号</Link>\n                  </Text>\n                  <Text>\n                    忘记密码 <Link to=\"/reset\">点击重置</Link>\n                  </Text>\n                </div>\n                {status.github_oauth || status.wechat_login || status.telegram_oauth ? (\n                  <>\n                    <Divider margin=\"12px\" align=\"center\">\n                      第三方登录\n                    </Divider>\n                    <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>\n                      {status.github_oauth ? (\n                        <Button\n                          type=\"primary\"\n                          icon={<IconGithubLogo />}\n                          onClick={() => onGitHubOAuthClicked(status.github_client_id)}\n                        />\n                      ) : (\n                        <></>\n                      )}\n                      {status.wechat_login ? (\n                        <Button\n                          type=\"primary\"\n                          style={{ color: 'rgba(var(--semi-green-5), 1)' }}\n                          icon={<Icon svg={<WeChatIcon />} />}\n                          onClick={onWeChatLoginClicked}\n                        />\n                      ) : (\n                        <></>\n                      )}\n\n                      {status.telegram_oauth ? (\n                        <TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />\n                      ) : (\n                        <></>\n                      )}\n                    </div>\n                  </>\n                ) : (\n                  <></>\n                )}\n                <Modal\n                  title=\"微信扫码登录\"\n                  visible={showWeChatLoginModal}\n                  maskClosable={true}\n                  onOk={onSubmitWeChatVerificationCode}\n                  onCancel={() => setShowWeChatLoginModal(false)}\n                  okText={'登录'}\n                  size={'small'}\n                  centered={true}\n                >\n                  <div style={{ display: 'flex', alignItem: 'center', flexDirection: 'column' }}>\n                    <img src={status.wechat_qrcode} />\n                  </div>\n                  <div style={{ textAlign: 'center' }}>\n                    <p>\n                      微信扫码关注公众号，输入「验证码」获取验证码（三分钟内有效）\n                    </p>\n                  </div>\n                  <Form size=\"large\">\n                    <Form.Input\n                      field={'wechat_verification_code'}\n                      placeholder=\"验证码\"\n                      label={'验证码'}\n                      value={inputs.wechat_verification_code}\n                      onChange={(value) => handleChange('wechat_verification_code', value)}\n                    />\n                  </Form>\n                </Modal>\n              </Card>\n              {turnstileEnabled ? (\n                <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>\n                  <Turnstile\n                    sitekey={turnstileSiteKey}\n                    onVerify={(token) => {\n                      setTurnstileToken(token);\n                    }}\n                  />\n                </div>\n              ) : (\n                <></>\n              )}\n            </div>\n          </div>\n\n        </Layout.Content>\n      </Layout>\n    </div>\n  );\n};\n\nexport default LoginForm;\n"
  },
  {
    "path": "web/air/src/components/LogsTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';\n\nimport { Avatar, Button, Form, Layout, Modal, Select, Space, Spin, Table, Tag } from '@douyinfe/semi-ui';\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderNumber, renderQuota, stringToColor } from '../helpers/render';\nimport Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';\n\nconst { Header } = Layout;\n\nfunction renderTimestamp(timestamp) {\n  return (<>\n    {timestamp2string(timestamp)}\n  </>);\n}\n\nconst MODE_OPTIONS = [{ key: 'all', text: '全部用户', value: 'all' }, { key: 'self', text: '当前用户', value: 'self' }];\n\nconst colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo', 'light-blue', 'lime', 'orange', 'pink', 'purple', 'red', 'teal', 'violet', 'yellow'];\n\nfunction renderType(type) {\n  switch (type) {\n    case 1:\n      return <Tag color=\"cyan\" size=\"large\"> 充值 </Tag>;\n    case 2:\n      return <Tag color=\"lime\" size=\"large\"> 消费 </Tag>;\n    case 3:\n      return <Tag color=\"orange\" size=\"large\"> 管理 </Tag>;\n    case 4:\n      return <Tag color=\"purple\" size=\"large\"> 系统 </Tag>;\n    case 5:\n      return <Tag color=\"violet\" size=\"large\"> 测试 </Tag>;\n    default:\n      return <Tag color=\"black\" size=\"large\"> 未知 </Tag>;\n  }\n}\n\nfunction renderIsStream(bool) {\n  if (bool) {\n    return <Tag color=\"blue\" size=\"large\">流</Tag>;\n  } else {\n    return <Tag color=\"purple\" size=\"large\">非流</Tag>;\n  }\n}\n\nfunction renderUseTime(type) {\n  const time = parseInt(type);\n  if (time < 101) {\n    return <Tag color=\"green\" size=\"large\"> {time} s </Tag>;\n  } else if (time < 300) {\n    return <Tag color=\"orange\" size=\"large\"> {time} s </Tag>;\n  } else {\n    return <Tag color=\"red\" size=\"large\"> {time} s </Tag>;\n  }\n}\n\nconst LogsTable = () => {\n  const columns = [{\n    title: '时间', dataIndex: 'timestamp2string'\n  }, {\n    title: '渠道',\n    dataIndex: 'channel',\n    className: isAdmin() ? 'tableShow' : 'tableHiddle',\n    render: (text, record, index) => {\n      return (isAdminUser ? record.type === 0 || record.type === 2 ? <div>\n        {<Tag color={colors[parseInt(text) % colors.length]} size=\"large\"> {text} </Tag>}\n      </div> : <></> : <></>);\n    }\n  }, {\n    title: '用户',\n    dataIndex: 'username',\n    className: isAdmin() ? 'tableShow' : 'tableHiddle',\n    render: (text, record, index) => {\n      return (isAdminUser ? <div>\n        <Avatar size=\"small\" color={stringToColor(text)} style={{ marginRight: 4 }}\n          onClick={() => showUserInfo(record.user_id)}>\n          {typeof text === 'string' && text.slice(0, 1)}\n        </Avatar>\n        {text}\n      </div> : <></>);\n    }\n  }, {\n    title: '令牌', dataIndex: 'token_name', render: (text, record, index) => {\n      return (record.type === 0 || record.type === 2 ? <div>\n        <Tag color=\"grey\" size=\"large\" onClick={() => {\n          copyText(text);\n        }}> {text} </Tag>\n      </div> : <></>);\n    }\n  }, {\n    title: '类型', dataIndex: 'type', render: (text, record, index) => {\n      return (<div>\n        {renderType(text)}\n      </div>);\n    }\n  }, {\n    title: '模型', dataIndex: 'model_name', render: (text, record, index) => {\n      return (record.type === 0 || record.type === 2 ? <div>\n        <Tag color={stringToColor(text)} size=\"large\" onClick={() => {\n          copyText(text);\n        }}> {text} </Tag>\n      </div> : <></>);\n    }\n  },\n  // {\n  //   title: '用时', dataIndex: 'use_time', render: (text, record, index) => {\n  //     return (<div>\n  //       <Space>\n  //         {renderUseTime(text)}\n  //         {renderIsStream(record.is_stream)}\n  //       </Space>\n  //     </div>);\n  //   }\n  // },\n  {\n    title: '提示', dataIndex: 'prompt_tokens', render: (text, record, index) => {\n      return (record.type === 0 || record.type === 2 ? <div>\n        {<span> {text} </span>}\n      </div> : <></>);\n    }\n  }, {\n    title: '补全', dataIndex: 'completion_tokens', render: (text, record, index) => {\n      return (parseInt(text) > 0 && (record.type === 0 || record.type === 2) ? <div>\n        {<span> {text} </span>}\n      </div> : <></>);\n    }\n  }, {\n    title: '花费', dataIndex: 'quota', render: (text, record, index) => {\n      return (record.type === 0 || record.type === 2 ? <div>\n        {renderQuota(text, 6)}\n      </div> : <></>);\n    }\n  }, {\n    title: '详情', dataIndex: 'content', render: (text, record, index) => {\n      return <Paragraph ellipsis={{ rows: 2, showTooltip: { type: 'popover', opts: { style: { width: 240 } } } }}\n        style={{ maxWidth: 240 }}>\n        {text}\n      </Paragraph>;\n    }\n  }];\n\n  const [logs, setLogs] = useState([]);\n  const [showStat, setShowStat] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [loadingStat, setLoadingStat] = useState(false);\n  const [activePage, setActivePage] = useState(1);\n  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);\n  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [logType, setLogType] = useState(0);\n  const isAdminUser = isAdmin();\n  let now = new Date();\n  // 初始化start_timestamp为前一天\n  const [inputs, setInputs] = useState({\n    username: '',\n    token_name: '',\n    model_name: '',\n    start_timestamp: timestamp2string(now.getTime() / 1000 - 86400),\n    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),\n    channel: ''\n  });\n  const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs;\n\n  const [stat, setStat] = useState({\n    quota: 0, token: 0\n  });\n\n  const handleInputChange = (value, name) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const getLogSelfStat = async () => {\n    let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n    let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n    let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setStat(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const getLogStat = async () => {\n    let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n    let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n    let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setStat(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const handleEyeClick = async () => {\n    setLoadingStat(true);\n    if (isAdminUser) {\n      await getLogStat();\n    } else {\n      await getLogSelfStat();\n    }\n    setShowStat(true);\n    setLoadingStat(false);\n  };\n\n  const showUserInfo = async (userId) => {\n    if (!isAdminUser) {\n      return;\n    }\n    const res = await API.get(`/api/user/${userId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      Modal.info({\n        title: '用户信息', content: <div style={{ padding: 12 }}>\n          <p>用户名: {data.username}</p>\n          <p>余额: {renderQuota(data.quota)}</p>\n          <p>已用额度：{renderQuota(data.used_quota)}</p>\n          <p>请求次数：{renderNumber(data.request_count)}</p>\n        </div>, centered: true\n      });\n    } else {\n      showError(message);\n    }\n  };\n\n  const setLogsFormat = (logs) => {\n    for (let i = 0; i < logs.length; i++) {\n      logs[i].timestamp2string = timestamp2string(logs[i].created_at);\n      logs[i].key = '' + logs[i].id;\n    }\n    // data.key = '' + data.id\n    setLogs(logs);\n    setLogCount(logs.length + ITEMS_PER_PAGE);\n    // console.log(logCount);\n  };\n\n  const loadLogs = async (startIdx, pageSize, logType = 0) => {\n    setLoading(true);\n\n    let url = '';\n    let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n    let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n    if (isAdminUser) {\n      url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`;\n    } else {\n      url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;\n    }\n    const res = await API.get(url);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setLogsFormat(data);\n      } else {\n        let newLogs = [...logs];\n        newLogs.splice(startIdx * pageSize, data.length, ...data);\n        setLogsFormat(newLogs);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const pageData = logs.slice((activePage - 1) * pageSize, activePage * pageSize);\n\n  const handlePageChange = page => {\n    setActivePage(page);\n    if (page === Math.ceil(logs.length / pageSize) + 1) {\n      // In this case we have to load more data and then append them.\n      loadLogs(page - 1, pageSize).then(r => {\n      });\n    }\n  };\n\n  const handlePageSizeChange = async (size) => {\n    localStorage.setItem('page-size', size + '');\n    setPageSize(size);\n    setActivePage(1);\n    loadLogs(0, size)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  };\n\n  const refresh = async (localLogType) => {\n    // setLoading(true);\n    setActivePage(1);\n    await loadLogs(0, pageSize, localLogType);\n  };\n\n  const copyText = async (text) => {\n    if (await copy(text)) {\n      showSuccess('已复制：' + text);\n    } else {\n      // setSearchKeyword(text);\n      Modal.error({ title: '无法复制到剪贴板，请手动复制', content: text });\n    }\n  };\n\n  useEffect(() => {\n    // console.log('default effect')\n    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;\n    setPageSize(localPageSize);\n    loadLogs(0, localPageSize)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, []);\n\n  const searchLogs = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadLogs(0, pageSize);\n      setActivePage(1);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setLogs(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  return (<>\n    <Layout>\n      <Header>\n        <Spin spinning={loadingStat}>\n          <h3>使用明细（总消耗额度：\n            <span onClick={handleEyeClick} style={{\n              cursor: 'pointer', color: 'gray'\n            }}>{showStat ? renderQuota(stat.quota) : '点击查看'}</span>\n            ）\n          </h3>\n        </Spin>\n      </Header>\n      <Form layout=\"horizontal\" style={{ marginTop: 10 }}>\n        <>\n          <Form.Input field=\"token_name\" label=\"令牌名称\" style={{ width: 176 }} value={token_name}\n            placeholder={'可选值'} name=\"token_name\"\n            onChange={value => handleInputChange(value, 'token_name')} />\n          <Form.Input field=\"model_name\" label=\"模型名称\" style={{ width: 176 }} value={model_name}\n            placeholder=\"可选值\"\n            name=\"model_name\"\n            onChange={value => handleInputChange(value, 'model_name')} />\n          <Form.DatePicker field=\"start_timestamp\" label=\"起始时间\" style={{ width: 272 }}\n            initValue={start_timestamp}\n            value={start_timestamp} type=\"dateTime\"\n            name=\"start_timestamp\"\n            onChange={value => handleInputChange(value, 'start_timestamp')} />\n          <Form.DatePicker field=\"end_timestamp\" fluid label=\"结束时间\" style={{ width: 272 }}\n            initValue={end_timestamp}\n            value={end_timestamp} type=\"dateTime\"\n            name=\"end_timestamp\"\n            onChange={value => handleInputChange(value, 'end_timestamp')} />\n          {isAdminUser && <>\n            <Form.Input field=\"channel\" label=\"渠道 ID\" style={{ width: 176 }} value={channel}\n              placeholder=\"可选值\" name=\"channel\"\n              onChange={value => handleInputChange(value, 'channel')} />\n            <Form.Input field=\"username\" label=\"用户名称\" style={{ width: 176 }} value={username}\n              placeholder={'可选值'} name=\"username\"\n              onChange={value => handleInputChange(value, 'username')} />\n          </>}\n          <Form.Section>\n            <Button label=\"查询\" type=\"primary\" htmlType=\"submit\" className=\"btn-margin-right\"\n              onClick={refresh} loading={loading}>查询</Button>\n          </Form.Section>\n        </>\n      </Form>\n      <Table style={{ marginTop: 5 }} columns={columns} dataSource={pageData} pagination={{\n        currentPage: activePage,\n        pageSize: pageSize,\n        total: logCount,\n        pageSizeOpts: [10, 20, 50, 100],\n        showSizeChanger: true,\n        onPageSizeChange: (size) => {\n          handlePageSizeChange(size).then();\n        },\n        onPageChange: handlePageChange\n      }} />\n      <Select defaultValue=\"0\" style={{ width: 120 }} onChange={(value) => {\n        setLogType(parseInt(value));\n        refresh(parseInt(value)).then();\n      }}>\n        <Select.Option value=\"0\">全部</Select.Option>\n        <Select.Option value=\"1\">充值</Select.Option>\n        <Select.Option value=\"2\">消费</Select.Option>\n        <Select.Option value=\"3\">管理</Select.Option>\n        <Select.Option value=\"4\">系统</Select.Option>\n      </Select>\n    </Layout>\n  </>);\n};\n\nexport default LogsTable;\n"
  },
  {
    "path": "web/air/src/components/MjLogsTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';\n\nimport { Banner, Button, Form, ImagePreview, Layout, Modal, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';\nimport { ITEMS_PER_PAGE } from '../constants';\n\n\nconst colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',\n  'light-blue', 'lime', 'orange', 'pink',\n  'purple', 'red', 'teal', 'violet', 'yellow'\n];\n\nfunction renderType(type) {\n  switch (type) {\n    case 'IMAGINE':\n      return <Tag color=\"blue\" size=\"large\">绘图</Tag>;\n    case 'UPSCALE':\n      return <Tag color=\"orange\" size=\"large\">放大</Tag>;\n    case 'VARIATION':\n      return <Tag color=\"purple\" size=\"large\">变换</Tag>;\n    case 'HIGH_VARIATION':\n      return <Tag color=\"purple\" size=\"large\">强变换</Tag>;\n    case 'LOW_VARIATION':\n      return <Tag color=\"purple\" size=\"large\">弱变换</Tag>;\n    case 'PAN':\n      return <Tag color=\"cyan\" size=\"large\">平移</Tag>;\n    case 'DESCRIBE':\n      return <Tag color=\"yellow\" size=\"large\">图生文</Tag>;\n    case 'BLEND':\n      return <Tag color=\"lime\" size=\"large\">图混合</Tag>;\n    case 'SHORTEN':\n      return <Tag color=\"pink\" size=\"large\">缩词</Tag>;\n    case 'REROLL':\n      return <Tag color=\"indigo\" size=\"large\">重绘</Tag>;\n    case 'INPAINT':\n      return <Tag color=\"violet\" size=\"large\">局部重绘-提交</Tag>;\n    case 'ZOOM':\n      return <Tag color=\"teal\" size=\"large\">变焦</Tag>;\n    case 'CUSTOM_ZOOM':\n      return <Tag color=\"teal\" size=\"large\">自定义变焦-提交</Tag>;\n    case 'MODAL':\n      return <Tag color=\"green\" size=\"large\">窗口处理</Tag>;\n    case 'SWAP_FACE':\n      return <Tag color=\"light-green\" size=\"large\">换脸</Tag>;\n    default:\n      return <Tag color=\"white\" size=\"large\">未知</Tag>;\n  }\n}\n\n\nfunction renderCode(code) {\n  switch (code) {\n    case 1:\n      return <Tag color=\"green\" size=\"large\">已提交</Tag>;\n    case 21:\n      return <Tag color=\"lime\" size=\"large\">等待中</Tag>;\n    case 22:\n      return <Tag color=\"orange\" size=\"large\">重复提交</Tag>;\n    case 0:\n      return <Tag color=\"yellow\" size=\"large\">未提交</Tag>;\n    default:\n      return <Tag color=\"white\" size=\"large\">未知</Tag>;\n  }\n}\n\n\nfunction renderStatus(type) {\n  // Ensure all cases are string literals by adding quotes.\n  switch (type) {\n    case 'SUCCESS':\n      return <Tag color=\"green\" size=\"large\">成功</Tag>;\n    case 'NOT_START':\n      return <Tag color=\"grey\" size=\"large\">未启动</Tag>;\n    case 'SUBMITTED':\n      return <Tag color=\"yellow\" size=\"large\">队列中</Tag>;\n    case 'IN_PROGRESS':\n      return <Tag color=\"blue\" size=\"large\">执行中</Tag>;\n    case 'FAILURE':\n      return <Tag color=\"red\" size=\"large\">失败</Tag>;\n    case 'MODAL':\n      return <Tag color=\"yellow\" size=\"large\">窗口等待</Tag>;\n    default:\n      return <Tag color=\"white\" size=\"large\">未知</Tag>;\n  }\n}\n\nconst renderTimestamp = (timestampInSeconds) => {\n  const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒\n\n  const year = date.getFullYear(); // 获取年份\n  const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份，从0开始需要+1，并保证两位数\n  const day = ('0' + date.getDate()).slice(-2); // 获取日期，并保证两位数\n  const hours = ('0' + date.getHours()).slice(-2); // 获取小时，并保证两位数\n  const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟，并保证两位数\n  const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟，并保证两位数\n\n  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出\n};\n\n\nconst LogsTable = () => {\n  const [isModalOpen, setIsModalOpen] = useState(false);\n  const [modalContent, setModalContent] = useState('');\n  const columns = [\n    {\n      title: '提交时间',\n      dataIndex: 'submit_time',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderTimestamp(text / 1000)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '渠道',\n      dataIndex: 'channel_id',\n      className: isAdmin() ? 'tableShow' : 'tableHiddle',\n      render: (text, record, index) => {\n        return (\n\n          <div>\n            <Tag color={colors[parseInt(text) % colors.length]} size=\"large\" onClick={() => {\n              copyText(text); // 假设copyText是用于文本复制的函数\n            }}> {text} </Tag>\n          </div>\n\n        );\n      }\n    },\n    {\n      title: '类型',\n      dataIndex: 'action',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderType(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '任务ID',\n      dataIndex: 'mj_id',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {text}\n          </div>\n        );\n      }\n    },\n    {\n      title: '提交结果',\n      dataIndex: 'code',\n      className: isAdmin() ? 'tableShow' : 'tableHiddle',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderCode(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '任务状态',\n      dataIndex: 'status',\n      className: isAdmin() ? 'tableShow' : 'tableHiddle',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderStatus(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '进度',\n      dataIndex: 'progress',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {\n              // 转换例如100%为数字100，如果text未定义，返回0\n              <Progress stroke={record.status === 'FAILURE' ? 'var(--semi-color-warning)' : null}\n                        percent={text ? parseInt(text.replace('%', '')) : 0} showInfo={true}\n                        aria-label=\"drawing progress\" />\n            }\n          </div>\n        );\n      }\n    },\n    {\n      title: '结果图片',\n      dataIndex: 'image_url',\n      render: (text, record, index) => {\n        if (!text) {\n          return '无';\n        }\n        return (\n          <Button\n            onClick={() => {\n              setModalImageUrl(text);  // 更新图片URL状态\n              setIsModalOpenurl(true);    // 打开模态框\n            }}\n          >\n            查看图片\n          </Button>\n        );\n      }\n    },\n    {\n      title: 'Prompt',\n      dataIndex: 'prompt',\n      render: (text, record, index) => {\n        // 如果text未定义，返回替代文本，例如空字符串''或其他\n        if (!text) {\n          return '无';\n        }\n\n        return (\n          <Typography.Text\n            ellipsis={{ showTooltip: true }}\n            style={{ width: 100 }}\n            onClick={() => {\n              setModalContent(text);\n              setIsModalOpen(true);\n            }}\n          >\n            {text}\n          </Typography.Text>\n        );\n      }\n    },\n    {\n      title: 'PromptEn',\n      dataIndex: 'prompt_en',\n      render: (text, record, index) => {\n        // 如果text未定义，返回替代文本，例如空字符串''或其他\n        if (!text) {\n          return '无';\n        }\n\n        return (\n          <Typography.Text\n            ellipsis={{ showTooltip: true }}\n            style={{ width: 100 }}\n            onClick={() => {\n              setModalContent(text);\n              setIsModalOpen(true);\n            }}\n          >\n            {text}\n          </Typography.Text>\n        );\n      }\n    },\n    {\n      title: '失败原因',\n      dataIndex: 'fail_reason',\n      render: (text, record, index) => {\n        // 如果text未定义，返回替代文本，例如空字符串''或其他\n        if (!text) {\n          return '无';\n        }\n\n        return (\n          <Typography.Text\n            ellipsis={{ showTooltip: true }}\n            style={{ width: 100 }}\n            onClick={() => {\n              setModalContent(text);\n              setIsModalOpen(true);\n            }}\n          >\n            {text}\n          </Typography.Text>\n        );\n      }\n    }\n\n  ];\n\n  const [logs, setLogs] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);\n  const [logType, setLogType] = useState(0);\n  const isAdminUser = isAdmin();\n  const [isModalOpenurl, setIsModalOpenurl] = useState(false);\n  const [showBanner, setShowBanner] = useState(false);\n\n  // 定义模态框图片URL的状态和更新函数\n  const [modalImageUrl, setModalImageUrl] = useState('');\n  let now = new Date();\n  // 初始化start_timestamp为前一天\n  const [inputs, setInputs] = useState({\n    channel_id: '',\n    mj_id: '',\n    start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),\n    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600)\n  });\n  const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;\n\n  const [stat, setStat] = useState({\n    quota: 0,\n    token: 0\n  });\n\n  const handleInputChange = (value, name) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n\n  const setLogsFormat = (logs) => {\n    for (let i = 0; i < logs.length; i++) {\n      logs[i].timestamp2string = timestamp2string(logs[i].created_at);\n      logs[i].key = '' + logs[i].id;\n    }\n    // data.key = '' + data.id\n    setLogs(logs);\n    setLogCount(logs.length + ITEMS_PER_PAGE);\n    // console.log(logCount);\n  };\n\n  const loadLogs = async (startIdx) => {\n    setLoading(true);\n\n    let url = '';\n    let localStartTimestamp = Date.parse(start_timestamp);\n    let localEndTimestamp = Date.parse(end_timestamp);\n    if (isAdminUser) {\n      url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;\n    } else {\n      url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;\n    }\n    const res = await API.get(url);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setLogsFormat(data);\n      } else {\n        let newLogs = [...logs];\n        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setLogsFormat(newLogs);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const pageData = logs.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);\n\n  const handlePageChange = page => {\n    setActivePage(page);\n    if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {\n      // In this case we have to load more data and then append them.\n      loadLogs(page - 1).then(r => {\n      });\n    }\n  };\n\n  const refresh = async () => {\n    // setLoading(true);\n    setActivePage(1);\n    await loadLogs(0);\n  };\n\n  const copyText = async (text) => {\n    if (await copy(text)) {\n      showSuccess('已复制：' + text);\n    } else {\n      // setSearchKeyword(text);\n      Modal.error({ title: '无法复制到剪贴板，请手动复制', content: text });\n    }\n  };\n\n  useEffect(() => {\n    refresh().then();\n  }, [logType]);\n\n  useEffect(() => {\n    const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');\n    if (mjNotifyEnabled !== 'true') {\n      setShowBanner(true);\n    }\n  }, []);\n\n  return (\n    <>\n\n      <Layout>\n        {isAdminUser && showBanner ? <Banner\n          type=\"info\"\n          description=\"当前未开启Midjourney回调，部分项目可能无法获得绘图结果，可在运营设置中开启。\"\n        /> : <></>\n        }\n        <Form layout=\"horizontal\" style={{ marginTop: 10 }}>\n          <>\n            <Form.Input field=\"channel_id\" label=\"渠道 ID\" style={{ width: 176 }} value={channel_id}\n                        placeholder={'可选值'} name=\"channel_id\"\n                        onChange={value => handleInputChange(value, 'channel_id')} />\n            <Form.Input field=\"mj_id\" label=\"任务 ID\" style={{ width: 176 }} value={mj_id}\n                        placeholder=\"可选值\"\n                        name=\"mj_id\"\n                        onChange={value => handleInputChange(value, 'mj_id')} />\n            <Form.DatePicker field=\"start_timestamp\" label=\"起始时间\" style={{ width: 272 }}\n                             initValue={start_timestamp}\n                             value={start_timestamp} type=\"dateTime\"\n                             name=\"start_timestamp\"\n                             onChange={value => handleInputChange(value, 'start_timestamp')} />\n            <Form.DatePicker field=\"end_timestamp\" fluid label=\"结束时间\" style={{ width: 272 }}\n                             initValue={end_timestamp}\n                             value={end_timestamp} type=\"dateTime\"\n                             name=\"end_timestamp\"\n                             onChange={value => handleInputChange(value, 'end_timestamp')} />\n\n            <Form.Section>\n              <Button label=\"查询\" type=\"primary\" htmlType=\"submit\" className=\"btn-margin-right\"\n                      onClick={refresh}>查询</Button>\n            </Form.Section>\n          </>\n        </Form>\n        <Table style={{ marginTop: 5 }} columns={columns} dataSource={pageData} pagination={{\n          currentPage: activePage,\n          pageSize: ITEMS_PER_PAGE,\n          total: logCount,\n          pageSizeOpts: [10, 20, 50, 100],\n          onPageChange: handlePageChange\n        }} loading={loading} />\n        <Modal\n          visible={isModalOpen}\n          onOk={() => setIsModalOpen(false)}\n          onCancel={() => setIsModalOpen(false)}\n          closable={null}\n          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式\n          width={800} // 设置模态框宽度\n        >\n          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>\n        </Modal>\n        <ImagePreview\n          src={modalImageUrl}\n          visible={isModalOpenurl}\n          onVisibleChange={(visible) => setIsModalOpenurl(visible)}\n        />\n\n      </Layout>\n    </>\n  );\n};\n\nexport default LogsTable;\n"
  },
  {
    "path": "web/air/src/components/OperationSetting.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Divider, Form, Grid, Header } from 'semantic-ui-react';\nimport { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers';\n\nconst OperationSetting = () => {\n  let now = new Date();\n  let [inputs, setInputs] = useState({\n    QuotaForNewUser: 0,\n    QuotaForInviter: 0,\n    QuotaForInvitee: 0,\n    QuotaRemindThreshold: 0,\n    PreConsumedQuota: 0,\n    ModelRatio: '',\n    CompletionRatio: '',\n    GroupRatio: '',\n    TopUpLink: '',\n    ChatLink: '',\n    QuotaPerUnit: 0,\n    AutomaticDisableChannelEnabled: '',\n    AutomaticEnableChannelEnabled: '',\n    ChannelDisableThreshold: 0,\n    LogConsumeEnabled: '',\n    DisplayInCurrencyEnabled: '',\n    DisplayTokenStatEnabled: '',\n    ApproximateTokenEnabled: '',\n    RetryTimes: 0\n  });\n  const [originInputs, setOriginInputs] = useState({});\n  let [loading, setLoading] = useState(false);\n  let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        if (item.key === 'ModelRatio' || item.key === 'GroupRatio' || item.key === 'CompletionRatio') {\n          item.value = JSON.stringify(JSON.parse(item.value), null, 2);\n        }\n        if (item.value === '{}') {\n          item.value = '';\n        }\n        newInputs[item.key] = item.value;\n      });\n      setInputs(newInputs);\n      setOriginInputs(newInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    if (key.endsWith('Enabled')) {\n      value = inputs[key] === 'true' ? 'false' : 'true';\n    }\n    const res = await API.put('/api/option/', {\n      key,\n      value\n    });\n    const { success, message } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, [key]: value }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (e, { name, value }) => {\n    if (name.endsWith('Enabled')) {\n      await updateOption(name, value);\n    } else {\n      setInputs((inputs) => ({ ...inputs, [name]: value }));\n    }\n  };\n\n  const submitConfig = async (group) => {\n    switch (group) {\n      case 'monitor':\n        if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) {\n          await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold);\n        }\n        if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {\n          await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);\n        }\n        break;\n      case 'ratio':\n        if (originInputs['ModelRatio'] !== inputs.ModelRatio) {\n          if (!verifyJSON(inputs.ModelRatio)) {\n            showError('模型倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('ModelRatio', inputs.ModelRatio);\n        }\n        if (originInputs['GroupRatio'] !== inputs.GroupRatio) {\n          if (!verifyJSON(inputs.GroupRatio)) {\n            showError('分组倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('GroupRatio', inputs.GroupRatio);\n        }\n        if (originInputs['CompletionRatio'] !== inputs.CompletionRatio) {\n          if (!verifyJSON(inputs.CompletionRatio)) {\n            showError('补全倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('CompletionRatio', inputs.CompletionRatio);\n        }\n        break;\n      case 'quota':\n        if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {\n          await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);\n        }\n        if (originInputs['QuotaForInvitee'] !== inputs.QuotaForInvitee) {\n          await updateOption('QuotaForInvitee', inputs.QuotaForInvitee);\n        }\n        if (originInputs['QuotaForInviter'] !== inputs.QuotaForInviter) {\n          await updateOption('QuotaForInviter', inputs.QuotaForInviter);\n        }\n        if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {\n          await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);\n        }\n        break;\n      case 'general':\n        if (originInputs['TopUpLink'] !== inputs.TopUpLink) {\n          await updateOption('TopUpLink', inputs.TopUpLink);\n        }\n        if (originInputs['ChatLink'] !== inputs.ChatLink) {\n          await updateOption('ChatLink', inputs.ChatLink);\n        }\n        if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) {\n          await updateOption('QuotaPerUnit', inputs.QuotaPerUnit);\n        }\n        if (originInputs['RetryTimes'] !== inputs.RetryTimes) {\n          await updateOption('RetryTimes', inputs.RetryTimes);\n        }\n        break;\n    }\n  };\n\n  const deleteHistoryLogs = async () => {\n    console.log(inputs);\n    const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`${data} 条日志已清理！`);\n      return;\n    }\n    showError('日志清理失败：' + message);\n  };\n\n  return (\n    <Grid columns={1}>\n      <Grid.Column>\n        <Form loading={loading}>\n          <Header as='h3'>\n            通用设置\n          </Header>\n          <Form.Group widths={4}>\n            <Form.Input\n              label='充值链接'\n              name='TopUpLink'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.TopUpLink}\n              type='link'\n              placeholder='例如发卡网站的购买链接'\n            />\n            <Form.Input\n              label='聊天页面链接'\n              name='ChatLink'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.ChatLink}\n              type='link'\n              placeholder='例如 ChatGPT Next Web 的部署地址'\n            />\n            <Form.Input\n              label='单位美元额度'\n              name='QuotaPerUnit'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaPerUnit}\n              type='number'\n              step='0.01'\n              placeholder='一单位货币能兑换的额度'\n            />\n            <Form.Input\n              label='失败重试次数'\n              name='RetryTimes'\n              type={'number'}\n              step='1'\n              min='0'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.RetryTimes}\n              placeholder='失败重试次数'\n            />\n          </Form.Group>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.DisplayInCurrencyEnabled === 'true'}\n              label='以货币形式显示额度'\n              name='DisplayInCurrencyEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.DisplayTokenStatEnabled === 'true'}\n              label='Billing 相关 API 显示令牌额度而非用户额度'\n              name='DisplayTokenStatEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.ApproximateTokenEnabled === 'true'}\n              label='使用近似的方式估算 token 数以减少计算量'\n              name='ApproximateTokenEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={() => {\n            submitConfig('general').then();\n          }}>保存通用设置</Form.Button>\n          <Divider />\n          <Header as='h3'>\n            日志设置\n          </Header>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.LogConsumeEnabled === 'true'}\n              label='启用额度消费日志记录'\n              name='LogConsumeEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Group widths={4}>\n            <Form.Input label='目标时间' value={historyTimestamp} type='datetime-local'\n                        name='history_timestamp'\n                        onChange={(e, { name, value }) => {\n                          setHistoryTimestamp(value);\n                        }} />\n          </Form.Group>\n          <Form.Button onClick={() => {\n            deleteHistoryLogs().then();\n          }}>清理历史日志</Form.Button>\n          <Divider />\n          <Header as='h3'>\n            监控设置\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='最长响应时间'\n              name='ChannelDisableThreshold'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.ChannelDisableThreshold}\n              type='number'\n              min='0'\n              placeholder='单位秒，当运行渠道全部测试时，超过此时间将自动禁用渠道'\n            />\n            <Form.Input\n              label='额度提醒阈值'\n              name='QuotaRemindThreshold'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaRemindThreshold}\n              type='number'\n              min='0'\n              placeholder='低于此额度时将发送邮件提醒用户'\n            />\n          </Form.Group>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.AutomaticDisableChannelEnabled === 'true'}\n              label='失败时自动禁用渠道'\n              name='AutomaticDisableChannelEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.AutomaticEnableChannelEnabled === 'true'}\n              label='成功时自动启用渠道'\n              name='AutomaticEnableChannelEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={() => {\n            submitConfig('monitor').then();\n          }}>保存监控设置</Form.Button>\n          <Divider />\n          <Header as='h3'>\n            额度设置\n          </Header>\n          <Form.Group widths={4}>\n            <Form.Input\n              label='新用户初始额度'\n              name='QuotaForNewUser'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaForNewUser}\n              type='number'\n              min='0'\n              placeholder='例如：100'\n            />\n            <Form.Input\n              label='请求预扣费额度'\n              name='PreConsumedQuota'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.PreConsumedQuota}\n              type='number'\n              min='0'\n              placeholder='请求结束后多退少补'\n            />\n            <Form.Input\n              label='邀请新用户奖励额度'\n              name='QuotaForInviter'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaForInviter}\n              type='number'\n              min='0'\n              placeholder='例如：2000'\n            />\n            <Form.Input\n              label='新用户使用邀请码奖励额度'\n              name='QuotaForInvitee'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaForInvitee}\n              type='number'\n              min='0'\n              placeholder='例如：1000'\n            />\n          </Form.Group>\n          <Form.Button onClick={() => {\n            submitConfig('quota').then();\n          }}>保存额度设置</Form.Button>\n          <Divider />\n          <Header as='h3'>\n            倍率设置\n          </Header>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label='模型倍率'\n              name='ModelRatio'\n              onChange={handleInputChange}\n              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}\n              autoComplete='new-password'\n              value={inputs.ModelRatio}\n              placeholder='为一个 JSON 文本，键为模型名称，值为倍率'\n            />\n          </Form.Group>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label='补全倍率'\n              name='CompletionRatio'\n              onChange={handleInputChange}\n              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}\n              autoComplete='new-password'\n              value={inputs.CompletionRatio}\n              placeholder='为一个 JSON 文本，键为模型名称，值为倍率，此处的倍率设置是模型补全倍率相较于提示倍率的比例，使用该设置可强制覆盖 One API 的内部比例'\n            />\n          </Form.Group>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label='分组倍率'\n              name='GroupRatio'\n              onChange={handleInputChange}\n              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}\n              autoComplete='new-password'\n              value={inputs.GroupRatio}\n              placeholder='为一个 JSON 文本，键为分组名称，值为倍率'\n            />\n          </Form.Group>\n          <Form.Button onClick={() => {\n            submitConfig('ratio').then();\n          }}>保存倍率设置</Form.Button>\n        </Form>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default OperationSetting;\n"
  },
  {
    "path": "web/air/src/components/OtherSetting.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react';\nimport { API, showError, showSuccess } from '../helpers';\nimport { marked } from 'marked';\nimport { Link } from 'react-router-dom';\n\nconst OtherSetting = () => {\n  let [inputs, setInputs] = useState({\n    Footer: '',\n    Notice: '',\n    About: '',\n    SystemName: '',\n    Logo: '',\n    HomePageContent: '',\n    Theme: ''\n  });\n  let [loading, setLoading] = useState(false);\n  const [showUpdateModal, setShowUpdateModal] = useState(false);\n  const [updateData, setUpdateData] = useState({\n    tag_name: '',\n    content: ''\n  });\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        if (item.key in inputs) {\n          newInputs[item.key] = item.value;\n        }\n      });\n      setInputs(newInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    const res = await API.put('/api/option/', {\n      key,\n      value\n    });\n    const { success, message } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, [key]: value }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const submitNotice = async () => {\n    await updateOption('Notice', inputs.Notice);\n  };\n\n  const submitFooter = async () => {\n    await updateOption('Footer', inputs.Footer);\n  };\n\n  const submitSystemName = async () => {\n    await updateOption('SystemName', inputs.SystemName);\n  };\n\n  const submitTheme = async () => {\n    await updateOption('Theme', inputs.Theme);\n  };\n\n  const submitLogo = async () => {\n    await updateOption('Logo', inputs.Logo);\n  };\n\n  const submitAbout = async () => {\n    await updateOption('About', inputs.About);\n  };\n\n  const submitOption = async (key) => {\n    await updateOption(key, inputs[key]);\n  };\n\n  const openGitHubRelease = () => {\n    window.location =\n      'https://github.com/songquanpeng/one-api/releases/latest';\n  };\n\n  const checkUpdate = async () => {\n    const res = await API.get(\n      'https://api.github.com/repos/songquanpeng/one-api/releases/latest'\n    );\n    const { tag_name, body } = res.data;\n    if (tag_name === process.env.REACT_APP_VERSION) {\n      showSuccess(`已是最新版本：${tag_name}`);\n    } else {\n      setUpdateData({\n        tag_name: tag_name,\n        content: marked.parse(body)\n      });\n      setShowUpdateModal(true);\n    }\n  };\n\n  return (\n    <Grid columns={1}>\n      <Grid.Column>\n        <Form loading={loading}>\n          <Header as='h3'>通用设置</Header>\n          <Form.Button onClick={checkUpdate}>检查更新</Form.Button>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label='公告'\n              placeholder='在此输入新的公告内容，支持 Markdown & HTML 代码'\n              value={inputs.Notice}\n              name='Notice'\n              onChange={handleInputChange}\n              style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitNotice}>保存公告</Form.Button>\n          <Divider />\n          <Header as='h3'>个性化设置</Header>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label='系统名称'\n              placeholder='在此输入系统名称'\n              value={inputs.SystemName}\n              name='SystemName'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitSystemName}>设置系统名称</Form.Button>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={<label>主题名称（<Link\n                to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>当前可用主题</Link>）</label>}\n              placeholder='请输入主题名称'\n              value={inputs.Theme}\n              name='Theme'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitTheme}>设置主题（重启生效）</Form.Button>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label='Logo 图片地址'\n              placeholder='在此输入 Logo 图片地址'\n              value={inputs.Logo}\n              name='Logo'\n              type='url'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitLogo}>设置 Logo</Form.Button>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label='首页内容'\n              placeholder='在此输入首页内容，支持 Markdown & HTML 代码，设置后首页的状态信息将不再显示。如果输入的是一个链接，则会使用该链接作为 iframe 的 src 属性，这允许你设置任意网页作为首页。'\n              value={inputs.HomePageContent}\n              name='HomePageContent'\n              onChange={handleInputChange}\n              style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}\n            />\n          </Form.Group>\n          <Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label='关于'\n              placeholder='在此输入新的关于内容，支持 Markdown & HTML 代码。如果输入的是一个链接，则会使用该链接作为 iframe 的 src 属性，这允许你设置任意网页作为关于页面。'\n              value={inputs.About}\n              name='About'\n              onChange={handleInputChange}\n              style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitAbout}>保存关于</Form.Button>\n          <Message>移除 One API\n            的版权标识必须首先获得授权，项目维护需要花费大量精力，如果本项目对你有意义，请主动支持本项目。</Message>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label='页脚'\n              placeholder='在此输入新的页脚，留空则使用默认页脚，支持 HTML 代码'\n              value={inputs.Footer}\n              name='Footer'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitFooter}>设置页脚</Form.Button>\n        </Form>\n      </Grid.Column>\n      <Modal\n        onClose={() => setShowUpdateModal(false)}\n        onOpen={() => setShowUpdateModal(true)}\n        open={showUpdateModal}\n      >\n        <Modal.Header>新版本：{updateData.tag_name}</Modal.Header>\n        <Modal.Content>\n          <Modal.Description>\n            <div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>\n          </Modal.Description>\n        </Modal.Content>\n        <Modal.Actions>\n          <Button onClick={() => setShowUpdateModal(false)}>关闭</Button>\n          <Button\n            content='详情'\n            onClick={() => {\n              setShowUpdateModal(false);\n              openGitHubRelease();\n            }}\n          />\n        </Modal.Actions>\n      </Modal>\n    </Grid>\n  );\n};\n\nexport default OtherSetting;\n"
  },
  {
    "path": "web/air/src/components/PasswordResetConfirm.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';\nimport { API, copy, showError, showNotice } from '../helpers';\nimport { useSearchParams } from 'react-router-dom';\n\nconst PasswordResetConfirm = () => {\n  const [inputs, setInputs] = useState({\n    email: '',\n    token: ''\n  });\n  const { email, token } = inputs;\n\n  const [loading, setLoading] = useState(false);\n\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n\n  const [newPassword, setNewPassword] = useState('');\n\n  const [searchParams, setSearchParams] = useSearchParams();\n  useEffect(() => {\n    let token = searchParams.get('token');\n    let email = searchParams.get('email');\n    setInputs({\n      token,\n      email\n    });\n  }, []);\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval);\n  }, [disableButton, countdown]);\n\n  async function handleSubmit(e) {\n    setDisableButton(true);\n    if (!email) return;\n    setLoading(true);\n    const res = await API.post(`/api/user/reset`, {\n      email,\n      token\n    });\n    const { success, message } = res.data;\n    if (success) {\n      let password = res.data.data;\n      setNewPassword(password);\n      await copy(password);\n      showNotice(`新密码已复制到剪贴板：${password}`);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  }\n\n  return (\n    <Grid textAlign=\"center\" style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Header as=\"h2\" color=\"\" textAlign=\"center\">\n          <Image src=\"/logo.png\" /> 密码重置确认\n        </Header>\n        <Form size=\"large\">\n          <Segment>\n            <Form.Input\n              fluid\n              icon=\"mail\"\n              iconPosition=\"left\"\n              placeholder=\"邮箱地址\"\n              name=\"email\"\n              value={email}\n              readOnly\n            />\n            {newPassword && (\n              <Form.Input\n                fluid\n                icon=\"lock\"\n                iconPosition=\"left\"\n                placeholder=\"新密码\"\n                name=\"newPassword\"\n                value={newPassword}\n                readOnly\n                onClick={(e) => {\n                  e.target.select();\n                  navigator.clipboard.writeText(newPassword);\n                  showNotice(`密码已复制到剪贴板：${newPassword}`);\n                }}\n              />\n            )}\n            <Button\n              color=\"green\"\n              fluid\n              size=\"large\"\n              onClick={handleSubmit}\n              loading={loading}\n              disabled={disableButton}\n            >\n              {disableButton ? `密码重置完成` : '提交'}\n            </Button>\n          </Segment>\n        </Form>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default PasswordResetConfirm;\n"
  },
  {
    "path": "web/air/src/components/PasswordResetForm.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';\nimport { API, showError, showInfo, showSuccess } from '../helpers';\nimport Turnstile from 'react-turnstile';\n\nconst PasswordResetForm = () => {\n  const [inputs, setInputs] = useState({\n    email: ''\n  });\n  const { email } = inputs;\n\n  const [loading, setLoading] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval);\n  }, [disableButton, countdown]);\n\n  function handleChange(e) {\n    const { name, value } = e.target;\n    setInputs(inputs => ({ ...inputs, [name]: value }));\n  }\n\n  async function handleSubmit(e) {\n    setDisableButton(true);\n    if (!email) return;\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    setLoading(true);\n    const res = await API.get(\n      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('重置邮件发送成功，请检查邮箱！');\n      setInputs({ ...inputs, email: '' });\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  }\n\n  return (\n    <Grid textAlign=\"center\" style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Header as=\"h2\" color=\"\" textAlign=\"center\">\n          <Image src=\"/logo.png\" /> 密码重置\n        </Header>\n        <Form size=\"large\">\n          <Segment>\n            <Form.Input\n              fluid\n              icon=\"mail\"\n              iconPosition=\"left\"\n              placeholder=\"邮箱地址\"\n              name=\"email\"\n              value={email}\n              onChange={handleChange}\n            />\n            {turnstileEnabled ? (\n              <Turnstile\n                sitekey={turnstileSiteKey}\n                onVerify={(token) => {\n                  setTurnstileToken(token);\n                }}\n              />\n            ) : (\n              <></>\n            )}\n            <Button\n              color=\"green\"\n              fluid\n              size=\"large\"\n              onClick={handleSubmit}\n              loading={loading}\n              disabled={disableButton}\n            >\n              {disableButton ? `重试 (${countdown})` : '提交'}\n            </Button>\n          </Segment>\n        </Form>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default PasswordResetForm;\n"
  },
  {
    "path": "web/air/src/components/PersonalSetting.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { API, copy, isRoot, showError, showInfo, showSuccess } from '../helpers';\nimport Turnstile from 'react-turnstile';\nimport { UserContext } from '../context/User';\nimport { onGitHubOAuthClicked } from './utils';\nimport {\n  Avatar,\n  Banner,\n  Button,\n  Card,\n  Descriptions,\n  Image,\n  Input,\n  InputNumber,\n  Layout,\n  Modal,\n  Space,\n  Tag,\n  Typography\n} from '@douyinfe/semi-ui';\nimport { getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor } from '../helpers/render';\nimport TelegramLoginButton from 'react-telegram-login';\n\nconst PersonalSetting = () => {\n  const [userState, userDispatch] = useContext(UserContext);\n  let navigate = useNavigate();\n\n  const [inputs, setInputs] = useState({\n    wechat_verification_code: '',\n    email_verification_code: '',\n    email: '',\n    self_account_deletion_confirmation: '',\n    set_new_password: '',\n    set_new_password_confirmation: ''\n  });\n  const [status, setStatus] = useState({});\n  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);\n  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);\n  const [showEmailBindModal, setShowEmailBindModal] = useState(false);\n  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [loading, setLoading] = useState(false);\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n  const [affLink, setAffLink] = useState('');\n  const [systemToken, setSystemToken] = useState('');\n  const [models, setModels] = useState([]);\n  const [openTransfer, setOpenTransfer] = useState(false);\n  const [transferAmount, setTransferAmount] = useState(0);\n\n  useEffect(() => {\n    // let user = localStorage.getItem('user');\n    // if (user) {\n    //   userDispatch({ type: 'login', payload: user });\n    // }\n    // console.log(localStorage.getItem('user'))\n\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      setStatus(status);\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n    getUserData().then(\n      (res) => {\n        console.log(userState);\n      }\n    );\n    loadModels().then();\n    getAffLink().then();\n    setTransferAmount(getQuotaPerUnit());\n  }, []);\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval); // Clean up on unmount\n  }, [disableButton, countdown]);\n\n  const handleInputChange = (name, value) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const generateAccessToken = async () => {\n    const res = await API.get('/api/user/token');\n    const { success, message, data } = res.data;\n    if (success) {\n      setSystemToken(data);\n      await copy(data);\n      showSuccess(`令牌已重置并已复制到剪贴板`);\n    } else {\n      showError(message);\n    }\n  };\n\n  const getAffLink = async () => {\n    const res = await API.get('/api/user/aff');\n    const { success, message, data } = res.data;\n    if (success) {\n      let link = `${window.location.origin}/register?aff=${data}`;\n      setAffLink(link);\n    } else {\n      showError(message);\n    }\n  };\n\n  const getUserData = async () => {\n    let res = await API.get(`/api/user/self`);\n    const { success, message, data } = res.data;\n    if (success) {\n      userDispatch({ type: 'login', payload: data });\n    } else {\n      showError(message);\n    }\n  };\n\n  const loadModels = async () => {\n    let res = await API.get(`/api/user/available_models`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setModels(data);\n      console.log(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const handleAffLinkClick = async (e) => {\n    e.target.select();\n    await copy(e.target.value);\n    showSuccess(`邀请链接已复制到剪切板`);\n  };\n\n  const handleSystemTokenClick = async (e) => {\n    e.target.select();\n    await copy(e.target.value);\n    showSuccess(`系统令牌已复制到剪切板`);\n  };\n\n  const deleteAccount = async () => {\n    if (inputs.self_account_deletion_confirmation !== userState.user.username) {\n      showError('请输入你的账户名以确认删除！');\n      return;\n    }\n\n    const res = await API.delete('/api/user/self');\n    const { success, message } = res.data;\n\n    if (success) {\n      showSuccess('账户已删除！');\n      await API.get('/api/user/logout');\n      userDispatch({ type: 'logout' });\n      localStorage.removeItem('user');\n      navigate('/login');\n    } else {\n      showError(message);\n    }\n  };\n\n  const bindWeChat = async () => {\n    if (inputs.wechat_verification_code === '') return;\n    const res = await API.get(\n      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('微信账户绑定成功！');\n      setShowWeChatBindModal(false);\n    } else {\n      showError(message);\n    }\n  };\n\n  const changePassword = async () => {\n    if (inputs.set_new_password !== inputs.set_new_password_confirmation) {\n      showError('两次输入的密码不一致！');\n      return;\n    }\n    const res = await API.put(\n      `/api/user/self`,\n      {\n        password: inputs.set_new_password\n      }\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('密码修改成功！');\n      setShowWeChatBindModal(false);\n    } else {\n      showError(message);\n    }\n    setShowChangePasswordModal(false);\n  };\n\n  const transfer = async () => {\n    if (transferAmount < getQuotaPerUnit()) {\n      showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));\n      return;\n    }\n    const res = await API.post(\n      `/api/user/aff_transfer`,\n      {\n        quota: transferAmount\n      }\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(message);\n      setOpenTransfer(false);\n      getUserData().then();\n    } else {\n      showError(message);\n    }\n  };\n\n  const sendVerificationCode = async () => {\n    if (inputs.email === '') {\n      showError('请输入邮箱！');\n      return;\n    }\n    setDisableButton(true);\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    setLoading(true);\n    const res = await API.get(\n      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('验证码发送成功，请检查邮箱！');\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const bindEmail = async () => {\n    if (inputs.email_verification_code === '') {\n      showError('请输入邮箱验证码！');\n      return;\n    }\n    setLoading(true);\n    const res = await API.get(\n      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('邮箱账户绑定成功！');\n      setShowEmailBindModal(false);\n      userState.user.email = inputs.email;\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const getUsername = () => {\n    if (userState.user) {\n      return userState.user.username;\n    } else {\n      return 'null';\n    }\n  };\n\n  const handleCancel = () => {\n    setOpenTransfer(false);\n  };\n\n  const copyText = async (text) => {\n    if (await copy(text)) {\n      showSuccess('已复制：' + text);\n    } else {\n      // setSearchKeyword(text);\n      Modal.error({ title: '无法复制到剪贴板，请手动复制', content: text });\n    }\n  };\n\n  return (\n    <div>\n      <Layout>\n        <Layout.Content>\n          <Modal\n            title=\"请输入要划转的数量\"\n            visible={openTransfer}\n            onOk={transfer}\n            onCancel={handleCancel}\n            maskClosable={false}\n            size={'small'}\n            centered={true}\n          >\n            <div style={{ marginTop: 20 }}>\n              <Typography.Text>{`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}</Typography.Text>\n              <Input style={{ marginTop: 5 }} value={userState?.user?.aff_quota} disabled={true}></Input>\n            </div>\n            <div style={{ marginTop: 20 }}>\n              <Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>\n              <div>\n                <InputNumber min={0} style={{ marginTop: 5 }} value={transferAmount}\n                  onChange={(value) => setTransferAmount(value)} disabled={false}></InputNumber>\n              </div>\n            </div>\n          </Modal>\n          <div style={{ marginTop: 20 }}>\n            <Card\n              title={\n                <Card.Meta\n                  avatar={<Avatar size=\"default\" color={stringToColor(getUsername())}\n                    style={{ marginRight: 4 }}>\n                    {typeof getUsername() === 'string' && getUsername().slice(0, 1)}\n                  </Avatar>}\n                  title={<Typography.Text>{getUsername()}</Typography.Text>}\n                  description={isRoot() ? <Tag color=\"red\">管理员</Tag> : <Tag color=\"blue\">普通用户</Tag>}\n                ></Card.Meta>\n              }\n              headerExtraContent={\n                <>\n                  <Space vertical align=\"start\">\n                    <Tag color=\"green\">{'ID: ' + userState?.user?.id}</Tag>\n                    <Tag color=\"blue\">{userState?.user?.group}</Tag>\n                  </Space>\n                </>\n              }\n              footer={\n                <Descriptions row>\n                  <Descriptions.Item itemKey=\"当前余额\">{renderQuota(userState?.user?.quota)}</Descriptions.Item>\n                  <Descriptions.Item itemKey=\"历史消耗\">{renderQuota(userState?.user?.used_quota)}</Descriptions.Item>\n                  <Descriptions.Item itemKey=\"请求次数\">{userState.user?.request_count}</Descriptions.Item>\n                </Descriptions>\n              }\n            >\n              <Typography.Title heading={6}>调用信息</Typography.Title>\n              <p>可用模型（可点击复制）</p>\n              <div style={{ marginTop: 10 }}>\n                <Space wrap>\n                  {models.map((model) => (\n                    <Tag key={model} color=\"cyan\" onClick={() => {\n                      copyText(model);\n                    }}>\n                      {model}\n                    </Tag>\n                  ))}\n                </Space>\n              </div>\n            </Card>\n            {/* <Card\n              footer={\n                <div>\n                  <Typography.Text>邀请链接</Typography.Text>\n                  <Input\n                    style={{ marginTop: 10 }}\n                    value={affLink}\n                    onClick={handleAffLinkClick}\n                    readOnly\n                  />\n                </div>\n              }\n            >\n              <Typography.Title heading={6}>邀请信息</Typography.Title>\n              <div style={{ marginTop: 10 }}>\n                <Descriptions row>\n                  <Descriptions.Item itemKey=\"待使用收益\">\n                    <span style={{ color: 'rgba(var(--semi-red-5), 1)' }}>\n                      {\n                        renderQuota(userState?.user?.aff_quota)\n                      }\n                    </span>\n                    <Button type={'secondary'} onClick={() => setOpenTransfer(true)} size={'small'}\n                      style={{ marginLeft: 10 }}>划转</Button>\n                  </Descriptions.Item>\n                  <Descriptions.Item\n                    itemKey=\"总收益\">{renderQuota(userState?.user?.aff_history_quota)}</Descriptions.Item>\n                  <Descriptions.Item itemKey=\"邀请人数\">{userState?.user?.aff_count}</Descriptions.Item>\n                </Descriptions>\n              </div>\n            </Card> */}\n            <Card>\n              <Typography.Title heading={6}>邀请链接</Typography.Title>\n              <Input\n                style={{ marginTop: 10 }}\n                value={affLink}\n                onClick={handleAffLinkClick}\n                readOnly\n              />\n            </Card>\n            <Card>\n              <Typography.Title heading={6}>个人信息</Typography.Title>\n              <div style={{ marginTop: 20 }}>\n                <Typography.Text strong>邮箱</Typography.Text>\n                <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n                  <div>\n                    <Input\n                      value={userState.user && userState.user.email !== '' ? userState.user.email : '未绑定'}\n                      readonly={true}\n                    ></Input>\n                  </div>\n                  <div>\n                    <Button onClick={() => {\n                      setShowEmailBindModal(true);\n                    }}>{\n                        userState.user && userState.user.email !== '' ? '修改绑定' : '绑定邮箱'\n                      }</Button>\n                  </div>\n                </div>\n              </div>\n              <div style={{ marginTop: 10 }}>\n                <Typography.Text strong>微信</Typography.Text>\n                <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n                  <div>\n                    <Input\n                      value={userState.user && userState.user.wechat_id !== '' ? '已绑定' : '未绑定'}\n                      readonly={true}\n                    ></Input>\n                  </div>\n                  <div>\n                    <Button disabled={(userState.user && userState.user.wechat_id !== '') || !status.wechat_login}>\n                      {\n                        status.wechat_login ? '绑定' : '未启用'\n                      }\n                    </Button>\n                  </div>\n                </div>\n              </div>\n              <div style={{ marginTop: 10 }}>\n                <Typography.Text strong>GitHub</Typography.Text>\n                <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n                  <div>\n                    <Input\n                      value={userState.user && userState.user.github_id !== '' ? userState.user.github_id : '未绑定'}\n                      readonly={true}\n                    ></Input>\n                  </div>\n                  <div>\n                    <Button\n                      onClick={() => {\n                        onGitHubOAuthClicked(status.github_client_id);\n                      }}\n                      disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}\n                    >\n                      {\n                        status.github_oauth ? '绑定' : '未启用'\n                      }\n                    </Button>\n                  </div>\n                </div>\n              </div>\n\n              {/* <div style={{ marginTop: 10 }}>\n                <Typography.Text strong>Telegram</Typography.Text>\n                <div style={{ display: 'flex', justifyContent: 'space-between' }}>\n                  <div>\n                    <Input\n                      value={userState.user && userState.user.telegram_id !== '' ? userState.user.telegram_id : '未绑定'}\n                      readonly={true}\n                    ></Input>\n                  </div>\n                  <div>\n                    {status.telegram_oauth ?\n                      userState.user.telegram_id !== '' ? <Button disabled={true}>已绑定</Button>\n                        : <TelegramLoginButton dataAuthUrl=\"/api/oauth/telegram/bind\"\n                          botName={status.telegram_bot_name} />\n                      : <Button disabled={true}>未启用</Button>\n                    }\n                  </div>\n                </div>\n              </div> */}\n\n              <div style={{ marginTop: 10 }}>\n                <Space>\n                  <Button onClick={generateAccessToken}>生成系统访问令牌</Button>\n                  <Button onClick={() => {\n                    setShowChangePasswordModal(true);\n                  }}>修改密码</Button>\n                  <Button type={'danger'} onClick={() => {\n                    setShowAccountDeleteModal(true);\n                  }}>删除个人账户</Button>\n                </Space>\n\n                {systemToken && (\n                  <Input\n                    readOnly\n                    value={systemToken}\n                    onClick={handleSystemTokenClick}\n                    style={{ marginTop: '10px' }}\n                  />\n                )}\n                {\n                  status.wechat_login && (\n                    <Button\n                      onClick={() => {\n                        setShowWeChatBindModal(true);\n                      }}\n                    >\n                      绑定微信账号\n                    </Button>\n                  )\n                }\n                <Modal\n                  onCancel={() => setShowWeChatBindModal(false)}\n                  // onOpen={() => setShowWeChatBindModal(true)}\n                  visible={showWeChatBindModal}\n                  size={'mini'}\n                >\n                  <Image src={status.wechat_qrcode} />\n                  <div style={{ textAlign: 'center' }}>\n                    <p>\n                      微信扫码关注公众号，输入「验证码」获取验证码（三分钟内有效）\n                    </p>\n                  </div>\n                  <Input\n                    placeholder=\"验证码\"\n                    name=\"wechat_verification_code\"\n                    value={inputs.wechat_verification_code}\n                    onChange={(v) => handleInputChange('wechat_verification_code', v)}\n                  />\n                  <Button color=\"\" fluid size=\"large\" onClick={bindWeChat}>\n                    绑定\n                  </Button>\n                </Modal>\n              </div>\n            </Card>\n            <Modal\n              onCancel={() => setShowEmailBindModal(false)}\n              // onOpen={() => setShowEmailBindModal(true)}\n              onOk={bindEmail}\n              visible={showEmailBindModal}\n              size={'small'}\n              centered={true}\n              maskClosable={false}\n            >\n              <Typography.Title heading={6}>绑定邮箱地址</Typography.Title>\n              <div style={{ marginTop: 20, display: 'flex', justifyContent: 'space-between' }}>\n                <Input\n                  fluid\n                  placeholder=\"输入邮箱地址\"\n                  onChange={(value) => handleInputChange('email', value)}\n                  name=\"email\"\n                  type=\"email\"\n                />\n                <Button onClick={sendVerificationCode}\n                  disabled={disableButton || loading}>\n                  {disableButton ? `重新发送(${countdown})` : '获取验证码'}\n                </Button>\n              </div>\n              <div style={{ marginTop: 10 }}>\n                <Input\n                  fluid\n                  placeholder=\"验证码\"\n                  name=\"email_verification_code\"\n                  value={inputs.email_verification_code}\n                  onChange={(value) => handleInputChange('email_verification_code', value)}\n                />\n              </div>\n              {turnstileEnabled ? (\n                <Turnstile\n                  sitekey={turnstileSiteKey}\n                  onVerify={(token) => {\n                    setTurnstileToken(token);\n                  }}\n                />\n              ) : (\n                <></>\n              )}\n            </Modal>\n            <Modal\n              onCancel={() => setShowAccountDeleteModal(false)}\n              visible={showAccountDeleteModal}\n              size={'small'}\n              centered={true}\n              onOk={deleteAccount}\n            >\n              <div style={{ marginTop: 20 }}>\n                <Banner\n                  type=\"danger\"\n                  description=\"您正在删除自己的帐户，将清空所有数据且不可恢复\"\n                  closeIcon={null}\n                />\n              </div>\n              <div style={{ marginTop: 20 }}>\n                <Input\n                  placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}\n                  name=\"self_account_deletion_confirmation\"\n                  value={inputs.self_account_deletion_confirmation}\n                  onChange={(value) => handleInputChange('self_account_deletion_confirmation', value)}\n                />\n                {turnstileEnabled ? (\n                  <Turnstile\n                    sitekey={turnstileSiteKey}\n                    onVerify={(token) => {\n                      setTurnstileToken(token);\n                    }}\n                  />\n                ) : (\n                  <></>\n                )}\n              </div>\n            </Modal>\n            <Modal\n              onCancel={() => setShowChangePasswordModal(false)}\n              visible={showChangePasswordModal}\n              size={'small'}\n              centered={true}\n              onOk={changePassword}\n            >\n              <div style={{ marginTop: 20 }}>\n                <Input\n                  name=\"set_new_password\"\n                  placeholder=\"新密码\"\n                  value={inputs.set_new_password}\n                  onChange={(value) => handleInputChange('set_new_password', value)}\n                />\n                <Input\n                  style={{ marginTop: 20 }}\n                  name=\"set_new_password_confirmation\"\n                  placeholder=\"确认新密码\"\n                  value={inputs.set_new_password_confirmation}\n                  onChange={(value) => handleInputChange('set_new_password_confirmation', value)}\n                />\n                {turnstileEnabled ? (\n                  <Turnstile\n                    sitekey={turnstileSiteKey}\n                    onVerify={(token) => {\n                      setTurnstileToken(token);\n                    }}\n                  />\n                ) : (\n                  <></>\n                )}\n              </div>\n            </Modal>\n          </div>\n\n        </Layout.Content>\n      </Layout>\n    </div>\n  );\n};\n\nexport default PersonalSetting;\n"
  },
  {
    "path": "web/air/src/components/PrivateRoute.js",
    "content": "import { Navigate } from 'react-router-dom';\n\nimport { history } from '../helpers';\n\n\nfunction PrivateRoute({ children }) {\n  if (!localStorage.getItem('user')) {\n    return <Navigate to=\"/login\" state={{ from: history.location }} />;\n  }\n  return children;\n}\n\nexport { PrivateRoute };"
  },
  {
    "path": "web/air/src/components/RedemptionsTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API, copy, showError, showSuccess, timestamp2string } from '../helpers';\n\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderQuota } from '../helpers/render';\nimport { Button, Form, Modal, Popconfirm, Popover, Table, Tag } from '@douyinfe/semi-ui';\nimport EditRedemption from '../pages/Redemption/EditRedemption';\n\nfunction renderTimestamp(timestamp) {\n  return (\n    <>\n      {timestamp2string(timestamp)}\n    </>\n  );\n}\n\nfunction renderStatus(status) {\n  switch (status) {\n    case 1:\n      return <Tag color=\"green\" size=\"large\">未使用</Tag>;\n    case 2:\n      return <Tag color=\"red\" size=\"large\"> 已禁用 </Tag>;\n    case 3:\n      return <Tag color=\"grey\" size=\"large\"> 已使用 </Tag>;\n    default:\n      return <Tag color=\"black\" size=\"large\"> 未知状态 </Tag>;\n  }\n}\n\nconst RedemptionsTable = () => {\n  const columns = [\n    {\n      title: 'ID',\n      dataIndex: 'id'\n    },\n    {\n      title: '名称',\n      dataIndex: 'name'\n    },\n    {\n      title: '状态',\n      dataIndex: 'status',\n      key: 'status',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderStatus(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '额度',\n      dataIndex: 'quota',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderQuota(parseInt(text))}\n          </div>\n        );\n      }\n    },\n    {\n      title: '创建时间',\n      dataIndex: 'created_time',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderTimestamp(text)}\n          </div>\n        );\n      }\n    },\n    // {\n    //   title: '兑换人ID',\n    //   dataIndex: 'used_user_id',\n    //   render: (text, record, index) => {\n    //     return (\n    //       <div>\n    //         {text === 0 ? '无' : text}\n    //       </div>\n    //     );\n    //   }\n    // },\n    {\n      title: '',\n      dataIndex: 'operate',\n      render: (text, record, index) => (\n        <div>\n          <Popover\n            content={\n              record.key\n            }\n            style={{ padding: 20 }}\n            position=\"top\"\n          >\n            <Button theme=\"light\" type=\"tertiary\" style={{ marginRight: 1 }}>查看</Button>\n          </Popover>\n          <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }}\n                  onClick={async (text) => {\n                    await copyText(record.key);\n                  }}\n          >复制</Button>\n          <Popconfirm\n            title=\"确定是否要删除此兑换码？\"\n            content=\"此修改将不可逆\"\n            okType={'danger'}\n            position={'left'}\n            onConfirm={() => {\n              manageRedemption(record.id, 'delete', record).then(\n                () => {\n                  removeRecord(record.key);\n                }\n              );\n            }}\n          >\n            <Button theme=\"light\" type=\"danger\" style={{ marginRight: 1 }}>删除</Button>\n          </Popconfirm>\n          {\n            record.status === 1 ?\n              <Button theme=\"light\" type=\"warning\" style={{ marginRight: 1 }} onClick={\n                async () => {\n                  manageRedemption(\n                    record.id,\n                    'disable',\n                    record\n                  );\n                }\n              }>禁用</Button> :\n              <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }} onClick={\n                async () => {\n                  manageRedemption(\n                    record.id,\n                    'enable',\n                    record\n                  );\n                }\n              } disabled={record.status === 3}>启用</Button>\n          }\n          <Button theme=\"light\" type=\"tertiary\" style={{ marginRight: 1 }} onClick={\n            () => {\n              setEditingRedemption(record);\n              setShowEdit(true);\n            }\n          } disabled={record.status !== 1}>编辑</Button>\n        </div>\n      )\n    }\n  ];\n\n  const [redemptions, setRedemptions] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);\n  const [selectedKeys, setSelectedKeys] = useState([]);\n  const [editingRedemption, setEditingRedemption] = useState({\n    id: undefined\n  });\n  const [showEdit, setShowEdit] = useState(false);\n\n  const closeEdit = () => {\n    setShowEdit(false);\n  };\n\n  // const setCount = (data) => {\n  //     if (data.length >= (activePage) * ITEMS_PER_PAGE) {\n  //         setTokenCount(data.length + 1);\n  //     } else {\n  //         setTokenCount(data.length);\n  //     }\n  // }\n\n  const setRedemptionFormat = (redeptions) => {\n    // for (let i = 0; i < redeptions.length; i++) {\n    //     redeptions[i].key = '' + redeptions[i].id;\n    // }\n    // data.key = '' + data.id\n    setRedemptions(redeptions);\n    if (redeptions.length >= (activePage) * ITEMS_PER_PAGE) {\n      setTokenCount(redeptions.length + 1);\n    } else {\n      setTokenCount(redeptions.length);\n    }\n  };\n\n  const loadRedemptions = async (startIdx) => {\n    const res = await API.get(`/api/redemption/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setRedemptionFormat(data);\n      } else {\n        let newRedemptions = redemptions;\n        newRedemptions.push(...data);\n        setRedemptionFormat(newRedemptions);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const removeRecord = key => {\n    let newDataSource = [...redemptions];\n    if (key != null) {\n      let idx = newDataSource.findIndex(data => data.key === key);\n\n      if (idx > -1) {\n        newDataSource.splice(idx, 1);\n        setRedemptions(newDataSource);\n      }\n    }\n  };\n\n  const copyText = async (text) => {\n    if (await copy(text)) {\n      showSuccess('已复制到剪贴板！');\n    } else {\n      // setSearchKeyword(text);\n      Modal.error({ title: '无法复制到剪贴板，请手动复制', content: text });\n    }\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadRedemptions(activePage - 1);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  useEffect(() => {\n    loadRedemptions(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, []);\n\n  const refresh = async () => {\n    await loadRedemptions(activePage - 1);\n  };\n\n  const manageRedemption = async (id, action, record) => {\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(`/api/redemption/${id}/`);\n        break;\n      case 'enable':\n        data.status = 1;\n        res = await API.put('/api/redemption/?status_only=true', data);\n        break;\n      case 'disable':\n        data.status = 2;\n        res = await API.put('/api/redemption/?status_only=true', data);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      let redemption = res.data.data;\n      let newRedemptions = [...redemptions];\n      // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      if (action === 'delete') {\n\n      } else {\n        record.status = redemption.status;\n      }\n      setRedemptions(newRedemptions);\n    } else {\n      showError(message);\n    }\n  };\n\n  const searchRedemptions = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadRedemptions(0);\n      setActivePage(1);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setRedemptions(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (value) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortRedemption = (key) => {\n    if (redemptions.length === 0) return;\n    setLoading(true);\n    let sortedRedemptions = [...redemptions];\n    sortedRedemptions.sort((a, b) => {\n      return ('' + a[key]).localeCompare(b[key]);\n    });\n    if (sortedRedemptions[0].id === redemptions[0].id) {\n      sortedRedemptions.reverse();\n    }\n    setRedemptions(sortedRedemptions);\n    setLoading(false);\n  };\n\n  const handlePageChange = page => {\n    setActivePage(page);\n    if (page === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {\n      // In this case we have to load more data and then append them.\n      loadRedemptions(page - 1).then(r => {\n      });\n    }\n  };\n\n  let pageData = redemptions.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);\n  const rowSelection = {\n    onSelect: (record, selected) => {\n    },\n    onSelectAll: (selected, selectedRows) => {\n    },\n    onChange: (selectedRowKeys, selectedRows) => {\n      setSelectedKeys(selectedRows);\n    }\n  };\n\n  const handleRow = (record, index) => {\n    if (record.status !== 1) {\n      return {\n        style: {\n          background: 'var(--semi-color-disabled-border)'\n        }\n      };\n    } else {\n      return {};\n    }\n  };\n\n  return (\n    <>\n      <EditRedemption refresh={refresh} editingRedemption={editingRedemption} visiable={showEdit}\n                      handleClose={closeEdit}></EditRedemption>\n      <Form onSubmit={searchRedemptions}>\n        <Form.Input\n          label=\"搜索关键字\"\n          field=\"keyword\"\n          icon=\"search\"\n          iconPosition=\"left\"\n          placeholder=\"关键字(id或者名称)\"\n          value={searchKeyword}\n          loading={searching}\n          onChange={handleKeywordChange}\n        />\n      </Form>\n\n      <Table style={{ marginTop: 20 }} columns={columns} dataSource={pageData} pagination={{\n        currentPage: activePage,\n        pageSize: ITEMS_PER_PAGE,\n        total: tokenCount,\n        // showSizeChanger: true,\n        // pageSizeOptions: [10, 20, 50, 100],\n        formatPageText: (page) => `第 ${page.currentStart} - ${page.currentEnd} 条，共 ${redemptions.length} 条`,\n        // onPageSizeChange: (size) => {\n        //   setPageSize(size);\n        //   setActivePage(1);\n        // },\n        onPageChange: handlePageChange\n      }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>\n      </Table>\n      <Button theme=\"light\" type=\"primary\" style={{ marginRight: 8 }} onClick={\n        () => {\n          setEditingRedemption({\n            id: undefined\n          });\n          setShowEdit(true);\n        }\n      }>添加兑换码</Button>\n      <Button label=\"复制所选兑换码\" type=\"warning\" onClick={\n        async () => {\n          if (selectedKeys.length === 0) {\n            showError('请至少选择一个兑换码！');\n            return;\n          }\n          let keys = '';\n          for (let i = 0; i < selectedKeys.length; i++) {\n            keys += selectedKeys[i].name + '    ' + selectedKeys[i].key + '\\n';\n          }\n          await copyText(keys);\n        }\n      }>复制所选兑换码到剪贴板</Button>\n    </>\n  );\n};\n\nexport default RedemptionsTable;\n"
  },
  {
    "path": "web/air/src/components/RegisterForm.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';\nimport { Link, useNavigate } from 'react-router-dom';\nimport { API, getLogo, showError, showInfo, showSuccess } from '../helpers';\nimport Turnstile from 'react-turnstile';\n\nconst RegisterForm = () => {\n  const [inputs, setInputs] = useState({\n    username: '',\n    password: '',\n    password2: '',\n    email: '',\n    verification_code: ''\n  });\n  const { username, password, password2 } = inputs;\n  const [showEmailVerification, setShowEmailVerification] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [loading, setLoading] = useState(false);\n  const logo = getLogo();\n  let affCode = new URLSearchParams(window.location.search).get('aff');\n  if (affCode) {\n    localStorage.setItem('aff', affCode);\n  }\n\n  useEffect(() => {\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      setShowEmailVerification(status.email_verification);\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n  });\n\n  let navigate = useNavigate();\n\n  function handleChange(e) {\n    const { name, value } = e.target;\n    console.log(name, value);\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  }\n\n  async function handleSubmit(e) {\n    if (password.length < 8) {\n      showInfo('密码长度不得小于 8 位！');\n      return;\n    }\n    if (password !== password2) {\n      showInfo('两次输入的密码不一致');\n      return;\n    }\n    if (username && password) {\n      if (turnstileEnabled && turnstileToken === '') {\n        showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n        return;\n      }\n      setLoading(true);\n      if (!affCode) {\n        affCode = localStorage.getItem('aff');\n      }\n      inputs.aff_code = affCode;\n      const res = await API.post(\n        `/api/user/register?turnstile=${turnstileToken}`,\n        inputs\n      );\n      const { success, message } = res.data;\n      if (success) {\n        navigate('/login');\n        showSuccess('注册成功！');\n      } else {\n        showError(message);\n      }\n      setLoading(false);\n    }\n  }\n\n  const sendVerificationCode = async () => {\n    if (inputs.email === '') return;\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    setLoading(true);\n    const res = await API.get(\n      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('验证码发送成功，请检查你的邮箱！');\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  return (\n    <Grid textAlign=\"center\" style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Header as=\"h2\" color=\"\" textAlign=\"center\">\n          <Image src={logo} /> 新用户注册\n        </Header>\n        <Form size=\"large\">\n          <Segment>\n            <Form.Input\n              fluid\n              icon=\"user\"\n              iconPosition=\"left\"\n              placeholder=\"输入用户名，最长 12 位\"\n              onChange={handleChange}\n              name=\"username\"\n            />\n            <Form.Input\n              fluid\n              icon=\"lock\"\n              iconPosition=\"left\"\n              placeholder=\"输入密码，最短 8 位，最长 20 位\"\n              onChange={handleChange}\n              name=\"password\"\n              type=\"password\"\n            />\n            <Form.Input\n              fluid\n              icon=\"lock\"\n              iconPosition=\"left\"\n              placeholder=\"输入密码，最短 8 位，最长 20 位\"\n              onChange={handleChange}\n              name=\"password2\"\n              type=\"password\"\n            />\n            {showEmailVerification ? (\n              <>\n                <Form.Input\n                  fluid\n                  icon=\"mail\"\n                  iconPosition=\"left\"\n                  placeholder=\"输入邮箱地址\"\n                  onChange={handleChange}\n                  name=\"email\"\n                  type=\"email\"\n                  action={\n                    <Button onClick={sendVerificationCode} disabled={loading}>\n                      获取验证码\n                    </Button>\n                  }\n                />\n                <Form.Input\n                  fluid\n                  icon=\"lock\"\n                  iconPosition=\"left\"\n                  placeholder=\"输入验证码\"\n                  onChange={handleChange}\n                  name=\"verification_code\"\n                />\n              </>\n            ) : (\n              <></>\n            )}\n            {turnstileEnabled ? (\n              <Turnstile\n                sitekey={turnstileSiteKey}\n                onVerify={(token) => {\n                  setTurnstileToken(token);\n                }}\n              />\n            ) : (\n              <></>\n            )}\n            <Button\n              color=\"green\"\n              fluid\n              size=\"large\"\n              onClick={handleSubmit}\n              loading={loading}\n            >\n              注册\n            </Button>\n          </Segment>\n        </Form>\n        <Message>\n          已有账户？\n          <Link to=\"/login\" className=\"btn btn-link\">\n            点击登录\n          </Link>\n        </Message>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default RegisterForm;\n"
  },
  {
    "path": "web/air/src/components/SiderBar.js",
    "content": "import React, { useContext, useEffect, useMemo, useState } from 'react';\nimport { Link, useNavigate } from 'react-router-dom';\nimport { UserContext } from '../context/User';\nimport { StatusContext } from '../context/Status';\n\nimport { API, getLogo, getSystemName, isAdmin, isMobile, showError } from '../helpers';\nimport '../index.css';\n\nimport {\n  IconCalendarClock,\n  IconComment,\n  IconCreditCard,\n  IconGift,\n  IconHistogram,\n  IconHome,\n  IconImage,\n  IconKey,\n  IconLayers,\n  IconSetting,\n  IconUser\n} from '@douyinfe/semi-icons';\nimport { Layout, Nav } from '@douyinfe/semi-ui';\n\n// HeaderBar Buttons\n\nconst SiderBar = () => {\n  const [userState, userDispatch] = useContext(UserContext);\n  const [statusState, statusDispatch] = useContext(StatusContext);\n  const defaultIsCollapsed = isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';\n\n  let navigate = useNavigate();\n  const [selectedKeys, setSelectedKeys] = useState(['home']);\n  const systemName = getSystemName();\n  const logo = getLogo();\n  const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);\n\n  const headerButtons = useMemo(() => [\n    {\n      text: '首页',\n      itemKey: 'home',\n      to: '/',\n      icon: <IconHome />\n    },\n    {\n      text: '渠道',\n      itemKey: 'channel',\n      to: '/channel',\n      icon: <IconLayers />,\n      className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'\n    },\n    {\n      text: '聊天',\n      itemKey: 'chat',\n      to: '/chat',\n      icon: <IconComment />,\n      className: localStorage.getItem('chat_link') ? 'semi-navigation-item-normal' : 'tableHiddle'\n    },\n    {\n      text: '令牌',\n      itemKey: 'token',\n      to: '/token',\n      icon: <IconKey />\n    },\n    {\n      text: '兑换',\n      itemKey: 'redemption',\n      to: '/redemption',\n      icon: <IconGift />,\n      className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'\n    },\n    {\n      text: '充值',\n      itemKey: 'topup',\n      to: '/topup',\n      icon: <IconCreditCard />\n    },\n    {\n      text: '用户',\n      itemKey: 'user',\n      to: '/user',\n      icon: <IconUser />,\n      className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'\n    },\n    {\n      text: '日志',\n      itemKey: 'log',\n      to: '/log',\n      icon: <IconHistogram />\n    },\n    {\n      text: '数据看板',\n      itemKey: 'detail',\n      to: '/detail',\n      icon: <IconCalendarClock />,\n      className: localStorage.getItem('enable_data_export') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'\n    },\n    {\n      text: '绘图',\n      itemKey: 'midjourney',\n      to: '/midjourney',\n      icon: <IconImage />,\n      className: localStorage.getItem('enable_drawing') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'\n    },\n    {\n      text: '设置',\n      itemKey: 'setting',\n      to: '/setting',\n      icon: <IconSetting />\n    }\n    // {\n    //     text: '关于',\n    //     itemKey: 'about',\n    //     to: '/about',\n    //     icon: <IconAt/>\n    // }\n  ], [localStorage.getItem('enable_data_export'), localStorage.getItem('enable_drawing'), localStorage.getItem('chat_link'), isAdmin()]);\n\n  const loadStatus = async () => {\n    const res = await API.get('/api/status');\n    const { success, data } = res.data;\n    if (success) {\n      localStorage.setItem('status', JSON.stringify(data));\n      statusDispatch({ type: 'set', payload: data });\n      localStorage.setItem('system_name', data.system_name);\n      localStorage.setItem('logo', data.logo);\n      localStorage.setItem('footer_html', data.footer_html);\n      localStorage.setItem('quota_per_unit', data.quota_per_unit);\n      localStorage.setItem('display_in_currency', data.display_in_currency);\n      localStorage.setItem('enable_drawing', data.enable_drawing);\n      localStorage.setItem('enable_data_export', data.enable_data_export);\n      localStorage.setItem('data_export_default_time', data.data_export_default_time);\n      localStorage.setItem('default_collapse_sidebar', data.default_collapse_sidebar);\n      localStorage.setItem('mj_notify_enabled', data.mj_notify_enabled);\n      if (data.chat_link) {\n        localStorage.setItem('chat_link', data.chat_link);\n      } else {\n        localStorage.removeItem('chat_link');\n      }\n      if (data.chat_link2) {\n        localStorage.setItem('chat_link2', data.chat_link2);\n      } else {\n        localStorage.removeItem('chat_link2');\n      }\n    } else {\n      showError('无法正常连接至服务器！');\n    }\n  };\n\n  useEffect(() => {\n    loadStatus().then(() => {\n      setIsCollapsed(isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true');\n    });\n  }, []);\n\n  return (\n    <>\n      <Layout>\n        <div style={{ height: '100%' }}>\n          <Nav\n            // bodyStyle={{ maxWidth: 200 }}\n            style={{ maxWidth: 200 }}\n            defaultIsCollapsed={isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true'}\n            isCollapsed={isCollapsed}\n            onCollapseChange={collapsed => {\n              setIsCollapsed(collapsed);\n            }}\n            selectedKeys={selectedKeys}\n            renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {\n              const routerMap = {\n                home: '/',\n                channel: '/channel',\n                token: '/token',\n                redemption: '/redemption',\n                topup: '/topup',\n                user: '/user',\n                log: '/log',\n                midjourney: '/midjourney',\n                setting: '/setting',\n                about: '/about',\n                chat: '/chat',\n                detail: '/detail'\n              };\n              return (\n                <Link\n                  style={{ textDecoration: 'none' }}\n                  to={routerMap[props.itemKey]}\n                >\n                  {itemElement}\n                </Link>\n              );\n            }}\n            items={headerButtons}\n            onSelect={key => {\n              setSelectedKeys([key.itemKey]);\n            }}\n            header={{\n              logo: <img src={logo} alt=\"logo\" style={{ marginRight: '0.75em' }} />,\n              text: systemName\n            }}\n            // footer={{\n            //   text: '© 2021 NekoAPI',\n            // }}\n          >\n\n            <Nav.Footer collapseButton={true}>\n            </Nav.Footer>\n          </Nav>\n        </div>\n      </Layout>\n    </>\n  );\n};\n\nexport default SiderBar;\n"
  },
  {
    "path": "web/air/src/components/SystemSetting.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react';\nimport { API, removeTrailingSlash, showError } from '../helpers';\n\nconst SystemSetting = () => {\n  let [inputs, setInputs] = useState({\n    PasswordLoginEnabled: '',\n    PasswordRegisterEnabled: '',\n    EmailVerificationEnabled: '',\n    GitHubOAuthEnabled: '',\n    GitHubClientId: '',\n    GitHubClientSecret: '',\n    Notice: '',\n    SMTPServer: '',\n    SMTPPort: '',\n    SMTPAccount: '',\n    SMTPFrom: '',\n    SMTPToken: '',\n    ServerAddress: '',\n    Footer: '',\n    WeChatAuthEnabled: '',\n    WeChatServerAddress: '',\n    WeChatServerToken: '',\n    WeChatAccountQRCodeImageURL: '',\n    MessagePusherAddress: '',\n    MessagePusherToken: '',\n    TurnstileCheckEnabled: '',\n    TurnstileSiteKey: '',\n    TurnstileSecretKey: '',\n    RegisterEnabled: '',\n    EmailDomainRestrictionEnabled: '',\n    EmailDomainWhitelist: ''\n  });\n  const [originInputs, setOriginInputs] = useState({});\n  let [loading, setLoading] = useState(false);\n  const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);\n  const [restrictedDomainInput, setRestrictedDomainInput] = useState('');\n  const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false);\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        newInputs[item.key] = item.value;\n      });\n      setInputs({\n        ...newInputs,\n        EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',')\n      });\n      setOriginInputs(newInputs);\n\n      setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {\n        return { key: item, text: item, value: item };\n      }));\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    switch (key) {\n      case 'PasswordLoginEnabled':\n      case 'PasswordRegisterEnabled':\n      case 'EmailVerificationEnabled':\n      case 'GitHubOAuthEnabled':\n      case 'WeChatAuthEnabled':\n      case 'TurnstileCheckEnabled':\n      case 'EmailDomainRestrictionEnabled':\n      case 'RegisterEnabled':\n        value = inputs[key] === 'true' ? 'false' : 'true';\n        break;\n      default:\n        break;\n    }\n    const res = await API.put('/api/option/', {\n      key,\n      value\n    });\n    const { success, message } = res.data;\n    if (success) {\n      if (key === 'EmailDomainWhitelist') {\n        value = value.split(',');\n      }\n      setInputs((inputs) => ({\n        ...inputs, [key]: value\n      }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (e, { name, value }) => {\n    if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {\n      // block disabling password login\n      setShowPasswordWarningModal(true);\n      return;\n    }\n    if (\n      name === 'Notice' ||\n      name.startsWith('SMTP') ||\n      name === 'ServerAddress' ||\n      name === 'GitHubClientId' ||\n      name === 'GitHubClientSecret' ||\n      name === 'WeChatServerAddress' ||\n      name === 'WeChatServerToken' ||\n      name === 'WeChatAccountQRCodeImageURL' ||\n      name === 'TurnstileSiteKey' ||\n      name === 'TurnstileSecretKey' ||\n      name === 'EmailDomainWhitelist'\n    ) {\n      setInputs((inputs) => ({ ...inputs, [name]: value }));\n    } else {\n      await updateOption(name, value);\n    }\n  };\n\n  const submitServerAddress = async () => {\n    let ServerAddress = removeTrailingSlash(inputs.ServerAddress);\n    await updateOption('ServerAddress', ServerAddress);\n  };\n\n  const submitSMTP = async () => {\n    if (originInputs['SMTPServer'] !== inputs.SMTPServer) {\n      await updateOption('SMTPServer', inputs.SMTPServer);\n    }\n    if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {\n      await updateOption('SMTPAccount', inputs.SMTPAccount);\n    }\n    if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) {\n      await updateOption('SMTPFrom', inputs.SMTPFrom);\n    }\n    if (\n      originInputs['SMTPPort'] !== inputs.SMTPPort &&\n      inputs.SMTPPort !== ''\n    ) {\n      await updateOption('SMTPPort', inputs.SMTPPort);\n    }\n    if (\n      originInputs['SMTPToken'] !== inputs.SMTPToken &&\n      inputs.SMTPToken !== ''\n    ) {\n      await updateOption('SMTPToken', inputs.SMTPToken);\n    }\n  };\n\n\n  const submitEmailDomainWhitelist = async () => {\n    if (\n      originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') &&\n      inputs.SMTPToken !== ''\n    ) {\n      await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(','));\n    }\n  };\n\n  const submitWeChat = async () => {\n    if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {\n      await updateOption(\n        'WeChatServerAddress',\n        removeTrailingSlash(inputs.WeChatServerAddress)\n      );\n    }\n    if (\n      originInputs['WeChatAccountQRCodeImageURL'] !==\n      inputs.WeChatAccountQRCodeImageURL\n    ) {\n      await updateOption(\n        'WeChatAccountQRCodeImageURL',\n        inputs.WeChatAccountQRCodeImageURL\n      );\n    }\n    if (\n      originInputs['WeChatServerToken'] !== inputs.WeChatServerToken &&\n      inputs.WeChatServerToken !== ''\n    ) {\n      await updateOption('WeChatServerToken', inputs.WeChatServerToken);\n    }\n  };\n\n  const submitMessagePusher = async () => {\n    if (originInputs['MessagePusherAddress'] !== inputs.MessagePusherAddress) {\n      await updateOption(\n        'MessagePusherAddress',\n        removeTrailingSlash(inputs.MessagePusherAddress)\n      );\n    }\n    if (\n      originInputs['MessagePusherToken'] !== inputs.MessagePusherToken &&\n      inputs.MessagePusherToken !== ''\n    ) {\n      await updateOption('MessagePusherToken', inputs.MessagePusherToken);\n    }\n  };\n\n  const submitGitHubOAuth = async () => {\n    if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {\n      await updateOption('GitHubClientId', inputs.GitHubClientId);\n    }\n    if (\n      originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret &&\n      inputs.GitHubClientSecret !== ''\n    ) {\n      await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);\n    }\n  };\n\n  const submitTurnstile = async () => {\n    if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {\n      await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);\n    }\n    if (\n      originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey &&\n      inputs.TurnstileSecretKey !== ''\n    ) {\n      await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);\n    }\n  };\n\n  const submitNewRestrictedDomain = () => {\n    const localDomainList = inputs.EmailDomainWhitelist;\n    if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) {\n      setRestrictedDomainInput('');\n      setInputs({\n        ...inputs,\n        EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],\n      });\n      setEmailDomainWhitelist([...EmailDomainWhitelist, {\n        key: restrictedDomainInput,\n        text: restrictedDomainInput,\n        value: restrictedDomainInput,\n      }]);\n    }\n  }\n\n  return (\n    <Grid columns={1}>\n      <Grid.Column>\n        <Form loading={loading}>\n          <Header as='h3'>通用设置</Header>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label='服务器地址'\n              placeholder='例如：https://yourdomain.com'\n              value={inputs.ServerAddress}\n              name='ServerAddress'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitServerAddress}>\n            更新服务器地址\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>配置登录注册</Header>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.PasswordLoginEnabled === 'true'}\n              label='允许通过密码进行登录'\n              name='PasswordLoginEnabled'\n              onChange={handleInputChange}\n            />\n            {\n              showPasswordWarningModal &&\n              <Modal\n                open={showPasswordWarningModal}\n                onClose={() => setShowPasswordWarningModal(false)}\n                size={'tiny'}\n                style={{ maxWidth: '450px' }}\n              >\n                <Modal.Header>警告</Modal.Header>\n                <Modal.Content>\n                  <p>取消密码登录将导致所有未绑定其他登录方式的用户（包括管理员）无法通过密码登录，确认取消？</p>\n                </Modal.Content>\n                <Modal.Actions>\n                  <Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button>\n                  <Button\n                    color='yellow'\n                    onClick={async () => {\n                      setShowPasswordWarningModal(false);\n                      await updateOption('PasswordLoginEnabled', 'false');\n                    }}\n                  >\n                    确定\n                  </Button>\n                </Modal.Actions>\n              </Modal>\n            }\n            <Form.Checkbox\n              checked={inputs.PasswordRegisterEnabled === 'true'}\n              label='允许通过密码进行注册'\n              name='PasswordRegisterEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.EmailVerificationEnabled === 'true'}\n              label='通过密码注册时需要进行邮箱验证'\n              name='EmailVerificationEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.GitHubOAuthEnabled === 'true'}\n              label='允许通过 GitHub 账户登录 & 注册'\n              name='GitHubOAuthEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.WeChatAuthEnabled === 'true'}\n              label='允许通过微信登录 & 注册'\n              name='WeChatAuthEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.RegisterEnabled === 'true'}\n              label='允许新用户注册（此项为否时，新用户将无法以任何方式进行注册）'\n              name='RegisterEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.TurnstileCheckEnabled === 'true'}\n              label='启用 Turnstile 用户校验'\n              name='TurnstileCheckEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Divider />\n          <Header as='h3'>\n            配置邮箱域名白名单\n            <Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Checkbox\n              label='启用邮箱域名白名单'\n              name='EmailDomainRestrictionEnabled'\n              onChange={handleInputChange}\n              checked={inputs.EmailDomainRestrictionEnabled === 'true'}\n            />\n          </Form.Group>\n          <Form.Group widths={2}>\n            <Form.Dropdown\n              label='允许的邮箱域名'\n              placeholder='允许的邮箱域名'\n              name='EmailDomainWhitelist'\n              required\n              fluid\n              multiple\n              selection\n              onChange={handleInputChange}\n              value={inputs.EmailDomainWhitelist}\n              autoComplete='new-password'\n              options={EmailDomainWhitelist}\n            />\n            <Form.Input\n              label='添加新的允许的邮箱域名'\n              action={\n                <Button type='button' onClick={() => {\n                  submitNewRestrictedDomain();\n                }}>填入</Button>\n              }\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  submitNewRestrictedDomain();\n                }\n              }}\n              autoComplete='new-password'\n              placeholder='输入新的允许的邮箱域名'\n              value={restrictedDomainInput}\n              onChange={(e, { value }) => {\n                setRestrictedDomainInput(value);\n              }}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button>\n          <Divider />\n          <Header as='h3'>\n            配置 SMTP\n            <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='SMTP 服务器地址'\n              name='SMTPServer'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.SMTPServer}\n              placeholder='例如：smtp.qq.com'\n            />\n            <Form.Input\n              label='SMTP 端口'\n              name='SMTPPort'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.SMTPPort}\n              placeholder='默认: 587'\n            />\n            <Form.Input\n              label='SMTP 账户'\n              name='SMTPAccount'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.SMTPAccount}\n              placeholder='通常是邮箱地址'\n            />\n          </Form.Group>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='SMTP 发送者邮箱'\n              name='SMTPFrom'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.SMTPFrom}\n              placeholder='通常和邮箱地址保持一致'\n            />\n            <Form.Input\n              label='SMTP 访问凭证'\n              name='SMTPToken'\n              onChange={handleInputChange}\n              type='password'\n              autoComplete='new-password'\n              checked={inputs.RegisterEnabled === 'true'}\n              placeholder='敏感信息不会发送到前端显示'\n            />\n          </Form.Group>\n          <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>\n          <Divider />\n          <Header as='h3'>\n            配置 GitHub OAuth App\n            <Header.Subheader>\n              用以支持通过 GitHub 进行登录注册，\n              <a href='https://github.com/settings/developers' target='_blank'>\n                点击此处\n              </a>\n              管理你的 GitHub OAuth App\n            </Header.Subheader>\n          </Header>\n          <Message>\n            Homepage URL 填 <code>{inputs.ServerAddress}</code>\n            ，Authorization callback URL 填{' '}\n            <code>{`${inputs.ServerAddress}/oauth/github`}</code>\n          </Message>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='GitHub Client ID'\n              name='GitHubClientId'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.GitHubClientId}\n              placeholder='输入你注册的 GitHub OAuth APP 的 ID'\n            />\n            <Form.Input\n              label='GitHub Client Secret'\n              name='GitHubClientSecret'\n              onChange={handleInputChange}\n              type='password'\n              autoComplete='new-password'\n              value={inputs.GitHubClientSecret}\n              placeholder='敏感信息不会发送到前端显示'\n            />\n          </Form.Group>\n          <Form.Button onClick={submitGitHubOAuth}>\n            保存 GitHub OAuth 设置\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>\n            配置 WeChat Server\n            <Header.Subheader>\n              用以支持通过微信进行登录注册，\n              <a\n                href='https://github.com/songquanpeng/wechat-server'\n                target='_blank'\n              >\n                点击此处\n              </a>\n              了解 WeChat Server\n            </Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='WeChat Server 服务器地址'\n              name='WeChatServerAddress'\n              placeholder='例如：https://yourdomain.com'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.WeChatServerAddress}\n            />\n            <Form.Input\n              label='WeChat Server 访问凭证'\n              name='WeChatServerToken'\n              type='password'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.WeChatServerToken}\n              placeholder='敏感信息不会发送到前端显示'\n            />\n            <Form.Input\n              label='微信公众号二维码图片链接'\n              name='WeChatAccountQRCodeImageURL'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.WeChatAccountQRCodeImageURL}\n              placeholder='输入一个图片链接'\n            />\n          </Form.Group>\n          <Form.Button onClick={submitWeChat}>\n            保存 WeChat Server 设置\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>\n            配置 Message Pusher\n            <Header.Subheader>\n              用以推送报警信息，\n              <a\n                href='https://github.com/songquanpeng/message-pusher'\n                target='_blank'\n              >\n                点击此处\n              </a>\n              了解 Message Pusher\n            </Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='Message Pusher 推送地址'\n              name='MessagePusherAddress'\n              placeholder='例如：https://msgpusher.com/push/your_username'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.MessagePusherAddress}\n            />\n            <Form.Input\n              label='Message Pusher 访问凭证'\n              name='MessagePusherToken'\n              type='password'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.MessagePusherToken}\n              placeholder='敏感信息不会发送到前端显示'\n            />\n          </Form.Group>\n          <Form.Button onClick={submitMessagePusher}>\n            保存 Message Pusher 设置\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>\n            配置 Turnstile\n            <Header.Subheader>\n              用以支持用户校验，\n              <a href='https://dash.cloudflare.com/' target='_blank'>\n                点击此处\n              </a>\n              管理你的 Turnstile Sites，推荐选择 Invisible Widget Type\n            </Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label='Turnstile Site Key'\n              name='TurnstileSiteKey'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.TurnstileSiteKey}\n              placeholder='输入你注册的 Turnstile Site Key'\n            />\n            <Form.Input\n              label='Turnstile Secret Key'\n              name='TurnstileSecretKey'\n              onChange={handleInputChange}\n              type='password'\n              autoComplete='new-password'\n              value={inputs.TurnstileSecretKey}\n              placeholder='敏感信息不会发送到前端显示'\n            />\n          </Form.Group>\n          <Form.Button onClick={submitTurnstile}>\n            保存 Turnstile 设置\n          </Form.Button>\n        </Form>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default SystemSetting;\n"
  },
  {
    "path": "web/air/src/components/TokensTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API, copy, showError, showSuccess, timestamp2string } from '../helpers';\n\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderQuota } from '../helpers/render';\nimport { Button, Dropdown, Form, Modal, Popconfirm, Popover, SplitButtonGroup, Table, Tag } from '@douyinfe/semi-ui';\n\nimport { IconTreeTriangleDown } from '@douyinfe/semi-icons';\nimport EditToken from '../pages/Token/EditToken';\n\nconst COPY_OPTIONS = [\n  { key: 'next', text: 'ChatGPT Next Web', value: 'next' },\n  { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },\n  { key: 'opencat', text: 'OpenCat', value: 'opencat' },\n  { key: 'lobechat', text: 'LobeChat', value: 'lobechat' },\n];\n\nconst OPEN_LINK_OPTIONS = [\n  { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },\n  { key: 'opencat', text: 'OpenCat', value: 'opencat' },\n  { key: 'lobechat', text: 'LobeChat', value: 'lobechat' }\n];\n\nfunction renderTimestamp(timestamp) {\n  return (\n    <>\n      {timestamp2string(timestamp)}\n    </>\n  );\n}\n\nfunction renderStatus(status, model_limits_enabled = false) {\n  switch (status) {\n    case 1:\n      if (model_limits_enabled) {\n        return <Tag color=\"green\" size=\"large\">已启用：限制模型</Tag>;\n      } else {\n        return <Tag color=\"green\" size=\"large\">已启用</Tag>;\n      }\n    case 2:\n      return <Tag color=\"red\" size=\"large\"> 已禁用 </Tag>;\n    case 3:\n      return <Tag color=\"yellow\" size=\"large\"> 已过期 </Tag>;\n    case 4:\n      return <Tag color=\"grey\" size=\"large\"> 已耗尽 </Tag>;\n    default:\n      return <Tag color=\"black\" size=\"large\"> 未知状态 </Tag>;\n  }\n}\n\nconst TokensTable = () => {\n\n  const link_menu = [\n    {\n      node: 'item', key: 'next', name: 'ChatGPT Next Web', onClick: () => {\n        onOpenLink('next');\n      }\n    },\n    { node: 'item', key: 'ama', name: 'AMA 问天', value: 'ama' },\n    {\n      node: 'item', key: 'next-mj', name: 'ChatGPT Web & Midjourney', value: 'next-mj', onClick: () => {\n        onOpenLink('next-mj');\n      }\n    },\n    { node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat' },\n    {\n      node: 'item', key: 'lobechat', name: 'LobeChat', onClick: () => {\n        onOpenLink('lobechat');\n      }\n    }\n  ];\n\n  const columns = [\n    {\n      title: '名称',\n      dataIndex: 'name'\n    },\n    {\n      title: '状态',\n      dataIndex: 'status',\n      key: 'status',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderStatus(text, record.model_limits_enabled)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '已用额度',\n      dataIndex: 'used_quota',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderQuota(parseInt(text))}\n          </div>\n        );\n      }\n    },\n    {\n      title: '剩余额度',\n      dataIndex: 'remain_quota',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {record.unlimited_quota ? <Tag size={'large'} color={'white'}>无限制</Tag> :\n              <Tag size={'large'} color={'light-blue'}>{renderQuota(parseInt(text))}</Tag>}\n          </div>\n        );\n      }\n    },\n    {\n      title: '创建时间',\n      dataIndex: 'created_time',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {renderTimestamp(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '过期时间',\n      dataIndex: 'expired_time',\n      render: (text, record, index) => {\n        return (\n          <div>\n            {record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}\n          </div>\n        );\n      }\n    },\n    {\n      title: '',\n      dataIndex: 'operate',\n      render: (text, record, index) => (\n        <div>\n          <Popover\n            content={\n              'sk-' + record.key\n            }\n            style={{ padding: 20 }}\n            position=\"top\"\n          >\n            <Button theme=\"light\" type=\"tertiary\" style={{ marginRight: 1 }}>查看</Button>\n          </Popover>\n          <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }}\n                  onClick={async (text) => {\n                    await copyText('sk-' + record.key);\n                  }}\n          >复制</Button>\n          <SplitButtonGroup style={{ marginRight: 1 }} aria-label=\"项目操作按钮组\">\n            <Button theme=\"light\" style={{ color: 'rgba(var(--semi-teal-7), 1)' }} onClick={() => {\n              onOpenLink('next', record.key);\n            }}>聊天</Button>\n            <Dropdown trigger=\"click\" position=\"bottomRight\" menu={\n              [\n                {\n                  node: 'item',\n                  key: 'next',\n                  disabled: !localStorage.getItem('chat_link'),\n                  name: 'ChatGPT Next Web',\n                  onClick: () => {\n                    onOpenLink('next', record.key);\n                  }\n                },\n                {\n                  node: 'item',\n                  key: 'next-mj',\n                  disabled: !localStorage.getItem('chat_link2'),\n                  name: 'ChatGPT Web & Midjourney',\n                  onClick: () => {\n                    onOpenLink('next-mj', record.key);\n                  }\n                },\n                {\n                  node: 'item', key: 'ama', name: 'AMA 问天（BotGem）', onClick: () => {\n                    onOpenLink('ama', record.key);\n                  }\n                },\n                {\n                  node: 'item', key: 'opencat', name: 'OpenCat', onClick: () => {\n                    onOpenLink('opencat', record.key);\n                  }\n                },\n                {\n                  node: 'item', key: 'lobechat', name: 'LobeChat', onClick: () => {\n                    onOpenLink('lobechat');\n                  }\n                }\n              ]\n            }\n            >\n              <Button style={{ padding: '8px 4px', color: 'rgba(var(--semi-teal-7), 1)' }} type=\"primary\"\n                      icon={<IconTreeTriangleDown />}></Button>\n            </Dropdown>\n          </SplitButtonGroup>\n          <Popconfirm\n            title=\"确定是否要删除此令牌？\"\n            content=\"此修改将不可逆\"\n            okType={'danger'}\n            position={'left'}\n            onConfirm={() => {\n              manageToken(record.id, 'delete', record).then(\n                () => {\n                  removeRecord(record.key);\n                }\n              );\n            }}\n          >\n            <Button theme=\"light\" type=\"danger\" style={{ marginRight: 1 }}>删除</Button>\n          </Popconfirm>\n          {\n            record.status === 1 ?\n              <Button theme=\"light\" type=\"warning\" style={{ marginRight: 1 }} onClick={\n                async () => {\n                  manageToken(\n                    record.id,\n                    'disable',\n                    record\n                  );\n                }\n              }>禁用</Button> :\n              <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }} onClick={\n                async () => {\n                  manageToken(\n                    record.id,\n                    'enable',\n                    record\n                  );\n                }\n              }>启用</Button>\n          }\n          <Button theme=\"light\" type=\"tertiary\" style={{ marginRight: 1 }} onClick={\n            () => {\n              setEditingToken(record);\n              setShowEdit(true);\n            }\n          }>编辑</Button>\n        </div>\n      )\n    }\n  ];\n\n  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);\n  const [showEdit, setShowEdit] = useState(false);\n  const [tokens, setTokens] = useState([]);\n  const [selectedKeys, setSelectedKeys] = useState([]);\n  const [tokenCount, setTokenCount] = useState(pageSize);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searchToken, setSearchToken] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [showTopUpModal, setShowTopUpModal] = useState(false);\n  const [targetTokenIdx, setTargetTokenIdx] = useState(0);\n  const [editingToken, setEditingToken] = useState({\n    id: undefined\n  });\n  const [orderBy, setOrderBy] = useState('');\n  const [dropdownVisible, setDropdownVisible] = useState(false);\n\n  const closeEdit = () => {\n    setShowEdit(false);\n    setTimeout(() => {\n      setEditingToken({\n        id: undefined\n      });\n    }, 500);\n  };\n\n  const setTokensFormat = (tokens) => {\n    setTokens(tokens);\n    if (tokens.length >= pageSize) {\n      setTokenCount(tokens.length + pageSize);\n    } else {\n      setTokenCount(tokens.length);\n    }\n  };\n\n  let pageData = tokens.slice((activePage - 1) * pageSize, activePage * pageSize);\n  const loadTokens = async (startIdx) => {\n    setLoading(true);\n    const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}&order=${orderBy}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setTokensFormat(data);\n      } else {\n        let newTokens = [...tokens];\n        newTokens.splice(startIdx * pageSize, data.length, ...data);\n        setTokensFormat(newTokens);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(tokens.length / pageSize) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadTokens(activePage - 1, orderBy);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const refresh = async () => {\n    await loadTokens(activePage - 1);\n  };\n\n  const onCopy = async (type, key) => {\n    let status = localStorage.getItem('status');\n    let serverAddress = '';\n    if (status) {\n      status = JSON.parse(status);\n      serverAddress = status.server_address;\n    }\n    if (serverAddress === '') {\n      serverAddress = window.location.origin;\n    }\n    let encodedServerAddress = encodeURIComponent(serverAddress);\n    const nextLink = localStorage.getItem('chat_link');\n    const mjLink = localStorage.getItem('chat_link2');\n    let nextUrl;\n\n    if (nextLink) {\n      nextUrl = nextLink + `/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    } else {\n      nextUrl = `https://app.nextchat.dev/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    }\n\n    let url;\n    switch (type) {\n      case 'ama':\n        url = mjLink + `/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n        break;\n      case 'opencat':\n        url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;\n        break;\n      case 'next':\n        url = nextUrl;\n        break;\n      default:\n        url = `sk-${key}`;\n    }\n    // if (await copy(url)) {\n    //     showSuccess('已复制到剪贴板！');\n    // } else {\n    //     showWarning('无法复制到剪贴板，请手动复制，已将令牌填入搜索框。');\n    //     setSearchKeyword(url);\n    // }\n  };\n\n  const copyText = async (text) => {\n    if (await copy(text)) {\n      showSuccess('已复制到剪贴板！');\n    } else {\n      // setSearchKeyword(text);\n      Modal.error({ title: '无法复制到剪贴板，请手动复制', content: text });\n    }\n  };\n\n  const onOpenLink = async (type, key) => {\n    let status = localStorage.getItem('status');\n    let serverAddress = '';\n    if (status) {\n      status = JSON.parse(status);\n      serverAddress = status.server_address;\n    }\n    if (serverAddress === '') {\n      serverAddress = window.location.origin;\n    }\n    let encodedServerAddress = encodeURIComponent(serverAddress);\n    const chatLink = localStorage.getItem('chat_link');\n    const mjLink = localStorage.getItem('chat_link2');\n    let defaultUrl;\n\n    if (chatLink) {\n      defaultUrl = chatLink + `/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    }\n    let url;\n    switch (type) {\n      case 'ama':\n        url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;\n        break;\n      case 'opencat':\n        url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;\n        break;\n      case 'next-mj':\n        url = mjLink + `/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n        break;\n      case 'lobechat':\n        url = chatLink + `/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"sk-${key}\",\"baseURL\":\"${serverAddress}/v1\"}}}`;\n        break;\n      default:\n        if (!chatLink) {\n          showError('管理员未设置聊天链接');\n          return;\n        }\n        url = defaultUrl;\n    }\n\n    window.open(url, '_blank');\n  };\n\n  useEffect(() => {\n    loadTokens(0, orderBy)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, [pageSize, orderBy]);\n\n  const removeRecord = key => {\n    let newDataSource = [...tokens];\n    if (key != null) {\n      let idx = newDataSource.findIndex(data => data.key === key);\n\n      if (idx > -1) {\n        newDataSource.splice(idx, 1);\n        setTokensFormat(newDataSource);\n      }\n    }\n  };\n\n  const manageToken = async (id, action, record) => {\n    setLoading(true);\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(`/api/token/${id}/`);\n        break;\n      case 'enable':\n        data.status = 1;\n        res = await API.put('/api/token/?status_only=true', data);\n        break;\n      case 'disable':\n        data.status = 2;\n        res = await API.put('/api/token/?status_only=true', data);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      let token = res.data.data;\n      let newTokens = [...tokens];\n      // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      if (action === 'delete') {\n\n      } else {\n        record.status = token.status;\n        // newTokens[realIdx].status = token.status;\n      }\n      setTokensFormat(newTokens);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const searchTokens = async () => {\n    if (searchKeyword === '' && searchToken === '') {\n      // if keyword is blank, load files instead.\n      await loadTokens(0);\n      setActivePage(1);\n      setOrderBy('');\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/token/search?keyword=${searchKeyword}&token=${searchToken}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setTokensFormat(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (value) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const handleSearchTokenChange = async (value) => {\n    setSearchToken(value.trim());\n  };\n\n  const sortToken = (key) => {\n    if (tokens.length === 0) return;\n    setLoading(true);\n    let sortedTokens = [...tokens];\n    sortedTokens.sort((a, b) => {\n      return ('' + a[key]).localeCompare(b[key]);\n    });\n    if (sortedTokens[0].id === tokens[0].id) {\n      sortedTokens.reverse();\n    }\n    setTokens(sortedTokens);\n    setLoading(false);\n  };\n\n\n  const handlePageChange = page => {\n    setActivePage(page);\n    if (page === Math.ceil(tokens.length / pageSize) + 1) {\n      // In this case we have to load more data and then append them.\n      loadTokens(page - 1).then(r => {\n      });\n    }\n  };\n\n  const rowSelection = {\n    onSelect: (record, selected) => {\n    },\n    onSelectAll: (selected, selectedRows) => {\n    },\n    onChange: (selectedRowKeys, selectedRows) => {\n      setSelectedKeys(selectedRows);\n    }\n  };\n\n  const handleRow = (record, index) => {\n    if (record.status !== 1) {\n      return {\n        style: {\n          background: 'var(--semi-color-disabled-border)'\n        }\n      };\n    } else {\n      return {};\n    }\n  };\n\n  const handleOrderByChange = (e, { value }) => {\n    setOrderBy(value);\n    setActivePage(1);\n    setDropdownVisible(false);\n  };\n\n  const renderSelectedOption = (orderBy) => {\n    switch (orderBy) {\n      case 'remain_quota':\n        return '按剩余额度排序';\n      case 'used_quota':\n        return '按已用额度排序';\n      default:\n        return '默认排序';\n    }\n  };\n\n  return (\n    <>\n      <EditToken refresh={refresh} editingToken={editingToken} visiable={showEdit} handleClose={closeEdit}></EditToken>\n      <Form layout=\"horizontal\" style={{ marginTop: 10 }} labelPosition={'left'}>\n        <Form.Input\n          field=\"keyword\"\n          label=\"搜索关键字\"\n          placeholder=\"令牌名称\"\n          value={searchKeyword}\n          loading={searching}\n          onChange={handleKeywordChange}\n        />\n        {/* <Form.Input\n          field=\"token\"\n          label=\"Key\"\n          placeholder=\"密钥\"\n          value={searchToken}\n          loading={searching}\n          onChange={handleSearchTokenChange}\n        /> */}\n        <Button label=\"查询\" type=\"primary\" htmlType=\"submit\" className=\"btn-margin-right\"\n                onClick={searchTokens} style={{ marginRight: 8 }}>查询</Button>\n      </Form>\n\n      <Table style={{ marginTop: 20 }} columns={columns} dataSource={pageData} pagination={{\n        currentPage: activePage,\n        pageSize: pageSize,\n        total: tokenCount,\n        showSizeChanger: true,\n        pageSizeOptions: [10, 20, 50, 100],\n        formatPageText: (page) => `第 ${page.currentStart} - ${page.currentEnd} 条，共 ${tokens.length} 条`,\n        onPageSizeChange: (size) => {\n          setPageSize(size);\n          setActivePage(1);\n        },\n        onPageChange: handlePageChange\n      }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>\n      </Table>\n      <Button theme=\"light\" type=\"primary\" style={{ marginRight: 8 }} onClick={\n        () => {\n          setEditingToken({\n            id: undefined\n          });\n          setShowEdit(true);\n        }\n      }>添加令牌</Button>\n      <Button label=\"复制所选令牌\" type=\"warning\" onClick={\n        async () => {\n          if (selectedKeys.length === 0) {\n            showError('请至少选择一个令牌！');\n            return;\n          }\n          let keys = '';\n          for (let i = 0; i < selectedKeys.length; i++) {\n            keys += selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\\n';\n          }\n          await copyText(keys);\n        }\n      }>复制所选令牌到剪贴板</Button>\n      <Dropdown\n        trigger=\"click\"\n        position=\"bottomLeft\"\n        visible={dropdownVisible}\n        onVisibleChange={(visible) => setDropdownVisible(visible)}\n        render={\n          <Dropdown.Menu>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: '' })}>默认排序</Dropdown.Item>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: 'remain_quota' })}>按剩余额度排序</Dropdown.Item>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: 'used_quota' })}>按已用额度排序</Dropdown.Item>\n          </Dropdown.Menu>\n        }\n      >\n      <Button style={{ marginLeft: '10px' }}>{renderSelectedOption(orderBy)}</Button>\n      </Dropdown>\n    </>\n  );\n};\n\nexport default TokensTable;\n"
  },
  {
    "path": "web/air/src/components/UsersTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API, showError, showSuccess } from '../helpers';\nimport { Button, Form, Popconfirm, Space, Table, Tag, Tooltip, Dropdown } from '@douyinfe/semi-ui';\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderGroup, renderNumber, renderQuota } from '../helpers/render';\nimport AddUser from '../pages/User/AddUser';\nimport EditUser from '../pages/User/EditUser';\n\nfunction renderRole(role) {\n  switch (role) {\n    case 1:\n      return <Tag size=\"large\">普通用户</Tag>;\n    case 10:\n      return <Tag color=\"yellow\" size=\"large\">管理员</Tag>;\n    case 100:\n      return <Tag color=\"orange\" size=\"large\">超级管理员</Tag>;\n    default:\n      return <Tag color=\"red\" size=\"large\">未知身份</Tag>;\n  }\n}\n\nconst UsersTable = () => {\n  const columns = [{\n    title: 'ID', dataIndex: 'id'\n  }, {\n    title: '用户名', dataIndex: 'username'\n  }, {\n    title: '分组', dataIndex: 'group', render: (text, record, index) => {\n      return (<div>\n        {renderGroup(text)}\n      </div>);\n    }\n  }, {\n    title: '统计信息', dataIndex: 'info', render: (text, record, index) => {\n      return (<div>\n        <Space spacing={1}>\n          <Tooltip content={'剩余额度'}>\n            <Tag color=\"white\" size=\"large\">{renderQuota(record.quota)}</Tag>\n          </Tooltip>\n          <Tooltip content={'已用额度'}>\n            <Tag color=\"white\" size=\"large\">{renderQuota(record.used_quota)}</Tag>\n          </Tooltip>\n          <Tooltip content={'调用次数'}>\n            <Tag color=\"white\" size=\"large\">{renderNumber(record.request_count)}</Tag>\n          </Tooltip>\n        </Space>\n      </div>);\n    }\n  },\n  // {\n  //   title: '邀请信息', dataIndex: 'invite', render: (text, record, index) => {\n  //     return (<div>\n  //       <Space spacing={1}>\n  //         <Tooltip content={'邀请人数'}>\n  //           <Tag color=\"white\" size=\"large\">{renderNumber(record.aff_count)}</Tag>\n  //         </Tooltip>\n  //         <Tooltip content={'邀请总收益'}>\n  //           <Tag color=\"white\" size=\"large\">{renderQuota(record.aff_history_quota)}</Tag>\n  //         </Tooltip>\n  //         <Tooltip content={'邀请人ID'}>\n  //           {record.inviter_id === 0 ? <Tag color=\"white\" size=\"large\">无</Tag> :\n  //             <Tag color=\"white\" size=\"large\">{record.inviter_id}</Tag>}\n  //         </Tooltip>\n  //       </Space>\n  //     </div>);\n  //   }\n  // },\n  {\n    title: '角色', dataIndex: 'role', render: (text, record, index) => {\n      return (<div>\n        {renderRole(text)}\n      </div>);\n    }\n  },\n  {\n    title: '状态', dataIndex: 'status', render: (text, record, index) => {\n      return (<div>\n        {renderStatus(text)}\n      </div>);\n    }\n  },\n  {\n    title: '', dataIndex: 'operate', render: (text, record, index) => (<div>\n      <>\n        <Popconfirm\n          title=\"确定？\"\n          okType={'warning'}\n          onConfirm={() => {\n            manageUser(record.username, 'promote', record);\n          }}\n        >\n          <Button theme=\"light\" type=\"warning\" style={{ marginRight: 1 }}>提升</Button>\n        </Popconfirm>\n        <Popconfirm\n          title=\"确定？\"\n          okType={'warning'}\n          onConfirm={() => {\n            manageUser(record.username, 'demote', record);\n          }}\n        >\n          <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }}>降级</Button>\n        </Popconfirm>\n        {record.status === 1 ?\n          <Button theme=\"light\" type=\"warning\" style={{ marginRight: 1 }} onClick={async () => {\n            manageUser(record.username, 'disable', record);\n          }}>禁用</Button> :\n          <Button theme=\"light\" type=\"secondary\" style={{ marginRight: 1 }} onClick={async () => {\n            manageUser(record.username, 'enable', record);\n          }} disabled={record.status === 3}>启用</Button>}\n        <Button theme=\"light\" type=\"tertiary\" style={{ marginRight: 1 }} onClick={() => {\n          setEditingUser(record);\n          setShowEditUser(true);\n        }}>编辑</Button>\n      </>\n      <Popconfirm\n        title=\"确定是否要删除此用户？\"\n        content=\"硬删除，此修改将不可逆\"\n        okType={'danger'}\n        position={'left'}\n        onConfirm={() => {\n          manageUser(record.username, 'delete', record).then(() => {\n            removeRecord(record.id);\n          });\n        }}\n      >\n        <Button theme=\"light\" type=\"danger\" style={{ marginRight: 1 }}>删除</Button>\n      </Popconfirm>\n    </div>)\n  }];\n\n  const [users, setUsers] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);\n  const [showAddUser, setShowAddUser] = useState(false);\n  const [showEditUser, setShowEditUser] = useState(false);\n  const [editingUser, setEditingUser] = useState({\n    id: undefined\n  });\n  const [orderBy, setOrderBy] = useState('');\n  const [dropdownVisible, setDropdownVisible] = useState(false);\n\n  const setCount = (data) => {\n    if (data.length >= (activePage) * ITEMS_PER_PAGE) {\n      setUserCount(data.length + 1);\n    } else {\n      setUserCount(data.length);\n    }\n  };\n\n  const removeRecord = key => {\n    console.log(key);\n    let newDataSource = [...users];\n    if (key != null) {\n      let idx = newDataSource.findIndex(data => data.id === key);\n\n      if (idx > -1) {\n        newDataSource.splice(idx, 1);\n        setUsers(newDataSource);\n      }\n    }\n  };\n\n  const loadUsers = async (startIdx) => {\n    const res = await API.get(`/api/user/?p=${startIdx}&order=${orderBy}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setUsers(data);\n        setCount(data);\n      } else {\n        let newUsers = users;\n        newUsers.push(...data);\n        setUsers(newUsers);\n        setCount(newUsers);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadUsers(activePage - 1, orderBy);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  useEffect(() => {\n    loadUsers(0, orderBy)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, [orderBy]);\n\n  const manageUser = async (username, action, record) => {\n    const res = await API.post('/api/user/manage', {\n      username, action\n    });\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      let user = res.data.data;\n      let newUsers = [...users];\n      if (action === 'delete') {\n\n      } else {\n        record.status = user.status;\n        record.role = user.role;\n      }\n      setUsers(newUsers);\n    } else {\n      showError(message);\n    }\n  };\n\n  const renderStatus = (status) => {\n    switch (status) {\n      case 1:\n        return <Tag size=\"large\">已激活</Tag>;\n      case 2:\n        return (<Tag size=\"large\" color=\"red\">\n          已封禁\n        </Tag>);\n      default:\n        return (<Tag size=\"large\" color=\"grey\">\n          未知状态\n        </Tag>);\n    }\n  };\n\n  const searchUsers = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadUsers(0);\n      setActivePage(1);\n      setOrderBy('');\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setUsers(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (value) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortUser = (key) => {\n    if (users.length === 0) return;\n    setLoading(true);\n    let sortedUsers = [...users];\n    sortedUsers.sort((a, b) => {\n      return ('' + a[key]).localeCompare(b[key]);\n    });\n    if (sortedUsers[0].id === users[0].id) {\n      sortedUsers.reverse();\n    }\n    setUsers(sortedUsers);\n    setLoading(false);\n  };\n\n  const handlePageChange = page => {\n    setActivePage(page);\n    if (page === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {\n      // In this case we have to load more data and then append them.\n      loadUsers(page - 1).then(r => {\n      });\n    }\n  };\n\n  const pageData = users.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);\n\n  const closeAddUser = () => {\n    setShowAddUser(false);\n  };\n\n  const closeEditUser = () => {\n    setShowEditUser(false);\n    setEditingUser({\n      id: undefined\n    });\n  };\n\n  const refresh = async () => {\n    if (searchKeyword === '') {\n      await loadUsers(activePage - 1);\n    } else {\n      await searchUsers();\n    }\n  };\n\n  const handleOrderByChange = (e, { value }) => {\n    setOrderBy(value);\n    setActivePage(1);\n    setDropdownVisible(false);\n  };\n\n  const renderSelectedOption = (orderBy) => {\n    switch (orderBy) {\n      case 'quota':\n        return '按剩余额度排序';\n      case 'used_quota':\n        return '按已用额度排序';\n      case 'request_count':\n        return '按请求次数排序';\n      default:\n        return '默认排序';\n    }\n  };\n\n  return (\n    <>\n      <AddUser refresh={refresh} visible={showAddUser} handleClose={closeAddUser}></AddUser>\n      <EditUser refresh={refresh} visible={showEditUser} handleClose={closeEditUser}\n        editingUser={editingUser}></EditUser>\n      <Form onSubmit={searchUsers}>\n        <Form.Input\n          label=\"搜索关键字\"\n          icon=\"search\"\n          field=\"keyword\"\n          iconPosition=\"left\"\n          placeholder=\"搜索用户的 ID，用户名，显示名称，以及邮箱地址 ...\"\n          value={searchKeyword}\n          loading={searching}\n          onChange={value => handleKeywordChange(value)}\n        />\n      </Form>\n\n      <Table columns={columns} dataSource={pageData} pagination={{\n        currentPage: activePage,\n        pageSize: ITEMS_PER_PAGE,\n        total: userCount,\n        pageSizeOpts: [10, 20, 50, 100],\n        onPageChange: handlePageChange\n      }} loading={loading} />\n      <Button theme=\"light\" type=\"primary\" style={{ marginRight: 8 }} onClick={\n        () => {\n          setShowAddUser(true);\n        }\n      }>添加用户</Button>\n      <Dropdown\n        trigger=\"click\"\n        position=\"bottomLeft\"\n        visible={dropdownVisible}\n        onVisibleChange={(visible) => setDropdownVisible(visible)}\n        render={\n          <Dropdown.Menu>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: '' })}>默认排序</Dropdown.Item>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: 'quota' })}>按剩余额度排序</Dropdown.Item>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: 'used_quota' })}>按已用额度排序</Dropdown.Item>\n            <Dropdown.Item onClick={() => handleOrderByChange('', { value: 'request_count' })}>按请求次数排序</Dropdown.Item>\n          </Dropdown.Menu>\n        }\n      >\n        <Button style={{ marginLeft: '10px' }}>{renderSelectedOption(orderBy)}</Button>\n      </Dropdown>\n    </>\n  );\n};\n\nexport default UsersTable;\n"
  },
  {
    "path": "web/air/src/components/WeChatIcon.js",
    "content": "import React from 'react';\nimport { Icon } from '@douyinfe/semi-ui';\n\nconst WeChatIcon = () => {\n  function CustomIcon() {\n    return <svg t=\"1709714447384\" className=\"icon\" viewBox=\"0 0 1024 1024\" version=\"1.1\"\n                xmlns=\"http://www.w3.org/2000/svg\" p-id=\"5091\" width=\"16\" height=\"16\">\n      <path\n        d=\"M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z\"\n        p-id=\"5092\"></path>\n      <path\n        d=\"M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z\"\n        p-id=\"5093\"></path>\n    </svg>;\n  }\n\n  return (\n    <div>\n      <Icon svg={<CustomIcon />} />\n    </div>\n  );\n};\n\nexport default WeChatIcon;\n"
  },
  {
    "path": "web/air/src/components/utils.js",
    "content": "import { API, showError } from '../helpers';\n\nexport async function getOAuthState() {\n  const res = await API.get('/api/oauth/state');\n  const { success, message, data } = res.data;\n  if (success) {\n    return data;\n  } else {\n    showError(message);\n    return '';\n  }\n}\n\nexport async function onGitHubOAuthClicked(github_client_id) {\n  const state = await getOAuthState();\n  if (!state) return;\n  window.open(\n    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`\n  );\n}"
  },
  {
    "path": "web/air/src/constants/channel.constants.js",
    "content": "export const CHANNEL_OPTIONS = [\n  { key: 1, text: 'OpenAI', value: 1, color: 'green' },\n  { key: 14, text: 'Anthropic Claude', value: 14, color: 'black' },\n  { key: 33, text: 'AWS', value: 33, color: 'black' },\n  { key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' },\n  { key: 11, text: 'Google PaLM2', value: 11, color: 'orange' },\n  { key: 24, text: 'Google Gemini', value: 24, color: 'orange' },\n  { key: 28, text: 'Mistral AI', value: 28, color: 'orange' },\n  { key: 41, text: 'Novita', value: 41, color: 'purple' },\n  {key: 40, text: '字节火山引擎', value: 40, color: 'blue'},\n  { key: 15, text: '百度文心千帆', value: 15, color: 'blue' },\n  { key: 17, text: '阿里通义千问', value: 17, color: 'orange' },\n  { key: 18, text: '讯飞星火认知', value: 18, color: 'blue' },\n  { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' },\n  { key: 19, text: '360 智脑', value: 19, color: 'blue' },\n  { key: 25, text: 'Moonshot AI', value: 25, color: 'black' },\n  { key: 23, text: '腾讯混元', value: 23, color: 'teal' },\n  { key: 26, text: '百川大模型', value: 26, color: 'orange' },\n  { key: 27, text: 'MiniMax', value: 27, color: 'red' },\n  { key: 29, text: 'Groq', value: 29, color: 'orange' },\n  { key: 30, text: 'Ollama', value: 30, color: 'black' },\n  { key: 31, text: '零一万物', value: 31, color: 'green' },\n  { key: 32, text: '阶跃星辰', value: 32, color: 'blue' },\n  { key: 34, text: 'Coze', value: 34, color: 'blue' },\n  { key: 35, text: 'Cohere', value: 35, color: 'blue' },\n  { key: 36, text: 'DeepSeek', value: 36, color: 'black' },\n  { key: 37, text: 'Cloudflare', value: 37, color: 'orange' },\n  { key: 38, text: 'DeepL', value: 38, color: 'black' },\n  { key: 39, text: 'together.ai', value: 39, color: 'blue' },\n  { key: 42, text: 'VertexAI', value: 42, color: 'blue' },\n  { key: 43, text: 'Proxy', value: 43, color: 'blue' },\n  { key: 44, text: 'SiliconFlow', value: 44, color: 'blue' },\n  { key: 45, text: 'xAI', value: 45, color: 'blue' },\n  { key: 46, text: 'Replicate', value: 46, color: 'blue' },\n  { key: 8, text: '自定义渠道', value: 8, color: 'pink' },\n  { key: 22, text: '知识库：FastGPT', value: 22, color: 'blue' },\n  { key: 21, text: '知识库：AI Proxy', value: 21, color: 'purple' },\n  {key: 20, text: 'OpenRouter', value: 20, color: 'black'},\n  { key: 2, text: '代理：API2D', value: 2, color: 'blue' },\n  { key: 5, text: '代理：OpenAI-SB', value: 5, color: 'brown' },\n  { key: 7, text: '代理：OhMyGPT', value: 7, color: 'purple' },\n  { key: 10, text: '代理：AI Proxy', value: 10, color: 'purple' },\n  { key: 4, text: '代理：CloseAI', value: 4, color: 'teal' },\n  { key: 6, text: '代理：OpenAI Max', value: 6, color: 'violet' },\n  { key: 9, text: '代理：AI.LS', value: 9, color: 'yellow' },\n  { key: 12, text: '代理：API2GPT', value: 12, color: 'blue' },\n  { key: 13, text: '代理：AIGC2D', value: 13, color: 'purple' }\n];\n\nfor (let i = 0; i < CHANNEL_OPTIONS.length; i++) {\n  CHANNEL_OPTIONS[i].label = CHANNEL_OPTIONS[i].text;\n}\n"
  },
  {
    "path": "web/air/src/constants/common.constant.js",
    "content": "export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!\n"
  },
  {
    "path": "web/air/src/constants/index.js",
    "content": "export * from './toast.constants';\nexport * from './user.constants';\nexport * from './common.constant';\nexport * from './channel.constants';"
  },
  {
    "path": "web/air/src/constants/toast.constants.js",
    "content": "export const toastConstants = {\n  SUCCESS_TIMEOUT: 1500,\n  INFO_TIMEOUT: 3000,\n  ERROR_TIMEOUT: 5000,\n  WARNING_TIMEOUT: 10000,\n  NOTICE_TIMEOUT: 20000\n};\n"
  },
  {
    "path": "web/air/src/constants/user.constants.js",
    "content": "export const userConstants = {\n    REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',\n    REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',\n    REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',\n\n    LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',\n    LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',\n    LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',\n    \n    LOGOUT: 'USERS_LOGOUT',\n\n    GETALL_REQUEST: 'USERS_GETALL_REQUEST',\n    GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',\n    GETALL_FAILURE: 'USERS_GETALL_FAILURE',\n\n    DELETE_REQUEST: 'USERS_DELETE_REQUEST',\n    DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',\n    DELETE_FAILURE: 'USERS_DELETE_FAILURE'    \n};\n"
  },
  {
    "path": "web/air/src/context/Status/index.js",
    "content": "// contexts/User/index.jsx\n\nimport React from 'react';\nimport { initialState, reducer } from './reducer';\n\nexport const StatusContext = React.createContext({\n  state: initialState,\n  dispatch: () => null,\n});\n\nexport const StatusProvider = ({ children }) => {\n  const [state, dispatch] = React.useReducer(reducer, initialState);\n\n  return (\n    <StatusContext.Provider value={[state, dispatch]}>\n      {children}\n    </StatusContext.Provider>\n  );\n};"
  },
  {
    "path": "web/air/src/context/Status/reducer.js",
    "content": "export const reducer = (state, action) => {\n  switch (action.type) {\n    case 'set':\n      return {\n        ...state,\n        status: action.payload,\n      };\n    case 'unset':\n      return {\n        ...state,\n        status: undefined,\n      };\n    default:\n      return state;\n  }\n};\n\nexport const initialState = {\n  status: undefined,\n};\n"
  },
  {
    "path": "web/air/src/context/User/index.js",
    "content": "// contexts/User/index.jsx\n\nimport React from \"react\"\nimport { reducer, initialState } from \"./reducer\"\n\nexport const UserContext = React.createContext({\n  state: initialState,\n  dispatch: () => null\n})\n\nexport const UserProvider = ({ children }) => {\n  const [state, dispatch] = React.useReducer(reducer, initialState)\n\n  return (\n    <UserContext.Provider value={[ state, dispatch ]}>\n      { children }\n    </UserContext.Provider>\n  )\n}"
  },
  {
    "path": "web/air/src/context/User/reducer.js",
    "content": "export const reducer = (state, action) => {\n  switch (action.type) {\n    case 'login':\n      return {\n        ...state,\n        user: action.payload\n      };\n    case 'logout':\n      return {\n        ...state,\n        user: undefined\n      };\n\n    default:\n      return state;\n  }\n};\n\nexport const initialState = {\n  user: undefined\n};"
  },
  {
    "path": "web/air/src/helpers/api.js",
    "content": "import { showError } from './utils';\nimport axios from 'axios';\n\nexport const API = axios.create({\n  baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',\n});\n\nAPI.interceptors.response.use(\n  (response) => response,\n  (error) => {\n    showError(error);\n  }\n);\n"
  },
  {
    "path": "web/air/src/helpers/auth-header.js",
    "content": "export function authHeader() {\n    // return authorization header with jwt token\n    let user = JSON.parse(localStorage.getItem('user'));\n\n    if (user && user.token) {\n        return { 'Authorization': 'Bearer ' + user.token };\n    } else {\n        return {};\n    }\n}"
  },
  {
    "path": "web/air/src/helpers/history.js",
    "content": "import { createBrowserHistory } from 'history';\n\nexport const history = createBrowserHistory();"
  },
  {
    "path": "web/air/src/helpers/index.js",
    "content": "export * from './history';\nexport * from './auth-header';\nexport * from './utils';\nexport * from './api';"
  },
  {
    "path": "web/air/src/helpers/render.js",
    "content": "import {Label} from 'semantic-ui-react';\nimport {Tag} from \"@douyinfe/semi-ui\";\n\nexport function renderText(text, limit) {\n    if (text.length > limit) {\n        return text.slice(0, limit - 3) + '...';\n    }\n    return text;\n}\n\nexport function renderGroup(group) {\n    if (group === '') {\n        return <Tag size='large'>default</Tag>;\n    }\n    let groups = group.split(',');\n    groups.sort();\n    return <>\n        {groups.map((group) => {\n            if (group === 'vip' || group === 'pro') {\n                return <Tag size='large' color='yellow'>{group}</Tag>;\n            } else if (group === 'svip' || group === 'premium') {\n                return <Tag size='large' color='red'>{group}</Tag>;\n            }\n            if (group === 'default') {\n                return <Tag size='large'>{group}</Tag>;\n            } else {\n                return <Tag size='large' color={stringToColor(group)}>{group}</Tag>;\n            }\n        })}\n    </>;\n}\n\nexport function renderNumber(num) {\n    if (num >= 1000000000) {\n        return (num / 1000000000).toFixed(1) + 'B';\n    } else if (num >= 1000000) {\n        return (num / 1000000).toFixed(1) + 'M';\n    } else if (num >= 10000) {\n        return (num / 1000).toFixed(1) + 'k';\n    } else {\n        return num;\n    }\n}\n\nexport function renderQuotaNumberWithDigit(num, digits = 2) {\n    let displayInCurrency = localStorage.getItem('display_in_currency');\n    num = num.toFixed(digits);\n    if (displayInCurrency) {\n        return '$' + num;\n    }\n    return num;\n}\n\nexport function renderNumberWithPoint(num) {\n    num = num.toFixed(2);\n    if (num >= 100000) {\n        // Convert number to string to manipulate it\n        let numStr = num.toString();\n        // Find the position of the decimal point\n        let decimalPointIndex = numStr.indexOf('.');\n\n        let wholePart = numStr;\n        let decimalPart = '';\n\n        // If there is a decimal point, split the number into whole and decimal parts\n        if (decimalPointIndex !== -1) {\n            wholePart = numStr.slice(0, decimalPointIndex);\n            decimalPart = numStr.slice(decimalPointIndex);\n        }\n\n        // Take the first two and last two digits of the whole number part\n        let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);\n\n        // Return the formatted number\n        return shortenedWholePart + decimalPart;\n    }\n\n    // If the number is less than 100,000, return it unmodified\n    return num;\n}\n\nexport function getQuotaPerUnit() {\n    let quotaPerUnit = localStorage.getItem('quota_per_unit');\n    quotaPerUnit = parseFloat(quotaPerUnit);\n    return quotaPerUnit;\n}\n\nexport function getQuotaWithUnit(quota, digits = 6) {\n    let quotaPerUnit = localStorage.getItem('quota_per_unit');\n    quotaPerUnit = parseFloat(quotaPerUnit);\n    return (quota / quotaPerUnit).toFixed(digits);\n}\n\nexport function renderQuota(quota, digits = 2) {\n    let quotaPerUnit = localStorage.getItem('quota_per_unit');\n    let displayInCurrency = localStorage.getItem('display_in_currency');\n    quotaPerUnit = parseFloat(quotaPerUnit);\n    displayInCurrency = displayInCurrency === 'true';\n    if (displayInCurrency) {\n        return '$' + (quota / quotaPerUnit).toFixed(digits);\n    }\n    return renderNumber(quota);\n}\n\nexport function renderQuotaWithPrompt(quota, digits) {\n    let displayInCurrency = localStorage.getItem('display_in_currency');\n    displayInCurrency = displayInCurrency === 'true';\n    if (displayInCurrency) {\n        return `（等价金额：${renderQuota(quota, digits)}）`;\n    }\n    return '';\n}\n\nconst colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',\n    'light-blue', 'lime', 'orange', 'pink',\n    'purple', 'red', 'teal', 'violet', 'yellow'\n]\n\nexport const modelColorMap = {\n    'dall-e': 'rgb(147,112,219)',  // 深紫色\n    'dall-e-2': 'rgb(147,112,219)',  // 介于紫色和蓝色之间的色调\n    'dall-e-3': 'rgb(153,50,204)',  // 介于紫罗兰和洋红之间的色调\n    'midjourney': 'rgb(136,43,180)',  // 介于紫罗兰和洋红之间的色调\n    'gpt-3.5-turbo': 'rgb(184,227,167)',  // 浅绿色\n    'gpt-3.5-turbo-0301': 'rgb(131,220,131)',  // 亮绿色\n    'gpt-3.5-turbo-0613': 'rgb(60,179,113)',  // 海洋绿\n    'gpt-3.5-turbo-1106': 'rgb(32,178,170)',  // 浅海洋绿\n    'gpt-3.5-turbo-16k': 'rgb(252,200,149)',  // 淡橙色\n    'gpt-3.5-turbo-16k-0613': 'rgb(255,181,119)',  // 淡桃色\n    'gpt-3.5-turbo-instruct': 'rgb(175,238,238)',  // 粉蓝色\n    'gpt-4': 'rgb(135,206,235)',  // 天蓝色\n    'gpt-4-0314': 'rgb(70,130,180)',  // 钢蓝色\n    'gpt-4-0613': 'rgb(100,149,237)',  // 矢车菊蓝\n    'gpt-4-1106-preview': 'rgb(30,144,255)',  // 道奇蓝\n    'gpt-4-0125-preview': 'rgb(2,177,236)',  // 深天蓝\n    'gpt-4-turbo-preview': 'rgb(2,177,255)',  // 深天蓝\n    'gpt-4-32k': 'rgb(104,111,238)',  // 中紫色\n    'gpt-4-32k-0314': 'rgb(90,105,205)',  // 暗灰蓝色\n    'gpt-4-32k-0613': 'rgb(61,71,139)',  // 暗蓝灰色\n    'gpt-4-all': 'rgb(65,105,225)',  // 皇家蓝\n    'gpt-4-gizmo-*': 'rgb(0,0,255)',  // 纯蓝色\n    'gpt-4-vision-preview': 'rgb(25,25,112)',  // 午夜蓝\n    'text-ada-001': 'rgb(255,192,203)',  // 粉红色\n    'text-babbage-001': 'rgb(255,160,122)',  // 浅珊瑚色\n    'text-curie-001': 'rgb(219,112,147)',  // 苍紫罗兰色\n    'text-davinci-002': 'rgb(199,21,133)',  // 中紫罗兰红色\n    'text-davinci-003': 'rgb(219,112,147)',  // 苍紫罗兰色（与Curie相同，表示同一个系列）\n    'text-davinci-edit-001': 'rgb(255,105,180)',  // 热粉色\n    'text-embedding-ada-002': 'rgb(255,182,193)',  // 浅粉红\n    'text-embedding-v1': 'rgb(255,174,185)',  // 浅粉红色（略有区别）\n    'text-moderation-latest': 'rgb(255,130,171)',  // 强粉色\n    'text-moderation-stable': 'rgb(255,160,122)',  // 浅珊瑚色（与Babbage相同，表示同一类功能）\n    'tts-1': 'rgb(255,140,0)',  // 深橙色\n    'tts-1-1106': 'rgb(255,165,0)',  // 橙色\n    'tts-1-hd': 'rgb(255,215,0)',  // 金色\n    'tts-1-hd-1106': 'rgb(255,223,0)',  // 金黄色（略有区别）\n    'whisper-1': 'rgb(245,245,220)'  // 米色\n}\n\nexport function stringToColor(str) {\n    let sum = 0;\n    // 对字符串中的每个字符进行操作\n    for (let i = 0; i < str.length; i++) {\n        // 将字符的ASCII值加到sum中\n        sum += str.charCodeAt(i);\n    }\n    // 使用模运算得到个位数\n    let i = sum % colors.length;\n    return colors[i];\n}"
  },
  {
    "path": "web/air/src/helpers/utils.js",
    "content": "import { Toast } from '@douyinfe/semi-ui';\nimport { toastConstants } from '../constants';\nimport React from 'react';\nimport {toast} from \"react-toastify\";\n\nconst HTMLToastContent = ({ htmlContent }) => {\n  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;\n};\nexport default HTMLToastContent;\nexport function isAdmin() {\n  let user = localStorage.getItem('user');\n  if (!user) return false;\n  user = JSON.parse(user);\n  return user.role >= 10;\n}\n\nexport function isRoot() {\n  let user = localStorage.getItem('user');\n  if (!user) return false;\n  user = JSON.parse(user);\n  return user.role >= 100;\n}\n\nexport function getSystemName() {\n  let system_name = localStorage.getItem('system_name');\n  if (!system_name) return 'One API';\n  return system_name;\n}\n\nexport function getLogo() {\n  let logo = localStorage.getItem('logo');\n  if (!logo) return '/logo.png';\n  return logo\n}\n\nexport function getFooterHTML() {\n  return localStorage.getItem('footer_html');\n}\n\nexport async function copy(text) {\n  let okay = true;\n  try {\n    await navigator.clipboard.writeText(text);\n  } catch (e) {\n    okay = false;\n    console.error(e);\n  }\n  return okay;\n}\n\nexport function isMobile() {\n  return window.innerWidth <= 600;\n}\n\nlet showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };\nlet showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };\nlet showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };\nlet showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };\nlet showNoticeOptions = { autoClose: false };\n\nif (isMobile()) {\n  showErrorOptions.position = 'top-center';\n  // showErrorOptions.transition = 'flip';\n\n  showSuccessOptions.position = 'top-center';\n  // showSuccessOptions.transition = 'flip';\n\n  showInfoOptions.position = 'top-center';\n  // showInfoOptions.transition = 'flip';\n\n  showNoticeOptions.position = 'top-center';\n  // showNoticeOptions.transition = 'flip';\n}\n\nexport function showError(error) {\n  console.error(error);\n  if (error.message) {\n    if (error.name === 'AxiosError') {\n      switch (error.response.status) {\n        case 401:\n          // toast.error('错误：未登录或登录已过期，请重新登录！', showErrorOptions);\n          window.location.href = '/login?expired=true';\n          break;\n        case 429:\n          Toast.error('错误：请求次数过多，请稍后再试！');\n          break;\n        case 500:\n          Toast.error('错误：服务器内部错误，请联系管理员！');\n          break;\n        case 405:\n          Toast.info('本站仅作演示之用，无服务端！');\n          break;\n        default:\n          Toast.error('错误：' + error.message);\n      }\n      return;\n    }\n    Toast.error('错误：' + error.message);\n  } else {\n    Toast.error('错误：' + error);\n  }\n}\n\nexport function showWarning(message) {\n  Toast.warning(message);\n}\n\nexport function showSuccess(message) {\n  Toast.success(message);\n}\n\nexport function showInfo(message) {\n  Toast.info(message);\n}\n\nexport function showNotice(message, isHTML = false) {\n  if (isHTML) {\n    toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);\n  } else {\n    Toast.info(message);\n  }\n}\n\nexport function openPage(url) {\n  window.open(url);\n}\n\nexport function removeTrailingSlash(url) {\n  if (url.endsWith('/')) {\n    return url.slice(0, -1);\n  } else {\n    return url;\n  }\n}\n\nexport function timestamp2string(timestamp) {\n  let date = new Date(timestamp * 1000);\n  let year = date.getFullYear().toString();\n  let month = (date.getMonth() + 1).toString();\n  let day = date.getDate().toString();\n  let hour = date.getHours().toString();\n  let minute = date.getMinutes().toString();\n  let second = date.getSeconds().toString();\n  if (month.length === 1) {\n    month = '0' + month;\n  }\n  if (day.length === 1) {\n    day = '0' + day;\n  }\n  if (hour.length === 1) {\n    hour = '0' + hour;\n  }\n  if (minute.length === 1) {\n    minute = '0' + minute;\n  }\n  if (second.length === 1) {\n    second = '0' + second;\n  }\n  return (\n    year +\n    '-' +\n    month +\n    '-' +\n    day +\n    ' ' +\n    hour +\n    ':' +\n    minute +\n    ':' +\n    second\n  );\n}\n\nexport function timestamp2string1(timestamp, dataExportDefaultTime = 'hour') {\n  let date = new Date(timestamp * 1000);\n  // let year = date.getFullYear().toString();\n  let month = (date.getMonth() + 1).toString();\n  let day = date.getDate().toString();\n  let hour = date.getHours().toString();\n  if (month.length === 1) {\n    month = '0' + month;\n  }\n  if (day.length === 1) {\n    day = '0' + day;\n  }\n  if (hour.length === 1) {\n    hour = '0' + hour;\n  }\n  let str = month + '-' + day\n  if (dataExportDefaultTime === 'hour') {\n    str += ' ' + hour + \":00\"\n  } else if (dataExportDefaultTime === 'week') {\n    let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000);\n    let nextMonth = (nextWeek.getMonth() + 1).toString();\n    let nextDay = nextWeek.getDate().toString();\n    if (nextMonth.length === 1) {\n        nextMonth = '0' + nextMonth;\n    }\n    if (nextDay.length === 1) {\n        nextDay = '0' + nextDay;\n    }\n    str += ' - ' + nextMonth + '-' + nextDay\n  }\n  return str;\n}\n\nexport function downloadTextAsFile(text, filename) {\n  let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });\n  let url = URL.createObjectURL(blob);\n  let a = document.createElement('a');\n  a.href = url;\n  a.download = filename;\n  a.click();\n}\n\nexport const verifyJSON = (str) => {\n  try {\n    JSON.parse(str);\n  } catch (e) {\n    return false;\n  }\n  return true;\n};\n\nexport function shouldShowPrompt(id) {\n  let prompt = localStorage.getItem(`prompt-${id}`);\n  return !prompt;\n\n}\n\nexport function setPromptShown(id) {\n  localStorage.setItem(`prompt-${id}`, 'true');\n}"
  },
  {
    "path": "web/air/src/index.css",
    "content": "body {\n    margin: 0;\n    padding-top: 55px;\n    overflow-y: scroll;\n    font-family: Lato, 'Helvetica Neue', Arial, Helvetica, \"Microsoft YaHei\", sans-serif;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    scrollbar-width: none;\n    color: var(--semi-color-text-0) !important;\n    background-color: var( --semi-color-bg-0) !important;\n    height: 100%;\n}\n\n#root {\n    height: 100%;\n}\n\n@media only screen and (max-width: 767px) {\n    .semi-table-tbody, .semi-table-row, .semi-table-row-cell {\n        display: block!important;\n        width: auto!important;\n        padding: 2px!important;\n    }\n    .semi-table-row-cell {\n        border-bottom: 0!important;\n    }\n    .semi-table-tbody>.semi-table-row {\n        border-bottom: 1px solid rgba(0,0,0,.1);\n    }\n    .semi-space {\n        /*display: block!important;*/\n        display: flex;\n        flex-direction: row;\n        flex-wrap: wrap;\n        row-gap: 3px;\n        column-gap: 10px;\n    }\n}\n\n.semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n    padding: 16px 14px;\n}\n\n.channel-table {\n    .semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n        padding: 16px 8px;\n    }\n}\n\n.semi-layout {\n    height: 100%;\n}\n\n.tableShow {\n    display: revert;\n}\n\n.tableHiddle {\n    display: none !important;\n}\n\nbody::-webkit-scrollbar {\n    display: none;\n}\n\ncode {\n    font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;\n}\n\n.semi-navigation-vertical {\n    /*display: flex;*/\n    /*flex-direction: column;*/\n}\n\n.semi-navigation-item {\n    margin-bottom: 0;\n}\n\n.semi-navigation-vertical {\n    /*flex: 0 0 auto;*/\n    /*display: flex;*/\n    /*flex-direction: column;*/\n    /*width: 100%;*/\n    height: 100%;\n    overflow: hidden;\n}\n\n.main-content {\n    padding: 4px;\n    height: 100%;\n}\n\n.small-icon .icon {\n    font-size: 1em !important;\n}\n\n.custom-footer {\n    font-size: 1.1em;\n}\n\n@media only screen and (max-width: 600px) {\n    .hide-on-mobile {\n        display: none !important;\n    }\n}\n\n\n/* 隐藏浏览器默认的滚动条 */\nbody {\n    overflow: hidden;\n}\n\n/* 自定义滚动条样式 */\nbody::-webkit-scrollbar {\n    width: 0;  /* 隐藏滚动条的宽度 */\n}"
  },
  {
    "path": "web/air/src/index.js",
    "content": "import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport {BrowserRouter} from 'react-router-dom';\nimport App from './App';\nimport HeaderBar from './components/HeaderBar';\nimport Footer from './components/Footer';\nimport 'semantic-ui-css/semantic.min.css';\nimport './index.css';\nimport {UserProvider} from './context/User';\nimport {ToastContainer} from 'react-toastify';\nimport 'react-toastify/dist/ReactToastify.css';\nimport {StatusProvider} from './context/Status';\nimport {Layout} from \"@douyinfe/semi-ui\";\nimport SiderBar from \"./components/SiderBar\";\n\n// initialization\ninitVChartSemiTheme({\n    isWatchingThemeSwitch: true,\n});\n\nconst root = ReactDOM.createRoot(document.getElementById('root'));\nconst {Sider, Content, Header} = Layout;\nroot.render(\n    <React.StrictMode>\n        <StatusProvider>\n            <UserProvider>\n                <BrowserRouter>\n                    <Layout>\n                        <Sider>\n                            <SiderBar/>\n                        </Sider>\n                        <Layout>\n                            <Header>\n                                <HeaderBar/>\n                            </Header>\n                            <Content\n                                style={{\n                                    padding: '24px',\n                                }}\n                            >\n                                <App/>\n                            </Content>\n                            <Layout.Footer>\n                                <Footer></Footer>\n                            </Layout.Footer>\n                        </Layout>\n                        <ToastContainer/>\n                    </Layout>\n                </BrowserRouter>\n            </UserProvider>\n        </StatusProvider>\n    </React.StrictMode>\n);\n"
  },
  {
    "path": "web/air/src/pages/About/index.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { Header, Segment } from 'semantic-ui-react';\nimport { API, showError } from '../../helpers';\nimport { marked } from 'marked';\n\nconst About = () => {\n  const [about, setAbout] = useState('');\n  const [aboutLoaded, setAboutLoaded] = useState(false);\n\n  const displayAbout = async () => {\n    setAbout(localStorage.getItem('about') || '');\n    const res = await API.get('/api/about');\n    const { success, message, data } = res.data;\n    if (success) {\n      let aboutContent = data;\n      if (!data.startsWith('https://')) {\n        aboutContent = marked.parse(data);\n      }\n      setAbout(aboutContent);\n      localStorage.setItem('about', aboutContent);\n    } else {\n      showError(message);\n      setAbout('加载关于内容失败...');\n    }\n    setAboutLoaded(true);\n  };\n\n  useEffect(() => {\n    displayAbout().then();\n  }, []);\n\n  return (\n    <>\n      {\n        aboutLoaded && about === '' ? <>\n          <Segment>\n            <Header as='h3'>关于</Header>\n            <p>可在设置页面设置关于内容，支持 HTML & Markdown</p>\n            项目仓库地址：\n            <a href='https://github.com/songquanpeng/one-api'>\n              https://github.com/songquanpeng/one-api\n            </a>\n          </Segment>\n        </> : <>\n          {\n            about.startsWith('https://') ? <iframe\n              src={about}\n              style={{ width: '100%', height: '100vh', border: 'none' }}\n            /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>\n          }\n        </>\n      }\n    </>\n  );\n};\n\n\nexport default About;\n"
  },
  {
    "path": "web/air/src/pages/Channel/EditChannel.js",
    "content": "import React, {useEffect, useRef, useState} from 'react';\nimport {useNavigate, useParams} from 'react-router-dom';\nimport {API, isMobile, showError, showInfo, showSuccess, verifyJSON} from '../../helpers';\nimport {CHANNEL_OPTIONS} from '../../constants';\nimport Title from \"@douyinfe/semi-ui/lib/es/typography/title\";\nimport {SideSheet, Space, Spin, Button, Input, Typography, Select, TextArea, Checkbox, Banner} from \"@douyinfe/semi-ui\";\n\nconst MODEL_MAPPING_EXAMPLE = {\n    'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',\n    'gpt-4-0314': 'gpt-4',\n    'gpt-4-32k-0314': 'gpt-4-32k'\n};\n\nfunction type2secretPrompt(type) {\n    // inputs.type === 15 ? '按照如下格式输入：APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入：APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')\n    switch (type) {\n        case 15:\n            return '按照如下格式输入：APIKey|SecretKey';\n        case 18:\n            return '按照如下格式输入：APPID|APISecret|APIKey';\n        case 22:\n            return '按照如下格式输入：APIKey-AppId，例如：fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';\n        case 23:\n            return '按照如下格式输入：AppId|SecretId|SecretKey';\n        default:\n            return '请输入渠道对应的鉴权密钥';\n    }\n}\n\nconst EditChannel = (props) => {\n    const navigate = useNavigate();\n    const channelId = props.editingChannel.id;\n    const isEdit = channelId !== undefined;\n    const [loading, setLoading] = useState(isEdit);\n    const handleCancel = () => {\n        props.handleClose()\n    };\n    const originInputs = {\n        name: '',\n        type: 1,\n        key: '',\n        openai_organization: '',\n        base_url: '',\n        other: '',\n        model_mapping: '',\n        system_prompt: '',\n        models: [],\n        auto_ban: 1,\n        groups: ['default']\n    };\n    const [batch, setBatch] = useState(false);\n    const [autoBan, setAutoBan] = useState(true);\n    // const [autoBan, setAutoBan] = useState(true);\n    const [inputs, setInputs] = useState(originInputs);\n    const [originModelOptions, setOriginModelOptions] = useState([]);\n    const [modelOptions, setModelOptions] = useState([]);\n    const [groupOptions, setGroupOptions] = useState([]);\n    const [basicModels, setBasicModels] = useState([]);\n    const [fullModels, setFullModels] = useState([]);\n    const [customModel, setCustomModel] = useState('');\n    const handleInputChange = (name, value) => {\n        setInputs((inputs) => ({...inputs, [name]: value}));\n        if (name === 'type' && inputs.models.length === 0) {\n            let localModels = [];\n            switch (value) {\n                case 14:\n                    localModels = [\"claude-instant-1.2\", \"claude-2\", \"claude-2.0\", \"claude-2.1\", \"claude-3-opus-20240229\", \"claude-3-sonnet-20240229\", \"claude-3-haiku-20240307\", \"claude-3-5-haiku-20241022\", \"claude-3-5-sonnet-20240620\", \"claude-3-5-sonnet-20241022\"];\n                    break;\n                case 11:\n                    localModels = ['PaLM-2'];\n                    break;\n                case 15:\n                    localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'ERNIE-Bot-4', 'Embedding-V1'];\n                    break;\n                case 17:\n                    localModels = [\"qwen-turbo\", \"qwen-plus\", \"qwen-max\", \"qwen-max-longcontext\", 'text-embedding-v1'];\n                    break;\n                case 16:\n                    localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite'];\n                    break;\n                case 18:\n                    localModels = ['SparkDesk', 'SparkDesk-v1.1', 'SparkDesk-v2.1', 'SparkDesk-v3.1', 'SparkDesk-v3.1-128K', 'SparkDesk-v3.5', 'SparkDesk-v3.5-32K', 'SparkDesk-v4.0'];\n                    break;\n                case 19:\n                    localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1'];\n                    break;\n                case 23:\n                    localModels = ['hunyuan'];\n                    break;\n                case 24:\n                    localModels = ['gemini-pro', 'gemini-pro-vision'];\n                    break;\n                case 25:\n                    localModels = ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'];\n                    break;\n                case 26:\n                    localModels = ['glm-4', 'glm-4v', 'glm-3-turbo'];\n                    break;\n                case 2:\n                    localModels = ['mj_imagine', 'mj_variation', 'mj_reroll', 'mj_blend', 'mj_upscale', 'mj_describe'];\n                    break;\n                case 5:\n                    localModels = [\n                        'swap_face',\n                        'mj_imagine',\n                        'mj_variation',\n                        'mj_reroll',\n                        'mj_blend',\n                        'mj_upscale',\n                        'mj_describe',\n                        'mj_zoom',\n                        'mj_shorten',\n                        'mj_modal',\n                        'mj_inpaint',\n                        'mj_custom_zoom',\n                        'mj_high_variation',\n                        'mj_low_variation',\n                        'mj_pan',\n                    ];\n                    break;\n            }\n            setInputs((inputs) => ({...inputs, models: localModels}));\n        }\n        //setAutoBan\n    };\n\n\n    const loadChannel = async () => {\n        setLoading(true)\n        let res = await API.get(`/api/channel/${channelId}`);\n        const {success, message, data} = res.data;\n        if (success) {\n            if (data.models === '') {\n                data.models = [];\n            } else {\n                data.models = data.models.split(',');\n            }\n            if (data.group === '') {\n                data.groups = [];\n            } else {\n                data.groups = data.group.split(',');\n            }\n            if (data.model_mapping !== '') {\n                data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);\n            }\n            setInputs(data);\n            if (data.auto_ban === 0) {\n                setAutoBan(false);\n            } else {\n                setAutoBan(true);\n            }\n            // console.log(data);\n        } else {\n            showError(message);\n        }\n        setLoading(false);\n    };\n\n    const fetchModels = async () => {\n        try {\n            let res = await API.get(`/api/channel/models`);\n            let localModelOptions = res.data.data.map((model) => ({\n                label: model.id,\n                value: model.id\n            }));\n            setOriginModelOptions(localModelOptions);\n            setFullModels(res.data.data.map((model) => model.id));\n            setBasicModels(res.data.data.filter((model) => {\n                return model.id.startsWith('gpt-3') || model.id.startsWith('text-');\n            }).map((model) => model.id));\n        } catch (error) {\n            showError(error.message);\n        }\n    };\n\n    const fetchGroups = async () => {\n        try {\n            let res = await API.get(`/api/group/`);\n            setGroupOptions(res.data.data.map((group) => ({\n                label: group,\n                value: group\n            })));\n        } catch (error) {\n            showError(error.message);\n        }\n    };\n\n    useEffect(() => {\n        let localModelOptions = [...originModelOptions];\n        inputs.models.forEach((model) => {\n            if (!localModelOptions.find((option) => option.key === model)) {\n                localModelOptions.push({\n                    label: model,\n                    value: model\n                });\n            }\n        });\n        setModelOptions(localModelOptions);\n    }, [originModelOptions, inputs.models]);\n\n    useEffect(() => {\n        fetchModels().then();\n        fetchGroups().then();\n        if (isEdit) {\n            loadChannel().then(\n                () => {\n\n                }\n            );\n        } else {\n            setInputs(originInputs)\n        }\n    }, [props.editingChannel.id]);\n\n\n    const submit = async () => {\n        if (!isEdit && (inputs.name === '' || inputs.key === '')) {\n            showInfo('请填写渠道名称和渠道密钥！');\n            return;\n        }\n        if (inputs.models.length === 0) {\n            showInfo('请至少选择一个模型！');\n            return;\n        }\n        if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {\n            showInfo('模型映射必须是合法的 JSON 格式！');\n            return;\n        }\n        let localInputs = {...inputs};\n        if (localInputs.base_url && localInputs.base_url.endsWith('/')) {\n            localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);\n        }\n        if (localInputs.type === 3 && localInputs.other === '') {\n            localInputs.other = '2024-03-01-preview';\n        }\n        if (localInputs.type === 18 && localInputs.other === '') {\n            localInputs.other = 'v2.1';\n        }\n        let res;\n        if (!Array.isArray(localInputs.models)) {\n            showError('提交失败，请勿重复提交！');\n            handleCancel();\n            return;\n        }\n        localInputs.auto_ban = autoBan ? 1 : 0;\n        localInputs.models = localInputs.models.join(',');\n        localInputs.group = localInputs.groups.join(',');\n        if (isEdit) {\n            res = await API.put(`/api/channel/`, {...localInputs, id: parseInt(channelId)});\n        } else {\n            res = await API.post(`/api/channel/`, localInputs);\n        }\n        const {success, message} = res.data;\n        if (success) {\n            if (isEdit) {\n                showSuccess('渠道更新成功！');\n            } else {\n                showSuccess('渠道创建成功！');\n                setInputs(originInputs);\n            }\n            props.refresh();\n            props.handleClose();\n        } else {\n            showError(message);\n        }\n    };\n\n    const addCustomModel = () => {\n        if (customModel.trim() === '') return;\n        if (inputs.models.includes(customModel)) return showError(\"该模型已存在！\");\n        let localModels = [...inputs.models];\n        localModels.push(customModel);\n        let localModelOptions = [];\n        localModelOptions.push({\n            key: customModel,\n            text: customModel,\n            value: customModel\n        });\n        setModelOptions(modelOptions => {\n            return [...modelOptions, ...localModelOptions];\n        });\n        setCustomModel('');\n        handleInputChange('models', localModels);\n    };\n\n    return (\n        <>\n            <SideSheet\n                maskClosable={false}\n                placement={isEdit ? 'right' : 'left'}\n                title={<Title level={3}>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Title>}\n                headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}\n                bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}\n                visible={props.visible}\n                footer={\n                    <div style={{display: 'flex', justifyContent: 'flex-end'}}>\n                        <Space>\n                            <Button theme='solid' size={'large'} onClick={submit}>提交</Button>\n                            <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>\n                        </Space>\n                    </div>\n                }\n                closeIcon={null}\n                onCancel={() => handleCancel()}\n                width={isMobile() ? '100%' : 600}\n            >\n                <Spin spinning={loading}>\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>类型：</Typography.Text>\n                    </div>\n                    <Select\n                      name='type'\n                      required\n                      optionList={CHANNEL_OPTIONS}\n                      value={inputs.type}\n                      onChange={value => handleInputChange('type', value)}\n                      style={{ width: '50%' }}\n                    />\n                    {\n                      inputs.type === 3 && (\n                        <>\n                            <div style={{ marginTop: 10 }}>\n                                <Banner type={\"warning\"} description={\n                                    <>\n                                        注意，<strong>模型部署名称必须和模型名称保持一致</strong>，因为 One API 会把请求体中的\n                                        model\n                                        参数替换为你的部署名称（模型名称中的点会被剔除），<a target='_blank'\n                                                                                          href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>。\n                                    </>\n                                }>\n                                </Banner>\n                            </div>\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>AZURE_OPENAI_ENDPOINT：</Typography.Text>\n                            </div>\n                            <Input\n                              label='AZURE_OPENAI_ENDPOINT'\n                              name='azure_base_url'\n                              placeholder={'请输入 AZURE_OPENAI_ENDPOINT，例如：https://docs-test-001.openai.azure.com'}\n                              onChange={value => {\n                                  handleInputChange('base_url', value)\n                              }}\n                              value={inputs.base_url}\n                              autoComplete='new-password'\n                            />\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>默认 API 版本：</Typography.Text>\n                            </div>\n                            <Input\n                              label='默认 API 版本'\n                              name='azure_other'\n                              placeholder={'请输入默认 API 版本，例如：2024-03-01-preview，该配置可以被实际的请求查询参数所覆盖'}\n                              onChange={value => {\n                                  handleInputChange('other', value)\n                              }}\n                              value={inputs.other}\n                              autoComplete='new-password'\n                            />\n                        </>\n                      )\n                    }\n                    {\n                      inputs.type === 8 && (\n                        <>\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>Base URL：</Typography.Text>\n                            </div>\n                            <Input\n                              name='base_url'\n                              placeholder={'请输入自定义渠道的 Base URL'}\n                              onChange={value => {\n                                  handleInputChange('base_url', value)\n                              }}\n                              value={inputs.base_url}\n                              autoComplete='new-password'\n                            />\n                        </>\n                      )\n                    }\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>名称：</Typography.Text>\n                    </div>\n                    <Input\n                      required\n                      name='name'\n                      placeholder={'请为渠道命名'}\n                      onChange={value => {\n                          handleInputChange('name', value)\n                      }}\n                      value={inputs.name}\n                      autoComplete='new-password'\n                    />\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>分组：</Typography.Text>\n                    </div>\n                    <Select\n                      placeholder={'请选择可以使用该渠道的分组'}\n                      name='groups'\n                      required\n                      multiple\n                      selection\n                      allowAdditions\n                      additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组：'}\n                      onChange={value => {\n                          handleInputChange('groups', value)\n                      }}\n                      value={inputs.groups}\n                      autoComplete='new-password'\n                      optionList={groupOptions}\n                    />\n                    {\n                      inputs.type === 18 && (\n                        <>\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>模型版本：</Typography.Text>\n                            </div>\n                            <Input\n                              name='other'\n                              placeholder={'请输入星火大模型版本，注意是接口地址中的版本号，例如：v2.1'}\n                              onChange={value => {\n                                  handleInputChange('other', value)\n                              }}\n                              value={inputs.other}\n                              autoComplete='new-password'\n                            />\n                        </>\n                      )\n                    }\n                    {\n                      inputs.type === 21 && (\n                        <>\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>知识库 ID：</Typography.Text>\n                            </div>\n                            <Input\n                              label='知识库 ID'\n                              name='other'\n                              placeholder={'请输入知识库 ID，例如：123456'}\n                              onChange={value => {\n                                  handleInputChange('other', value)\n                              }}\n                              value={inputs.other}\n                              autoComplete='new-password'\n                            />\n                        </>\n                      )\n                    }\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>模型：</Typography.Text>\n                    </div>\n                    <Select\n                      placeholder={'请选择该渠道所支持的模型'}\n                      name='models'\n                      required\n                      multiple\n                      selection\n                      onChange={value => {\n                          handleInputChange('models', value)\n                      }}\n                      value={inputs.models}\n                      autoComplete='new-password'\n                      optionList={modelOptions}\n                    />\n                    <div style={{ lineHeight: '40px', marginBottom: '12px' }}>\n                        <Space>\n                            <Button type='primary' onClick={() => {\n                                handleInputChange('models', basicModels);\n                            }}>填入基础模型</Button>\n                            <Button type='secondary' onClick={() => {\n                                handleInputChange('models', fullModels);\n                            }}>填入所有模型</Button>\n                            <Button type='warning' onClick={() => {\n                                handleInputChange('models', []);\n                            }}>清除所有模型</Button>\n                        </Space>\n                        <Input\n                          addonAfter={\n                              <Button type='primary' onClick={addCustomModel}>填入</Button>\n                          }\n                          placeholder='输入自定义模型名称'\n                          value={customModel}\n                          onChange={(value) => {\n                              setCustomModel(value.trim());\n                          }}\n                        />\n                    </div>\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>模型重定向：</Typography.Text>\n                    </div>\n                    <TextArea\n                      placeholder={`此项可选，用于修改请求体中的模型名称，为一个 JSON 字符串，键为请求中模型名称，值为要替换的模型名称，例如：\\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}\n                      name='model_mapping'\n                      onChange={value => {\n                          handleInputChange('model_mapping', value)\n                      }}\n                      autosize\n                      value={inputs.model_mapping}\n                      autoComplete='new-password'\n                    />\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>系统提示词：</Typography.Text>\n                    </div>\n                    <TextArea\n                      placeholder={`此项可选，用于强制设置给定的系统提示词，请配合自定义模型 & 模型重定向使用，首先创建一个唯一的自定义模型名称并在上面填入，之后将该自定义模型重定向映射到该渠道一个原生支持的模型`}\n                      name='system_prompt'\n                      onChange={value => {\n                          handleInputChange('system_prompt', value)\n                      }}\n                      autosize\n                      value={inputs.system_prompt}\n                      autoComplete='new-password'\n                    />\n                    <Typography.Text style={{\n                        color: 'rgba(var(--semi-blue-5), 1)',\n                        userSelect: 'none',\n                        cursor: 'pointer'\n                    }} onClick={\n                        () => {\n                            handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))\n                        }\n                    }>\n                        填入模板\n                    </Typography.Text>\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>密钥：</Typography.Text>\n                    </div>\n                    {\n                        batch ?\n                          <TextArea\n                            label='密钥'\n                            name='key'\n                            required\n                            placeholder={'请输入密钥，一行一个'}\n                            onChange={value => {\n                                handleInputChange('key', value)\n                            }}\n                            value={inputs.key}\n                            style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}\n                            autoComplete='new-password'\n                          />\n                          :\n                          <Input\n                            label='密钥'\n                            name='key'\n                            required\n                            placeholder={type2secretPrompt(inputs.type)}\n                            onChange={value => {\n                                handleInputChange('key', value)\n                            }}\n                            value={inputs.key}\n                            autoComplete='new-password'\n                          />\n                    }\n                    <div style={{ marginTop: 10 }}>\n                        <Typography.Text strong>组织：</Typography.Text>\n                    </div>\n                    <Input\n                      label='组织，可选，不填则为默认组织'\n                      name='openai_organization'\n                      placeholder='请输入组织org-xxx'\n                      onChange={value => {\n                          handleInputChange('openai_organization', value)\n                      }}\n                      value={inputs.openai_organization}\n                    />\n                    <div style={{ marginTop: 10, display: 'flex' }}>\n                        <Space>\n                            <Checkbox\n                              name='auto_ban'\n                              checked={autoBan}\n                              onChange={\n                                  () => {\n                                      setAutoBan(!autoBan);\n                                  }\n                              }\n                              // onChange={handleInputChange}\n                            />\n                            <Typography.Text\n                              strong>是否自动禁用（仅当自动禁用开启时有效），关闭后不会自动禁用该渠道：</Typography.Text>\n                        </Space>\n                    </div>\n\n                    {\n                      !isEdit && (\n                        <div style={{ marginTop: 10, display: 'flex' }}>\n                            <Space>\n                                <Checkbox\n                                  checked={batch}\n                                  label='批量创建'\n                                  name='batch'\n                                  onChange={() => setBatch(!batch)}\n                                />\n                                <Typography.Text strong>批量创建</Typography.Text>\n                            </Space>\n                        </div>\n                      )\n                    }\n                    {\n                      inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && (\n                        <>\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>代理：</Typography.Text>\n                            </div>\n                            <Input\n                              label='代理'\n                              name='base_url'\n                              placeholder={'此项可选，用于通过代理站来进行 API 调用'}\n                              onChange={value => {\n                                  handleInputChange('base_url', value)\n                              }}\n                              value={inputs.base_url}\n                              autoComplete='new-password'\n                            />\n                        </>\n                      )\n                    }\n                    {\n                      inputs.type === 22 && (\n                        <>\n                            <div style={{ marginTop: 10 }}>\n                                <Typography.Text strong>私有部署地址：</Typography.Text>\n                            </div>\n                            <Input\n                              name='base_url'\n                              placeholder={'请输入私有部署地址，格式为：https://fastgpt.run/api/openapi'}\n                              onChange={value => {\n                                  handleInputChange('base_url', value)\n                              }}\n                              value={inputs.base_url}\n                              autoComplete='new-password'\n                            />\n                        </>\n                      )\n                    }\n\n                </Spin>\n            </SideSheet>\n        </>\n    );\n};\n\nexport default EditChannel;\n"
  },
  {
    "path": "web/air/src/pages/Channel/index.js",
    "content": "import React from 'react';\nimport ChannelsTable from '../../components/ChannelsTable';\nimport {Layout} from \"@douyinfe/semi-ui\";\n\nconst File = () => (\n    <>\n        <Layout>\n            <Layout.Header>\n                <h3>管理渠道</h3>\n            </Layout.Header>\n            <Layout.Content>\n                <ChannelsTable/>\n            </Layout.Content>\n        </Layout>\n    </>\n);\n\nexport default File;\n"
  },
  {
    "path": "web/air/src/pages/Chat/index.js",
    "content": "import React from 'react';\n\nconst Chat = () => {\n  const chatLink = localStorage.getItem('chat_link');\n\n  return (\n    <iframe\n      src={chatLink}\n      style={{ width: '100%', height: '85vh', border: 'none' }}\n    />\n  );\n};\n\n\nexport default Chat;\n"
  },
  {
    "path": "web/air/src/pages/Detail/index.js",
    "content": "import React, {useEffect, useRef, useState} from 'react';\nimport {Button, Col, Form, Layout, Row, Spin} from \"@douyinfe/semi-ui\";\nimport VChart from '@visactor/vchart';\nimport {API, isAdmin, showError, timestamp2string, timestamp2string1} from \"../../helpers\";\nimport {\n    getQuotaWithUnit, modelColorMap,\n    renderNumber,\n    renderQuota,\n    renderQuotaNumberWithDigit,\n    stringToColor\n} from \"../../helpers/render\";\n\nconst Detail = (props) => {\n    const formRef = useRef();\n    let now = new Date();\n    const [inputs, setInputs] = useState({\n        username: '',\n        token_name: '',\n        model_name: '',\n        start_timestamp: localStorage.getItem('data_export_default_time') === 'hour' ? timestamp2string(now.getTime() / 1000 - 86400) : (localStorage.getItem('data_export_default_time') === 'week' ? timestamp2string(now.getTime() / 1000 - 86400 * 30) : timestamp2string(now.getTime() / 1000 - 86400 * 7)),\n        end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),\n        channel: '',\n        data_export_default_time: ''\n    });\n    const {username, model_name, start_timestamp, end_timestamp, channel} = inputs;\n    const isAdminUser = isAdmin();\n    const initialized = useRef(false)\n    const [modelDataChart, setModelDataChart] = useState(null);\n    const [modelDataPieChart, setModelDataPieChart] = useState(null);\n    const [loading, setLoading] = useState(false);\n    const [quotaData, setQuotaData] = useState([]);\n    const [consumeQuota, setConsumeQuota] = useState(0);\n    const [times, setTimes] = useState(0);\n    const [dataExportDefaultTime, setDataExportDefaultTime] = useState(localStorage.getItem('data_export_default_time') || 'hour');\n\n    const handleInputChange = (value, name) => {\n        if (name === 'data_export_default_time') {\n            setDataExportDefaultTime(value);\n            return\n        }\n        setInputs((inputs) => ({...inputs, [name]: value}));\n    };\n\n    const spec_line = {\n        type: 'bar',\n        data: [\n            {\n                id: 'barData',\n                values: []\n            }\n        ],\n        xField: 'Time',\n        yField: 'Usage',\n        seriesField: 'Model',\n        stack: true,\n        legends: {\n            visible: true\n        },\n        title: {\n            visible: true,\n            text: '模型消耗分布',\n            subtext: '0'\n        },\n        bar: {\n            // The state style of bar\n            state: {\n                hover: {\n                    stroke: '#000',\n                    lineWidth: 1\n                }\n            }\n        },\n        tooltip: {\n            mark: {\n                content: [\n                    {\n                        key: datum => datum['Model'],\n                        value: datum => renderQuotaNumberWithDigit(parseFloat(datum['Usage']), 4)\n                    }\n                ]\n            },\n            dimension: {\n                content: [\n                    {\n                        key: datum => datum['Model'],\n                        value: datum => datum['Usage']\n                    }\n                ],\n                updateContent: array => {\n                    // sort by value\n                    array.sort((a, b) => b.value - a.value);\n                    // add $\n                    let sum = 0;\n                    for (let i = 0; i < array.length; i++) {\n                        sum += parseFloat(array[i].value);\n                        array[i].value = renderQuotaNumberWithDigit(parseFloat(array[i].value), 4);\n                    }\n                    // add to first\n                    array.unshift({\n                        key: '总计',\n                        value: renderQuotaNumberWithDigit(sum, 4)\n                    });\n                    return array;\n                }\n            }\n        },\n        color: {\n            specified: modelColorMap\n        }\n    };\n\n    const spec_pie = {\n        type: 'pie',\n        data: [\n            {\n                id: 'id0',\n                values: [\n                    {type: 'null', value: '0'},\n                ]\n            }\n        ],\n        outerRadius: 0.8,\n        innerRadius: 0.5,\n        padAngle: 0.6,\n        valueField: 'value',\n        categoryField: 'type',\n        pie: {\n            style: {\n                cornerRadius: 10\n            },\n            state: {\n                hover: {\n                    outerRadius: 0.85,\n                    stroke: '#000',\n                    lineWidth: 1\n                },\n                selected: {\n                    outerRadius: 0.85,\n                    stroke: '#000',\n                    lineWidth: 1\n                }\n            }\n        },\n        title: {\n            visible: true,\n            text: '模型调用次数占比'\n        },\n        legends: {\n            visible: true,\n            orient: 'left'\n        },\n        label: {\n            visible: true\n        },\n        tooltip: {\n            mark: {\n                content: [\n                    {\n                        key: datum => datum['type'],\n                        value: datum => renderNumber(datum['value'])\n                    }\n                ]\n            }\n        },\n        color: {\n            specified: modelColorMap\n        }\n    };\n\n    const loadQuotaData = async (lineChart, pieChart) => {\n        setLoading(true);\n\n        let url = '';\n        let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n        let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n        if (isAdminUser) {\n            url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;\n        } else {\n            url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;\n        }\n        const res = await API.get(url);\n        const {success, message, data} = res.data;\n        if (success) {\n            setQuotaData(data);\n            if (data.length === 0) {\n                data.push({\n                    'count': 0,\n                    'model_name': '无数据',\n                    'quota': 0,\n                    'created_at': now.getTime() / 1000\n                })\n            }\n            // 根据dataExportDefaultTime重制时间粒度\n            let timeGranularity = 3600;\n            if (dataExportDefaultTime === 'day') {\n                timeGranularity = 86400;\n            } else if (dataExportDefaultTime === 'week') {\n                timeGranularity = 604800;\n            }\n            data.forEach(item => {\n                item['created_at'] = Math.floor(item['created_at'] / timeGranularity) * timeGranularity;\n            });\n            updateChart(lineChart, pieChart, data);\n        } else {\n            showError(message);\n        }\n        setLoading(false);\n    };\n\n    const refresh = async () => {\n        await loadQuotaData(modelDataChart, modelDataPieChart);\n    };\n\n    const initChart = async () => {\n        let lineChart = modelDataChart\n        if (!modelDataChart) {\n            lineChart = new VChart(spec_line, {dom: 'model_data'});\n            setModelDataChart(lineChart);\n            lineChart.renderAsync();\n        }\n        let pieChart = modelDataPieChart\n        if (!modelDataPieChart) {\n            pieChart = new VChart(spec_pie, {dom: 'model_pie'});\n            setModelDataPieChart(pieChart);\n            pieChart.renderAsync();\n        }\n        console.log('init vchart');\n        await loadQuotaData(lineChart, pieChart)\n    }\n\n    const updateChart = (lineChart, pieChart, data) => {\n        if (isAdminUser) {\n            // 将所有用户合并\n        }\n        let pieData = [];\n        let lineData = [];\n        let consumeQuota = 0;\n        let times = 0;\n        for (let i = 0; i < data.length; i++) {\n            const item = data[i];\n            consumeQuota += item.quota;\n            times += item.count;\n            // 合并model_name\n            let pieItem = pieData.find(it => it.type === item.model_name);\n            if (pieItem) {\n                pieItem.value += item.count;\n            } else {\n                pieData.push({\n                    \"type\": item.model_name,\n                    \"value\": item.count\n                });\n            }\n            // 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳\n            // 转换日期格式\n            let createTime = timestamp2string1(item.created_at, dataExportDefaultTime);\n            let lineItem = lineData.find(it => it.Time === createTime && it.Model === item.model_name);\n            if (lineItem) {\n                lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));\n            } else {\n                lineData.push({\n                    \"Time\": createTime,\n                    \"Model\": item.model_name,\n                    \"Usage\": parseFloat(getQuotaWithUnit(item.quota))\n                });\n            }\n        }\n        setConsumeQuota(consumeQuota);\n        setTimes(times);\n\n        // sort by count\n        pieData.sort((a, b) => b.value - a.value);\n        spec_pie.title.subtext = `总计：${renderNumber(times)}`;\n        spec_pie.data[0].values = pieData;\n\n        spec_line.title.subtext = `总计：${renderQuota(consumeQuota, 2)}`;\n        spec_line.data[0].values = lineData;\n        pieChart.updateSpec(spec_pie);\n        lineChart.updateSpec(spec_line);\n\n        // pieChart.updateData('id0', pieData);\n        // lineChart.updateData('barData', lineData);\n        pieChart.reLayout();\n        lineChart.reLayout();\n    }\n\n    useEffect(() => {\n        // setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));\n        // if (dataExportDefaultTime === 'day') {\n        //     // 设置开始时间为7天前\n        //     let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)\n        //     inputs.start_timestamp = st;\n        //     formRef.current.formApi.setValue('start_timestamp', st);\n        // }\n        if (!initialized.current) {\n            initialized.current = true;\n            initChart();\n        }\n    }, []);\n\n    return (\n        <>\n            <Layout>\n                <Layout.Header>\n                    <h3>数据看板</h3>\n                </Layout.Header>\n                <Layout.Content>\n                    <Form ref={formRef} layout='horizontal' style={{marginTop: 10}}>\n                        <>\n                            <Form.DatePicker field=\"start_timestamp\" label='起始时间' style={{width: 272}}\n                                             initValue={start_timestamp}\n                                             value={start_timestamp} type='dateTime'\n                                             name='start_timestamp'\n                                             onChange={value => handleInputChange(value, 'start_timestamp')}/>\n                            <Form.DatePicker field=\"end_timestamp\" fluid label='结束时间' style={{width: 272}}\n                                             initValue={end_timestamp}\n                                             value={end_timestamp} type='dateTime'\n                                             name='end_timestamp'\n                                             onChange={value => handleInputChange(value, 'end_timestamp')}/>\n                            <Form.Select field=\"data_export_default_time\" label='时间粒度' style={{width: 176}}\n                                         initValue={dataExportDefaultTime}\n                                         placeholder={'时间粒度'} name='data_export_default_time'\n                                         optionList={\n                                             [\n                                                 {label: '小时', value: 'hour'},\n                                                 {label: '天', value: 'day'},\n                                                 {label: '周', value: 'week'}\n                                             ]\n                                         }\n                                         onChange={value => handleInputChange(value, 'data_export_default_time')}>\n                            </Form.Select>\n                            {\n                                isAdminUser && <>\n                                    <Form.Input field=\"username\" label='用户名称' style={{width: 176}} value={username}\n                                                placeholder={'可选值'} name='username'\n                                                onChange={value => handleInputChange(value, 'username')}/>\n                                </>\n                            }\n                            <Form.Section>\n                                <Button label='查询' type=\"primary\" htmlType=\"submit\" className=\"btn-margin-right\"\n                                        onClick={refresh} loading={loading}>查询</Button>\n                            </Form.Section>\n                        </>\n                    </Form>\n                    <Spin spinning={loading}>\n                        <div style={{height: 500}}>\n                            <div id=\"model_pie\" style={{width: '100%', minWidth: 100}}></div>\n                        </div>\n                        <div style={{height: 500}}>\n                            <div id=\"model_data\" style={{width: '100%', minWidth: 100}}></div>\n                        </div>\n                    </Spin>\n                </Layout.Content>\n            </Layout>\n        </>\n    );\n};\n\n\nexport default Detail;\n"
  },
  {
    "path": "web/air/src/pages/Home/index.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { Card, Col, Row } from '@douyinfe/semi-ui';\nimport { API, showError, showNotice, timestamp2string } from '../../helpers';\nimport { StatusContext } from '../../context/Status';\nimport { marked } from 'marked';\n\nconst Home = () => {\n  const [statusState] = useContext(StatusContext);\n  const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);\n  const [homePageContent, setHomePageContent] = useState('');\n\n  const displayNotice = async () => {\n    const res = await API.get('/api/notice');\n    const { success, message, data } = res.data;\n    if (success) {\n      let oldNotice = localStorage.getItem('notice');\n      if (data !== oldNotice && data !== '') {\n        const htmlNotice = marked(data);\n        showNotice(htmlNotice, true);\n        localStorage.setItem('notice', data);\n      }\n    } else {\n      showError(message);\n    }\n  };\n\n  const displayHomePageContent = async () => {\n    setHomePageContent(localStorage.getItem('home_page_content') || '');\n    const res = await API.get('/api/home_page_content');\n    const { success, message, data } = res.data;\n    if (success) {\n      let content = data;\n      if (!data.startsWith('https://')) {\n        content = marked.parse(data);\n      }\n      setHomePageContent(content);\n      localStorage.setItem('home_page_content', content);\n    } else {\n      showError(message);\n      setHomePageContent('加载首页内容失败...');\n    }\n    setHomePageContentLoaded(true);\n  };\n\n  const getStartTimeString = () => {\n    const timestamp = statusState?.status?.start_time;\n    return statusState.status ? timestamp2string(timestamp) : '';\n  };\n\n  useEffect(() => {\n    displayNotice().then();\n    displayHomePageContent().then();\n  }, []);\n  return (\n    <>\n      {\n        homePageContentLoaded && homePageContent === '' ?\n          <>\n            <Card\n              bordered={false}\n              headerLine={false}\n              title='系统状况'\n              bodyStyle={{ padding: '10px 20px' }}\n            >\n              <Row gutter={16}>\n                <Col span={12}>\n                  <Card\n                    title='系统信息'\n                    headerExtraContent={<span\n                      style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统信息总览</span>}>\n                    <p>名称：{statusState?.status?.system_name}</p>\n                    <p>版本：{statusState?.status?.version ? statusState?.status?.version : 'unknown'}</p>\n                    <p>\n                      源码：\n                      <a\n                        href='https://github.com/songquanpeng/one-api'\n                        target='_blank' rel='noreferrer'\n                      >\n                        https://github.com/songquanpeng/one-api\n                      </a>\n                    </p>\n                    <p>启动时间：{getStartTimeString()}</p>\n                  </Card>\n                </Col>\n                <Col span={12}>\n                  <Card\n                    title='系统配置'\n                    headerExtraContent={<span\n                      style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统配置总览</span>}>\n                    <p>\n                      邮箱验证：\n                      {statusState?.status?.email_verification === true ? '已启用' : '未启用'}\n                    </p>\n                    <p>\n                      GitHub 身份验证：\n                      {statusState?.status?.github_oauth === true ? '已启用' : '未启用'}\n                    </p>\n                    <p>\n                      微信身份验证：\n                      {statusState?.status?.wechat_login === true ? '已启用' : '未启用'}\n                    </p>\n                    <p>\n                      Turnstile 用户校验：\n                      {statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}\n                    </p>\n                    {/*<p>*/}\n                    {/*  Telegram 身份验证：*/}\n                    {/*  {statusState?.status?.telegram_oauth === true*/}\n                    {/*    ? '已启用' : '未启用'}*/}\n                    {/*</p>*/}\n                  </Card>\n                </Col>\n              </Row>\n            </Card>\n\n          </>\n          : <>\n            {\n              homePageContent.startsWith('https://') ?\n                <iframe src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} /> :\n                <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>\n            }\n          </>\n      }\n\n    </>\n  );\n};\n\nexport default Home;"
  },
  {
    "path": "web/air/src/pages/Log/index.js",
    "content": "import React from 'react';\nimport LogsTable from '../../components/LogsTable';\n\nconst Token = () => (\n  <>\n    <LogsTable />\n  </>\n);\n\nexport default Token;\n"
  },
  {
    "path": "web/air/src/pages/Midjourney/index.js",
    "content": "import React from 'react';\nimport MjLogsTable from '../../components/MjLogsTable';\n\nconst Midjourney = () => (\n  <>\n    <MjLogsTable />\n  </>\n);\n\nexport default Midjourney;\n"
  },
  {
    "path": "web/air/src/pages/NotFound/index.js",
    "content": "import React from 'react';\nimport { Message } from 'semantic-ui-react';\n\nconst NotFound = () => (\n  <>\n    <Message negative>\n      <Message.Header>页面不存在</Message.Header>\n      <p>请检查你的浏览器地址是否正确</p>\n    </Message>\n  </>\n);\n\nexport default NotFound;\n"
  },
  {
    "path": "web/air/src/pages/Redemption/EditRedemption.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { API, downloadTextAsFile, isMobile, showError, showSuccess } from '../../helpers';\nimport { renderQuotaWithPrompt } from '../../helpers/render';\nimport { AutoComplete, Button, Input, Modal, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';\nimport Title from '@douyinfe/semi-ui/lib/es/typography/title';\nimport { Divider } from 'semantic-ui-react';\n\nconst EditRedemption = (props) => {\n  const isEdit = props.editingRedemption.id !== undefined;\n  const [loading, setLoading] = useState(isEdit);\n\n  const params = useParams();\n  const navigate = useNavigate();\n  const originInputs = {\n    name: '',\n    quota: 100000,\n    count: 1\n  };\n  const [inputs, setInputs] = useState(originInputs);\n  const { name, quota, count } = inputs;\n\n  const handleCancel = () => {\n    props.handleClose();\n  };\n\n  const handleInputChange = (name, value) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const loadRedemption = async () => {\n    setLoading(true);\n    let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  useEffect(() => {\n    if (isEdit) {\n      loadRedemption().then(\n        () => {\n          // console.log(inputs);\n        }\n      );\n    } else {\n      setInputs(originInputs);\n    }\n  }, [props.editingRedemption.id]);\n\n  const submit = async () => {\n    if (!isEdit && inputs.name === '') return;\n    setLoading(true);\n    let localInputs = inputs;\n    localInputs.count = parseInt(localInputs.count);\n    localInputs.quota = parseInt(localInputs.quota);\n    let res;\n    if (isEdit) {\n      res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(props.editingRedemption.id) });\n    } else {\n      res = await API.post(`/api/redemption/`, {\n        ...localInputs\n      });\n    }\n    const { success, message, data } = res.data;\n    if (success) {\n      if (isEdit) {\n        showSuccess('兑换码更新成功！');\n        props.refresh();\n        props.handleClose();\n      } else {\n        showSuccess('兑换码创建成功！');\n        setInputs(originInputs);\n        props.refresh();\n        props.handleClose();\n      }\n    } else {\n      showError(message);\n    }\n    if (!isEdit && data) {\n      let text = '';\n      for (let i = 0; i < data.length; i++) {\n        text += data[i] + '\\n';\n      }\n      // downloadTextAsFile(text, `${inputs.name}.txt`);\n      Modal.confirm({\n        title: '兑换码创建成功',\n        content: (\n          <div>\n            <p>兑换码创建成功，是否下载兑换码？</p>\n            <p>兑换码将以文本文件的形式下载，文件名为兑换码的名称。</p>\n          </div>\n        ),\n        onOk: () => {\n          downloadTextAsFile(text, `${inputs.name}.txt`);\n        }\n      });\n    }\n    setLoading(false);\n  };\n\n  return (\n    <>\n      <SideSheet\n        placement={isEdit ? 'right' : 'left'}\n        title={<Title level={3}>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Title>}\n        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        visible={props.visiable}\n        footer={\n          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>\n            <Space>\n              <Button theme=\"solid\" size={'large'} onClick={submit}>提交</Button>\n              <Button theme=\"solid\" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>\n            </Space>\n          </div>\n        }\n        closeIcon={null}\n        onCancel={() => handleCancel()}\n        width={isMobile() ? '100%' : 600}\n      >\n        <Spin spinning={loading}>\n          <Input\n            style={{ marginTop: 20 }}\n            label=\"名称\"\n            name=\"name\"\n            placeholder={'请输入名称'}\n            onChange={value => handleInputChange('name', value)}\n            value={name}\n            autoComplete=\"new-password\"\n            required={!isEdit}\n          />\n          <Divider />\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>{`额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>\n          </div>\n          <AutoComplete\n            style={{ marginTop: 8 }}\n            name=\"quota\"\n            placeholder={'请输入额度'}\n            onChange={(value) => handleInputChange('quota', value)}\n            value={quota}\n            autoComplete=\"new-password\"\n            type=\"number\"\n            position={'bottom'}\n            data={[\n              { value: 500000, label: '1$' },\n              { value: 5000000, label: '10$' },\n              { value: 25000000, label: '50$' },\n              { value: 50000000, label: '100$' },\n              { value: 250000000, label: '500$' },\n              { value: 500000000, label: '1000$' }\n            ]}\n          />\n          {\n            !isEdit && <>\n              <Divider />\n              <Typography.Text>生成数量</Typography.Text>\n              <Input\n                style={{ marginTop: 8 }}\n                label=\"生成数量\"\n                name=\"count\"\n                placeholder={'请输入生成数量'}\n                onChange={value => handleInputChange('count', value)}\n                value={count}\n                autoComplete=\"new-password\"\n                type=\"number\"\n              />\n            </>\n          }\n        </Spin>\n      </SideSheet>\n    </>\n  );\n};\n\nexport default EditRedemption;\n"
  },
  {
    "path": "web/air/src/pages/Redemption/index.js",
    "content": "import React from 'react';\nimport RedemptionsTable from '../../components/RedemptionsTable';\nimport {Layout} from \"@douyinfe/semi-ui\";\n\nconst Redemption = () => (\n  <>\n      <Layout>\n          <Layout.Header>\n              <h3>管理兑换码</h3>\n          </Layout.Header>\n          <Layout.Content>\n              <RedemptionsTable/>\n          </Layout.Content>\n      </Layout>\n  </>\n);\n\nexport default Redemption;\n"
  },
  {
    "path": "web/air/src/pages/Setting/index.js",
    "content": "import React from 'react';\nimport SystemSetting from '../../components/SystemSetting';\nimport {isRoot} from '../../helpers';\nimport OtherSetting from '../../components/OtherSetting';\nimport PersonalSetting from '../../components/PersonalSetting';\nimport OperationSetting from '../../components/OperationSetting';\nimport {Layout, TabPane, Tabs} from \"@douyinfe/semi-ui\";\n\nconst Setting = () => {\n    let panes = [\n        {\n            tab: '个人设置',\n            content: <PersonalSetting/>,\n            itemKey: '1'\n        }\n    ];\n\n    if (isRoot()) {\n        panes.push({\n            tab: '运营设置',\n            content: <OperationSetting/>,\n            itemKey: '2'\n        });\n        panes.push({\n            tab: '系统设置',\n            content: <SystemSetting/>,\n            itemKey: '3'\n        });\n        panes.push({\n            tab: '其他设置',\n            content: <OtherSetting/>,\n            itemKey: '4'\n        });\n    }\n\n    return (\n        <div>\n            <Layout>\n                <Layout.Content>\n                    <Tabs type=\"line\" defaultActiveKey=\"1\">\n                        {panes.map(pane => (\n                            <TabPane itemKey={pane.itemKey} tab={pane.tab}>\n                                {pane.content}\n                            </TabPane>\n                        ))}\n                    </Tabs>\n                </Layout.Content>\n            </Layout>\n        </div>\n    );\n};\n\nexport default Setting;\n"
  },
  {
    "path": "web/air/src/pages/Token/EditToken.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { API, isMobile, showError, showSuccess, timestamp2string } from '../../helpers';\nimport { renderQuotaWithPrompt } from '../../helpers/render';\nimport {\n    AutoComplete,\n    Banner,\n    Button,\n    Checkbox,\n    DatePicker,\n    Input,\n    Select,\n    SideSheet,\n    Space,\n    Spin,\n    Typography\n} from '@douyinfe/semi-ui';\nimport Title from '@douyinfe/semi-ui/lib/es/typography/title';\nimport { Divider } from 'semantic-ui-react';\n\nconst EditToken = (props) => {\n  const [isEdit, setIsEdit] = useState(false);\n  const [loading, setLoading] = useState(isEdit);\n  const originInputs = {\n    name: '',\n    remain_quota: isEdit ? 0 : 500000,\n    expired_time: -1,\n    unlimited_quota: false,\n    model_limits_enabled: false,\n    model_limits: []\n  };\n  const [inputs, setInputs] = useState(originInputs);\n  const { name, remain_quota, expired_time, unlimited_quota, model_limits_enabled, model_limits } = inputs;\n  // const [visible, setVisible] = useState(false);\n  const [models, setModels] = useState({});\n  const navigate = useNavigate();\n  const handleInputChange = (name, value) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n  const handleCancel = () => {\n    props.handleClose();\n  };\n  const setExpiredTime = (month, day, hour, minute) => {\n    let now = new Date();\n    let timestamp = now.getTime() / 1000;\n    let seconds = month * 30 * 24 * 60 * 60;\n    seconds += day * 24 * 60 * 60;\n    seconds += hour * 60 * 60;\n    seconds += minute * 60;\n    if (seconds !== 0) {\n      timestamp += seconds;\n      setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });\n    } else {\n      setInputs({ ...inputs, expired_time: -1 });\n    }\n  };\n\n  const setUnlimitedQuota = () => {\n    setInputs({ ...inputs, unlimited_quota: !unlimited_quota });\n  };\n\n  // const loadModels = async () => {\n  //   let res = await API.get(`/api/user/models`);\n  //   const { success, message, data } = res.data;\n  //   if (success) {\n  //     let localModelOptions = data.map((model) => ({\n  //       label: model,\n  //       value: model\n  //     }));\n  //     setModels(localModelOptions);\n  //   } else {\n  //     showError(message);\n  //   }\n  // };\n\n  const loadToken = async () => {\n    setLoading(true);\n    let res = await API.get(`/api/token/${props.editingToken.id}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (data.expired_time !== -1) {\n        data.expired_time = timestamp2string(data.expired_time);\n      }\n      // if (data.model_limits !== '') {\n      //   data.model_limits = data.model_limits.split(',');\n      // } else {\n      //   data.model_limits = [];\n      // }\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n  useEffect(() => {\n    setIsEdit(props.editingToken.id !== undefined);\n  }, [props.editingToken.id]);\n\n  useEffect(() => {\n    if (!isEdit) {\n      setInputs(originInputs);\n    } else {\n      loadToken().then(\n        () => {\n          // console.log(inputs);\n        }\n      );\n    }\n    // loadModels();\n  }, [isEdit]);\n\n  // 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量，默认为 1\n  const [tokenCount, setTokenCount] = useState(1);\n\n  // 新增处理 tokenCount 变化的函数\n  const handleTokenCountChange = (value) => {\n    // 确保用户输入的是正整数\n    const count = parseInt(value, 10);\n    if (!isNaN(count) && count > 0) {\n      setTokenCount(count);\n    }\n  };\n\n  // 生成一个随机的四位字母数字字符串\n  const generateRandomSuffix = () => {\n    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n    let result = '';\n    for (let i = 0; i < 6; i++) {\n      result += characters.charAt(Math.floor(Math.random() * characters.length));\n    }\n    return result;\n  };\n\n  const submit = async () => {\n    setLoading(true);\n    if (isEdit) {\n      // 编辑令牌的逻辑保持不变\n      let localInputs = { ...inputs };\n      localInputs.remain_quota = parseInt(localInputs.remain_quota);\n      if (localInputs.expired_time !== -1) {\n        let time = Date.parse(localInputs.expired_time);\n        if (isNaN(time)) {\n          showError('过期时间格式错误！');\n          setLoading(false);\n          return;\n        }\n        localInputs.expired_time = Math.ceil(time / 1000);\n      }\n      // localInputs.model_limits = localInputs.model_limits.join(',');\n      let res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(props.editingToken.id) });\n      const { success, message } = res.data;\n      if (success) {\n        showSuccess('令牌更新成功！');\n        props.refresh();\n        props.handleClose();\n      } else {\n        showError(message);\n      }\n    } else {\n      // 处理新增多个令牌的情况\n      let successCount = 0; // 记录成功创建的令牌数量\n      for (let i = 0; i < tokenCount; i++) {\n        let localInputs = { ...inputs };\n        if (i !== 0) {\n          // 如果用户想要创建多个令牌，则给每个令牌一个序号后缀\n          localInputs.name = `${inputs.name}-${generateRandomSuffix()}`;\n        }\n        localInputs.remain_quota = parseInt(localInputs.remain_quota);\n\n        if (localInputs.expired_time !== -1) {\n          let time = Date.parse(localInputs.expired_time);\n          if (isNaN(time)) {\n            showError('过期时间格式错误！');\n            setLoading(false);\n            break;\n          }\n          localInputs.expired_time = Math.ceil(time / 1000);\n        }\n        // localInputs.model_limits = localInputs.model_limits.join(',');\n        let res = await API.post(`/api/token/`, localInputs);\n        const { success, message } = res.data;\n\n        if (success) {\n          successCount++;\n        } else {\n          showError(message);\n          break; // 如果创建失败，终止循环\n        }\n      }\n\n      if (successCount > 0) {\n        showSuccess(`${successCount}个令牌创建成功，请在列表页面点击复制获取令牌！`);\n        props.refresh();\n        props.handleClose();\n      }\n    }\n    setLoading(false);\n    setInputs(originInputs); // 重置表单\n    setTokenCount(1); // 重置数量为默认值\n  };\n\n\n  return (\n    <>\n      <SideSheet\n        placement={isEdit ? 'right' : 'left'}\n        title={<Title level={3}>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Title>}\n        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        visible={props.visiable}\n        footer={\n          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>\n            <Space>\n              <Button theme=\"solid\" size={'large'} onClick={submit}>提交</Button>\n              <Button theme=\"solid\" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>\n            </Space>\n          </div>\n        }\n        closeIcon={null}\n        onCancel={() => handleCancel()}\n        width={isMobile() ? '100%' : 600}\n      >\n        <Spin spinning={loading}>\n          <Input\n            style={{ marginTop: 20 }}\n            label=\"名称\"\n            name=\"name\"\n            placeholder={'请输入名称'}\n            onChange={(value) => handleInputChange('name', value)}\n            value={name}\n            autoComplete=\"new-password\"\n            required={!isEdit}\n          />\n          <Divider />\n          <DatePicker\n            label=\"过期时间\"\n            name=\"expired_time\"\n            placeholder={'请选择过期时间'}\n            onChange={(value) => handleInputChange('expired_time', value)}\n            value={expired_time}\n            autoComplete=\"new-password\"\n            type=\"dateTime\"\n          />\n          <div style={{ marginTop: 20 }}>\n            <Space>\n              <Button type={'tertiary'} onClick={() => {\n                setExpiredTime(0, 0, 0, 0);\n              }}>永不过期</Button>\n              <Button type={'tertiary'} onClick={() => {\n                setExpiredTime(0, 0, 1, 0);\n              }}>一小时</Button>\n              <Button type={'tertiary'} onClick={() => {\n                setExpiredTime(1, 0, 0, 0);\n              }}>一个月</Button>\n              <Button type={'tertiary'} onClick={() => {\n                setExpiredTime(0, 1, 0, 0);\n              }}>一天</Button>\n            </Space>\n          </div>\n\n          <Divider />\n          <Banner type={'warning'}\n                  description={'注意，令牌的额度仅用于限制令牌本身的最大额度使用量，实际的使用受到账户的剩余额度限制。'}></Banner>\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>\n          </div>\n          <AutoComplete\n            style={{ marginTop: 8 }}\n            name=\"remain_quota\"\n            placeholder={'请输入额度'}\n            onChange={(value) => handleInputChange('remain_quota', value)}\n            value={remain_quota}\n            autoComplete=\"new-password\"\n            type=\"number\"\n            // position={'top'}\n            data={[\n              { value: 500000, label: '1$' },\n              { value: 5000000, label: '10$' },\n              { value: 25000000, label: '50$' },\n              { value: 50000000, label: '100$' },\n              { value: 250000000, label: '500$' },\n              { value: 500000000, label: '1000$' }\n            ]}\n            disabled={unlimited_quota}\n          />\n\n          {!isEdit && (\n            <>\n              <div style={{ marginTop: 20 }}>\n                <Typography.Text>新建数量</Typography.Text>\n              </div>\n              <AutoComplete\n                style={{ marginTop: 8 }}\n                label=\"数量\"\n                placeholder={'请选择或输入创建令牌的数量'}\n                onChange={(value) => handleTokenCountChange(value)}\n                onSelect={(value) => handleTokenCountChange(value)}\n                value={tokenCount.toString()}\n                autoComplete=\"off\"\n                type=\"number\"\n                data={[\n                  { value: 10, label: '10个' },\n                  { value: 20, label: '20个' },\n                  { value: 30, label: '30个' },\n                  { value: 100, label: '100个' }\n                ]}\n                disabled={unlimited_quota}\n              />\n            </>\n          )}\n\n          <div>\n            <Button style={{ marginTop: 8 }} type={'warning'} onClick={() => {\n              setUnlimitedQuota();\n            }}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>\n          </div>\n          {/* <Divider />\n          <div style={{ marginTop: 10, display: 'flex' }}>\n            <Space>\n              <Checkbox\n                name=\"model_limits_enabled\"\n                checked={model_limits_enabled}\n                onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}\n              >\n              </Checkbox>\n              <Typography.Text>启用模型限制（非必要，不建议启用）</Typography.Text>\n            </Space>\n          </div>\n\n          <Select\n            style={{ marginTop: 8 }}\n            placeholder={'请选择该渠道所支持的模型'}\n            name=\"models\"\n            required\n            multiple\n            selection\n            onChange={value => {\n              handleInputChange('model_limits', value);\n            }}\n            value={inputs.model_limits}\n            autoComplete=\"new-password\"\n            optionList={models}\n            disabled={!model_limits_enabled}\n          /> */}\n        </Spin>\n      </SideSheet>\n    </>\n  );\n};\n\nexport default EditToken;\n"
  },
  {
    "path": "web/air/src/pages/Token/index.js",
    "content": "import React from 'react';\nimport TokensTable from '../../components/TokensTable';\nimport {Layout} from \"@douyinfe/semi-ui\";\nconst Token = () => (\n  <>\n    <Layout>\n      <Layout.Header>\n          <h3>我的令牌</h3>\n      </Layout.Header>\n      <Layout.Content>\n          <TokensTable/>\n      </Layout.Content>\n    </Layout>\n  </>\n);\n\nexport default Token;\n"
  },
  {
    "path": "web/air/src/pages/TopUp/index.js",
    "content": "import React, {useEffect, useState} from 'react';\nimport {API, isMobile, showError, showInfo, showSuccess} from '../../helpers';\nimport {renderNumber, renderQuota} from '../../helpers/render';\nimport {Col, Layout, Row, Typography, Card, Button, Form, Divider, Space, Modal} from \"@douyinfe/semi-ui\";\nimport Title from \"@douyinfe/semi-ui/lib/es/typography/title\";\nimport Text from '@douyinfe/semi-ui/lib/es/typography/text';\nimport { Link } from 'react-router-dom';\n\nconst TopUp = () => {\n    const [redemptionCode, setRedemptionCode] = useState('');\n    const [topUpCode, setTopUpCode] = useState('');\n    const [topUpCount, setTopUpCount] = useState(10);\n    const [minTopupCount, setMinTopUpCount] = useState(1);\n    const [amount, setAmount] = useState(0.0);\n    const [minTopUp, setMinTopUp] = useState(1);\n    const [topUpLink, setTopUpLink] = useState('');\n    const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false);\n    const [userQuota, setUserQuota] = useState(0);\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [open, setOpen] = useState(false);\n    const [payWay, setPayWay] = useState('');\n\n    const topUp = async () => {\n        if (redemptionCode === '') {\n            showInfo('请输入兑换码！')\n            return;\n        }\n        setIsSubmitting(true);\n        try {\n            const res = await API.post('/api/user/topup', {\n                key: redemptionCode\n            });\n            const {success, message, data} = res.data;\n            if (success) {\n                showSuccess('兑换成功！');\n                Modal.success({title: '兑换成功！', content: '成功兑换额度：' + renderQuota(data), centered: true});\n                setUserQuota((quota) => {\n                    return quota + data;\n                });\n                setRedemptionCode('');\n            } else {\n                showError(message);\n            }\n        } catch (err) {\n            showError('请求失败');\n        } finally {\n            setIsSubmitting(false);\n        }\n    };\n\n    const openTopUpLink = () => {\n        if (!topUpLink) {\n            showError('超级管理员未设置充值链接！');\n            return;\n        }\n        window.open(topUpLink, '_blank');\n    };\n\n    const preTopUp = async (payment) => {\n        if (!enableOnlineTopUp) {\n            showError('管理员未开启在线充值！');\n            return;\n        }\n        if (amount === 0) {\n            await getAmount();\n        }\n        if (topUpCount < minTopUp) {\n            showInfo('充值数量不能小于' + minTopUp);\n            return;\n        }\n        setPayWay(payment)\n        setOpen(true);\n    }\n\n    const onlineTopUp = async () => {\n        if (amount === 0) {\n            await getAmount();\n        }\n        if (topUpCount < minTopUp) {\n            showInfo('充值数量不能小于' + minTopUp);\n            return;\n        }\n        setOpen(false);\n        try {\n            const res = await API.post('/api/user/pay', {\n                amount: parseInt(topUpCount),\n                top_up_code: topUpCode,\n                payment_method: payWay\n            });\n            if (res !== undefined) {\n                const {message, data} = res.data;\n                // showInfo(message);\n                if (message === 'success') {\n\n                    let params = data\n                    let url = res.data.url\n                    let form = document.createElement('form')\n                    form.action = url\n                    form.method = 'POST'\n                    // 判断是否为safari浏览器\n                    let isSafari = navigator.userAgent.indexOf(\"Safari\") > -1 && navigator.userAgent.indexOf(\"Chrome\") < 1;\n                    if (!isSafari) {\n                        form.target = '_blank'\n                    }\n                    for (let key in params) {\n                        let input = document.createElement('input')\n                        input.type = 'hidden'\n                        input.name = key\n                        input.value = params[key]\n                        form.appendChild(input)\n                    }\n                    document.body.appendChild(form)\n                    form.submit()\n                    document.body.removeChild(form)\n                } else {\n                    showError(data);\n                    // setTopUpCount(parseInt(res.data.count));\n                    // setAmount(parseInt(data));\n                }\n            } else {\n                showError(res);\n            }\n        } catch (err) {\n            console.log(err);\n        } finally {\n        }\n    }\n\n    const getUserQuota = async () => {\n        let res = await API.get(`/api/user/self`);\n        const {success, message, data} = res.data;\n        if (success) {\n            setUserQuota(data.quota);\n        } else {\n            showError(message);\n        }\n    }\n\n    useEffect(() => {\n        let status = localStorage.getItem('status');\n        if (status) {\n            status = JSON.parse(status);\n            if (status.top_up_link) {\n                setTopUpLink(status.top_up_link);\n            }\n            if (status.min_topup) {\n                setMinTopUp(status.min_topup);\n            }\n            if (status.enable_online_topup) {\n                setEnableOnlineTopUp(status.enable_online_topup);\n            }\n        }\n        getUserQuota().then();\n    }, []);\n\n    const renderAmount = () => {\n        // console.log(amount);\n        return amount + '元';\n    }\n\n    const getAmount = async (value) => {\n        if (value === undefined) {\n            value = topUpCount;\n        }\n        try {\n            const res = await API.post('/api/user/amount', {\n                amount: parseFloat(value),\n                top_up_code: topUpCode\n            });\n            if (res !== undefined) {\n                const {message, data} = res.data;\n                // showInfo(message);\n                if (message === 'success') {\n                    setAmount(parseFloat(data));\n                } else {\n                    showError(data);\n                    // setTopUpCount(parseInt(res.data.count));\n                    // setAmount(parseInt(data));\n                }\n            } else {\n                showError(res);\n            }\n        } catch (err) {\n            console.log(err);\n        } finally {\n        }\n    }\n\n    const handleCancel = () => {\n        setOpen(false);\n    }\n\n    return (\n        <div>\n            <Layout>\n                <Layout.Header>\n                    <h3>充值额度</h3>\n                </Layout.Header>\n                <Layout.Content>\n                    <Modal\n                        title=\"确定要充值吗\"\n                        visible={open}\n                        onOk={onlineTopUp}\n                        onCancel={handleCancel}\n                        maskClosable={false}\n                        size={'small'}\n                        centered={true}\n                    >\n                        <p>充值数量：{topUpCount}$</p>\n                        <p>实付金额：{renderAmount()}</p>\n                        <p>是否确认充值？</p>\n                    </Modal>\n                    <div style={{marginTop: 20, display: 'flex', justifyContent: 'center'}}>\n                        <Card\n                            style={{width: '500px', padding: '20px'}}\n                        >\n                            <Title level={3} style={{textAlign: 'center'}}>余额 {renderQuota(userQuota)}</Title>\n                            <div style={{marginTop: 20}}>\n                                <Divider>\n                                    兑换余额\n                                </Divider>\n                                <Form>\n                                    <Form.Input\n                                        field={'redemptionCode'}\n                                        label={'兑换码'}\n                                        placeholder='兑换码'\n                                        name='redemptionCode'\n                                        value={redemptionCode}\n                                        onChange={(value) => {\n                                            setRedemptionCode(value);\n                                        }}\n                                    />\n                                    <Space>\n                                        {\n                                            topUpLink ?\n                                                <Button type={'primary'} theme={'solid'} onClick={openTopUpLink}>\n                                                    获取兑换码\n                                                </Button> : null\n                                        }\n                                        <Button type={\"warning\"} theme={'solid'} onClick={topUp}\n                                                disabled={isSubmitting}>\n                                            {isSubmitting ? '兑换中...' : '兑换'}\n                                        </Button>\n                                    </Space>\n                                </Form>\n                            </div>\n                            {/* <div style={{marginTop: 20}}>\n                                <Divider>\n                                    在线充值\n                                </Divider>\n                                <Form>\n                                    <Form.Input\n                                        disabled={!enableOnlineTopUp}\n                                        field={'redemptionCount'}\n                                        label={'实付金额：' + renderAmount()}\n                                        placeholder={'充值数量，最低' + minTopUp + '$'}\n                                        name='redemptionCount'\n                                        type={'number'}\n                                        value={topUpCount}\n                                        suffix={'$'}\n                                        min={minTopUp}\n                                        defaultValue={minTopUp}\n                                        max={100000}\n                                        onChange={async (value) => {\n                                            if (value < 1) {\n                                                value = 1;\n                                            }\n                                            if (value > 100000) {\n                                                value = 100000;\n                                            }\n                                            setTopUpCount(value);\n                                            await getAmount(value);\n                                        }}\n                                    />\n                                    <Space>\n                                        <Button type={'primary'} theme={'solid'} onClick={\n                                            async () => {\n                                                preTopUp('zfb')\n                                            }\n                                        }>\n                                            支付宝\n                                        </Button>\n                                        <Button style={{backgroundColor: 'rgba(var(--semi-green-5), 1)'}}\n                                                type={'primary'}\n                                                theme={'solid'} onClick={\n                                            async () => {\n                                                preTopUp('wx')\n                                            }\n                                        }>\n                                            微信\n                                        </Button>\n                                    </Space>\n                                </Form>\n                            </div> */}\n                            {/*<div style={{ display: 'flex', justifyContent: 'right' }}>*/}\n                            {/*    <Text>*/}\n                            {/*        <Link onClick={*/}\n                            {/*            async () => {*/}\n                            {/*                window.location.href = '/topup/history'*/}\n                            {/*            }*/}\n                            {/*        }>充值记录</Link>*/}\n                            {/*    </Text>*/}\n                            {/*</div>*/}\n                        </Card>\n                    </div>\n\n                </Layout.Content>\n            </Layout>\n        </div>\n\n    );\n};\n\nexport default TopUp;"
  },
  {
    "path": "web/air/src/pages/User/AddUser.js",
    "content": "import React, { useState } from 'react';\nimport { API, isMobile, showError, showSuccess } from '../../helpers';\nimport Title from '@douyinfe/semi-ui/lib/es/typography/title';\nimport { Button, Input, SideSheet, Space, Spin } from '@douyinfe/semi-ui';\n\nconst AddUser = (props) => {\n  const originInputs = {\n    username: '',\n    display_name: '',\n    password: ''\n  };\n  const [inputs, setInputs] = useState(originInputs);\n  const [loading, setLoading] = useState(false);\n  const { username, display_name, password } = inputs;\n\n  const handleInputChange = (name, value) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const submit = async () => {\n    setLoading(true);\n    if (inputs.username === '' || inputs.password === '') return;\n    const res = await API.post(`/api/user/`, inputs);\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('用户账户创建成功！');\n      setInputs(originInputs);\n      props.refresh();\n      props.handleClose();\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleCancel = () => {\n    props.handleClose();\n  };\n\n  return (\n    <>\n      <SideSheet\n        placement={'left'}\n        title={<Title level={3}>{'添加用户'}</Title>}\n        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        visible={props.visible}\n        footer={\n          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>\n            <Space>\n              <Button theme=\"solid\" size={'large'} onClick={submit}>提交</Button>\n              <Button theme=\"solid\" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>\n            </Space>\n          </div>\n        }\n        closeIcon={null}\n        onCancel={() => handleCancel()}\n        width={isMobile() ? '100%' : 600}\n      >\n        <Spin spinning={loading}>\n          <Input\n            style={{ marginTop: 20 }}\n            label=\"用户名\"\n            name=\"username\"\n            addonBefore={'用户名'}\n            placeholder={'请输入用户名'}\n            onChange={value => handleInputChange('username', value)}\n            value={username}\n            autoComplete=\"off\"\n          />\n          <Input\n            style={{ marginTop: 20 }}\n            addonBefore={'显示名'}\n            label=\"显示名称\"\n            name=\"display_name\"\n            autoComplete=\"off\"\n            placeholder={'请输入显示名称'}\n            onChange={value => handleInputChange('display_name', value)}\n            value={display_name}\n          />\n          <Input\n            style={{ marginTop: 20 }}\n            label=\"密 码\"\n            name=\"password\"\n            type={'password'}\n            addonBefore={'密码'}\n            placeholder={'请输入密码'}\n            onChange={value => handleInputChange('password', value)}\n            value={password}\n            autoComplete=\"off\"\n          />\n        </Spin>\n      </SideSheet>\n    </>\n  );\n};\n\nexport default AddUser;\n"
  },
  {
    "path": "web/air/src/pages/User/EditUser.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { API, isMobile, showError, showSuccess } from '../../helpers';\nimport { renderQuotaWithPrompt } from '../../helpers/render';\nimport Title from '@douyinfe/semi-ui/lib/es/typography/title';\nimport { Button, Divider, Input, Select, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';\n\nconst EditUser = (props) => {\n  const userId = props.editingUser.id;\n  const [loading, setLoading] = useState(true);\n  const [inputs, setInputs] = useState({\n    username: '',\n    display_name: '',\n    password: '',\n    github_id: '',\n    wechat_id: '',\n    email: '',\n    quota: 0,\n    group: 'default'\n  });\n  const [groupOptions, setGroupOptions] = useState([]);\n  const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } =\n    inputs;\n  const handleInputChange = (name, value) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n  const fetchGroups = async () => {\n    try {\n      let res = await API.get(`/api/group/`);\n      setGroupOptions(res.data.data.map((group) => ({\n        label: group,\n        value: group\n      })));\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n  const navigate = useNavigate();\n  const handleCancel = () => {\n    props.handleClose();\n  };\n  const loadUser = async () => {\n    setLoading(true);\n    let res = undefined;\n    if (userId) {\n      res = await API.get(`/api/user/${userId}`);\n    } else {\n      res = await API.get(`/api/user/self`);\n    }\n    const { success, message, data } = res.data;\n    if (success) {\n      data.password = '';\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  useEffect(() => {\n    loadUser().then();\n    if (userId) {\n      fetchGroups().then();\n    }\n  }, [props.editingUser.id]);\n\n  const submit = async () => {\n    setLoading(true);\n    let res = undefined;\n    if (userId) {\n      let data = { ...inputs, id: parseInt(userId) };\n      if (typeof data.quota === 'string') {\n        data.quota = parseInt(data.quota);\n      }\n      res = await API.put(`/api/user/`, data);\n    } else {\n      res = await API.put(`/api/user/self`, inputs);\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('用户信息更新成功！');\n      props.refresh();\n      props.handleClose();\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  return (\n    <>\n      <SideSheet\n        placement={'right'}\n        title={<Title level={3}>{'编辑用户'}</Title>}\n        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}\n        visible={props.visible}\n        footer={\n          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>\n            <Space>\n              <Button theme=\"solid\" size={'large'} onClick={submit}>提交</Button>\n              <Button theme=\"solid\" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>\n            </Space>\n          </div>\n        }\n        closeIcon={null}\n        onCancel={() => handleCancel()}\n        width={isMobile() ? '100%' : 600}\n      >\n        <Spin spinning={loading}>\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>用户名</Typography.Text>\n          </div>\n          <Input\n            label=\"用户名\"\n            name=\"username\"\n            placeholder={'请输入新的用户名'}\n            onChange={value => handleInputChange('username', value)}\n            value={username}\n            autoComplete=\"new-password\"\n          />\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>密码</Typography.Text>\n          </div>\n          <Input\n            label=\"密码\"\n            name=\"password\"\n            type={'password'}\n            placeholder={'请输入新的密码，最短 8 位'}\n            onChange={value => handleInputChange('password', value)}\n            value={password}\n            autoComplete=\"new-password\"\n          />\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>显示名称</Typography.Text>\n          </div>\n          <Input\n            label=\"显示名称\"\n            name=\"display_name\"\n            placeholder={'请输入新的显示名称'}\n            onChange={value => handleInputChange('display_name', value)}\n            value={display_name}\n            autoComplete=\"new-password\"\n          />\n          {\n            userId && <>\n              <div style={{ marginTop: 20 }}>\n                <Typography.Text>分组</Typography.Text>\n              </div>\n              <Select\n                placeholder={'请选择分组'}\n                name=\"group\"\n                fluid\n                search\n                selection\n                allowAdditions\n                additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组：'}\n                onChange={value => handleInputChange('group', value)}\n                value={inputs.group}\n                autoComplete=\"new-password\"\n                optionList={groupOptions}\n              />\n              <div style={{ marginTop: 20 }}>\n                <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>\n              </div>\n              <Input\n                name=\"quota\"\n                placeholder={'请输入新的剩余额度'}\n                onChange={value => handleInputChange('quota', value)}\n                value={quota}\n                type={'number'}\n                autoComplete=\"new-password\"\n              />\n            </>\n          }\n          <Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>已绑定的 GitHub 账户</Typography.Text>\n          </div>\n          <Input\n            name=\"github_id\"\n            value={github_id}\n            autoComplete=\"new-password\"\n            placeholder=\"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\"\n            readonly\n          />\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>已绑定的微信账户</Typography.Text>\n          </div>\n          <Input\n            name=\"wechat_id\"\n            value={wechat_id}\n            autoComplete=\"new-password\"\n            placeholder=\"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\"\n            readonly\n          />\n          <Input\n            name=\"telegram_id\"\n            value={telegram_id}\n            autoComplete=\"new-password\"\n            placeholder=\"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\"\n            readonly\n          />\n          <div style={{ marginTop: 20 }}>\n            <Typography.Text>已绑定的邮箱账户</Typography.Text>\n          </div>\n          <Input\n            name=\"email\"\n            value={email}\n            autoComplete=\"new-password\"\n            placeholder=\"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\"\n            readonly\n          />\n        </Spin>\n      </SideSheet>\n    </>\n  );\n};\n\nexport default EditUser;\n"
  },
  {
    "path": "web/air/src/pages/User/index.js",
    "content": "import React from 'react';\nimport UsersTable from '../../components/UsersTable';\nimport {Layout} from \"@douyinfe/semi-ui\";\n\nconst User = () => (\n  <>\n    <Layout>\n        <Layout.Header>\n            <h3>管理用户</h3>\n        </Layout.Header>\n        <Layout.Content>\n            <UsersTable/>\n        </Layout.Content>\n    </Layout>\n  </>\n);\n\nexport default User;\n"
  },
  {
    "path": "web/air/vercel.json",
    "content": "{\n  \"github\": {\n    \"silent\": true\n  }\n}\n"
  },
  {
    "path": "web/berry/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.idea\npackage-lock.json\nyarn.lock"
  },
  {
    "path": "web/berry/.prettierrc",
    "content": "{\n  \"bracketSpacing\": true,\n  \"printWidth\": 140,\n  \"singleQuote\": true,\n  \"trailingComma\": \"none\",\n  \"tabWidth\": 2,\n  \"useTabs\": false\n}\n"
  },
  {
    "path": "web/berry/README.md",
    "content": "# One API 前端界面\n\n这个项目是 One API 的前端界面，它基于 [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template) 进行开发。\n\n## 使用的开源项目\n\n使用了以下开源项目作为我们项目的一部分：\n\n- [Berry Free React Admin Template](https://github.com/codedthemes/berry-free-react-admin-template)\n- [minimal-ui-kit](minimal-ui-kit)\n\n## 开发说明\n\n当添加新的渠道时，需要修改以下地方：\n\n1. `web/berry/src/constants/ChannelConstants.js`\n\n在该文件中的 `CHANNEL_OPTIONS` 添加新的渠道\n\n```js\nexport const CHANNEL_OPTIONS = {\n  //key 为渠道ID\n  1: {\n    key: 1, // 渠道ID\n    text: \"OpenAI\", // 渠道名称\n    value: 1, // 渠道ID\n    color: \"primary\", // 渠道列表显示的颜色\n  },\n};\n```\n\n2. `web/berry/src/views/Channel/type/Config.js`\n\n在该文件中的`typeConfig`添加新的渠道配置， 如果无需配置，可以不添加\n\n```js\nconst typeConfig = {\n  // key 为渠道ID\n  3: {\n    inputLabel: {\n      // 输入框名称 配置\n      // 对应的字段名称\n      base_url: \"AZURE_OPENAI_ENDPOINT\",\n      other: \"默认 API 版本\",\n    },\n    prompt: {\n      // 输入框提示 配置\n      // 对应的字段名称\n      base_url: \"请填写AZURE_OPENAI_ENDPOINT\",\n\n      // 注意：通过判断 `other` 是否有值来判断是否需要显示 `other` 输入框， 默认是没有值的\n      other: \"请输入默认API版本，例如：2024-03-01-preview\",\n    },\n    modelGroup: \"openai\", // 模型组名称,这个值是给 填入渠道支持模型 按钮使用的。 填入渠道支持模型 按钮会根据这个值来获取模型组，如果填写默认是 openai\n  },\n};\n```\n\n## 许可证\n\n本项目中使用的代码遵循 MIT 许可证。\n"
  },
  {
    "path": "web/berry/jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"module\": \"commonjs\",\n    \"baseUrl\": \"src\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "web/berry/package.json",
    "content": "{\n  \"name\": \"one_api_web\",\n  \"version\": \"1.0.0\",\n  \"proxy\": \"http://127.0.0.1:3000\",\n  \"private\": true,\n  \"homepage\": \"\",\n  \"dependencies\": {\n    \"@emotion/cache\": \"^11.9.3\",\n    \"@emotion/react\": \"^11.9.3\",\n    \"@emotion/styled\": \"^11.9.3\",\n    \"@mui/icons-material\": \"^5.8.4\",\n    \"@mui/lab\": \"^5.0.0-alpha.88\",\n    \"@mui/material\": \"^5.8.6\",\n    \"@mui/system\": \"^5.8.6\",\n    \"@mui/utils\": \"^5.8.6\",\n    \"@mui/x-date-pickers\": \"^6.18.5\",\n    \"@tabler/icons-react\": \"^2.44.0\",\n    \"apexcharts\": \"3.35.3\",\n    \"axios\": \"^0.27.2\",\n    \"dayjs\": \"^1.11.10\",\n    \"formik\": \"^2.2.9\",\n    \"framer-motion\": \"^6.3.16\",\n    \"history\": \"^5.3.0\",\n    \"marked\": \"^4.1.1\",\n    \"material-ui-popup-state\": \"^4.0.1\",\n    \"notistack\": \"^3.0.1\",\n    \"prop-types\": \"^15.8.1\",\n    \"react\": \"^18.2.0\",\n    \"react-apexcharts\": \"1.4.0\",\n    \"react-device-detect\": \"^2.2.2\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-perfect-scrollbar\": \"^1.5.8\",\n    \"react-redux\": \"^8.0.2\",\n    \"react-router\": \"6.3.0\",\n    \"react-router-dom\": \"6.3.0\",\n    \"react-scripts\": \"^5.0.1\",\n    \"react-turnstile\": \"^1.1.2\",\n    \"redux\": \"^4.2.0\",\n    \"yup\": \"^0.32.11\"\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"build\": \"react-scripts build && mv -f build ../build/berry\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\"\n    ]\n  },\n  \"babel\": {\n    \"presets\": [\n      \"@babel/preset-react\"\n    ]\n  },\n  \"browserslist\": {\n    \"production\": [\n      \"defaults\",\n      \"not IE 11\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.21.4\",\n    \"@babel/eslint-parser\": \"^7.21.3\",\n    \"eslint\": \"^8.38.0\",\n    \"eslint-config-prettier\": \"^8.8.0\",\n    \"eslint-config-react-app\": \"^7.0.1\",\n    \"eslint-plugin-flowtype\": \"^8.0.3\",\n    \"eslint-plugin-import\": \"^2.27.5\",\n    \"eslint-plugin-jsx-a11y\": \"^6.7.1\",\n    \"eslint-plugin-prettier\": \"^4.2.1\",\n    \"eslint-plugin-react\": \"^7.32.2\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"immutable\": \"^4.3.0\",\n    \"prettier\": \"^2.8.7\",\n    \"sass\": \"^1.53.0\"\n  }\n}\n"
  },
  {
    "path": "web/berry/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n  <head>\n    <title>One API</title>\n    <link rel=\"icon\" href=\"/favicon.ico\" />\n    <!-- Meta Tags-->\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta name=\"theme-color\" content=\"#2296f3\" />\n    <meta\n      name=\"description\"\n      content=\"OpenAI 接口聚合管理，支持多种渠道包括 Azure，可用于二次分发管理 key，仅单可执行文件，已打包好 Docker 镜像，一键部署，开箱即用\"\n    />\n  </head>\n\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n\n  </body>\n</html>\n"
  },
  {
    "path": "web/berry/src/App.js",
    "content": "import { useEffect } from 'react';\nimport { useSelector, useDispatch } from 'react-redux';\n\nimport { ThemeProvider } from '@mui/material/styles';\nimport { CssBaseline, StyledEngineProvider } from '@mui/material';\nimport { SET_THEME } from 'store/actions';\n// routing\nimport Routes from 'routes';\n\n// defaultTheme\nimport themes from 'themes';\n\n// project imports\nimport NavigationScroll from 'layout/NavigationScroll';\n\n// auth\nimport UserProvider from 'contexts/UserContext';\nimport StatusProvider from 'contexts/StatusContext';\nimport { SnackbarProvider } from 'notistack';\n\n// ==============================|| APP ||============================== //\n\nconst App = () => {\n  const dispatch = useDispatch();\n  const customization = useSelector((state) => state.customization);\n\n  useEffect(() => {\n    const storedTheme = localStorage.getItem('theme');\n    if (storedTheme) {\n      dispatch({ type: SET_THEME, theme: storedTheme });\n    }\n  }, [dispatch]);\n\n  return (\n    <StyledEngineProvider injectFirst>\n      <ThemeProvider theme={themes(customization)}>\n        <CssBaseline />\n        <NavigationScroll>\n          <SnackbarProvider autoHideDuration={5000} maxSnack={3} anchorOrigin={{ vertical: 'top', horizontal: 'right' }}>\n            <UserProvider>\n              <StatusProvider>\n                <Routes />\n              </StatusProvider>\n            </UserProvider>\n          </SnackbarProvider>\n        </NavigationScroll>\n      </ThemeProvider>\n    </StyledEngineProvider>\n  );\n};\n\nexport default App;\n"
  },
  {
    "path": "web/berry/src/assets/scss/_themes-vars.module.scss",
    "content": "// paper & background\n$paper: #ffffff;\n\n// primary\n$primaryLight: #eef2f6;\n$primaryMain: #2196f3;\n$primaryDark: #1e88e5;\n$primary200: #90caf9;\n$primary800: #1565c0;\n\n// secondary\n$secondaryLight: #ede7f6;\n$secondaryMain: #673ab7;\n$secondaryDark: #5e35b1;\n$secondary200: #b39ddb;\n$secondary800: #4527a0;\n\n// success Colors\n$successLight: #b9f6ca;\n$success200: #69f0ae;\n$successMain: #00e676;\n$successDark: #00c853;\n\n// error\n$errorLight: #ef9a9a;\n$errorMain: #f44336;\n$errorDark: #c62828;\n\n// orange\n$orangeLight: #fbe9e7;\n$orangeMain: #ffab91;\n$orangeDark: #d84315;\n\n// warning\n$warningLight: #fff8e1;\n$warningMain: #ffe57f;\n$warningDark: #ffc107;\n\n// grey\n$grey50: #f8fafc;\n$grey100: #eef2f6;\n$grey200: #e3e8ef;\n$grey300: #cdd5df;\n$grey500: #697586;\n$grey600: #4b5565;\n$grey700: #364152;\n$grey900: #121926;\n\n$tableBackground: #f4f6f8;\n$tableBorderBottom: #f1f3f4;\n\n// ==============================|| DARK THEME VARIANTS ||============================== //\n\n// paper & background\n$darkBackground: #1a223f; // level 3\n$darkPaper: #111936; // level 4\n$darkDivider: rgba(227, 232, 239, 0.2);\n$darkSelectedBack : rgba(124, 77, 255, 0.15);\n\n// dark 800 & 900\n$darkLevel1: #29314f; // level 1\n$darkLevel2: #212946; // level 2\n\n// primary dark\n$darkPrimaryLight: #eef2f6;\n$darkPrimaryMain: #2196f3;\n$darkPrimaryDark: #1e88e5;\n$darkPrimary200: #90caf9;\n$darkPrimary800: #1565c0;\n\n// secondary dark\n$darkSecondaryLight: #d1c4e9;\n$darkSecondaryMain: #7c4dff;\n$darkSecondaryDark: #651fff;\n$darkSecondary200: #b39ddb;\n$darkSecondary800: #6200ea;\n\n// text variants\n$darkTextTitle: #d7dcec;\n$darkTextPrimary: #bdc8f0;\n$darkTextSecondary: #8492c4;\n\n// ==============================|| JAVASCRIPT ||============================== //\n\n:export {\n  // paper & background\n  paper: $paper;\n\n  // primary\n  primaryLight: $primaryLight;\n  primary200: $primary200;\n  primaryMain: $primaryMain;\n  primaryDark: $primaryDark;\n  primary800: $primary800;\n\n  // secondary\n  secondaryLight: $secondaryLight;\n  secondary200: $secondary200;\n  secondaryMain: $secondaryMain;\n  secondaryDark: $secondaryDark;\n  secondary800: $secondary800;\n\n  // success\n  successLight: $successLight;\n  success200: $success200;\n  successMain: $successMain;\n  successDark: $successDark;\n\n  // error\n  errorLight: $errorLight;\n  errorMain: $errorMain;\n  errorDark: $errorDark;\n\n  // orange\n  orangeLight: $orangeLight;\n  orangeMain: $orangeMain;\n  orangeDark: $orangeDark;\n\n  // warning\n  warningLight: $warningLight;\n  warningMain: $warningMain;\n  warningDark: $warningDark;\n\n  // grey\n  grey50: $grey50;\n  grey100: $grey100;\n  grey200: $grey200;\n  grey300: $grey300;\n  grey500: $grey500;\n  grey600: $grey600;\n  grey700: $grey700;\n  grey900: $grey900;\n\n  // ==============================|| DARK THEME VARIANTS ||============================== //\n\n  // paper & background\n  darkPaper: $darkPaper;\n  darkBackground: $darkBackground;\n\n  // dark 800 & 900\n  darkLevel1: $darkLevel1;\n  darkLevel2: $darkLevel2;\n\n  // text variants\n  darkTextTitle: $darkTextTitle;\n  darkTextPrimary: $darkTextPrimary;\n  darkTextSecondary: $darkTextSecondary;\n\n  // primary dark\n  darkPrimaryLight: $darkPrimaryLight;\n  darkPrimaryMain: $darkPrimaryMain;\n  darkPrimaryDark: $darkPrimaryDark;\n  darkPrimary200: $darkPrimary200;\n  darkPrimary800: $darkPrimary800;\n\n  // secondary dark\n  darkSecondaryLight: $darkSecondaryLight;\n  darkSecondaryMain: $darkSecondaryMain;\n  darkSecondaryDark: $darkSecondaryDark;\n  darkSecondary200: $darkSecondary200;\n  darkSecondary800: $darkSecondary800;\n\n  darkDivider: $darkDivider;\n  darkSelectedBack: $darkSelectedBack;\n  tableBackground: $tableBackground;\n  tableBorderBottom: $tableBorderBottom;\n}\n"
  },
  {
    "path": "web/berry/src/assets/scss/fonts.scss",
    "content": "\n/* roboto-regular */\n@font-face {\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 400;\n    font-display: swap;\n    src: local('Roboto'), url('../fonts/roboto-regular.woff2') format('woff2');\n    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n  }\n\n  /* roboto-500 */\n@font-face {\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 500;\n    font-display: swap;\n    src: local('Roboto'), url('../fonts/roboto-500.woff2') format('woff2');\n    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n  \n    \n/* roboto-700 */\n@font-face {\n    font-family: 'Roboto';\n    font-style: normal;\n    font-weight: 700;\n    font-display: swap;\n    src: local('Roboto'), url('../fonts/roboto-700.woff2') format('woff2');\n    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n    "
  },
  {
    "path": "web/berry/src/assets/scss/style.scss",
    "content": "@import 'fonts.scss';\n// color variants\n@import 'themes-vars.module.scss';\n\n// third-party\n@import '~react-perfect-scrollbar/dist/css/styles.css';\n\n// ==============================|| LIGHT BOX ||============================== //\n.fullscreen .react-images__blanket {\n  z-index: 1200;\n}\n\n// ==============================|| APEXCHART ||============================== //\n\n.apexcharts-legend-series .apexcharts-legend-marker {\n  margin-right: 8px;\n}\n\n// ==============================|| PERFECT SCROLLBAR ||============================== //\n\n.scrollbar-container {\n  .ps__rail-y {\n    &:hover > .ps__thumb-y,\n    &:focus > .ps__thumb-y,\n    &.ps--clicking .ps__thumb-y {\n      background-color: $grey500;\n      width: 5px;\n    }\n  }\n  .ps__thumb-y {\n    background-color: $grey500;\n    border-radius: 6px;\n    width: 5px;\n    right: 0;\n  }\n}\n\n.scrollbar-container.ps,\n.scrollbar-container > .ps {\n  &.ps--active-y > .ps__rail-y {\n    width: 5px;\n    background-color: transparent !important;\n    z-index: 999;\n    &:hover,\n    &.ps--clicking {\n      width: 5px;\n      background-color: transparent;\n    }\n  }\n  &.ps--scrolling-y > .ps__rail-y,\n  &.ps--scrolling-x > .ps__rail-x {\n    opacity: 0.4;\n    background-color: transparent;\n  }\n}\n\n// ==============================|| ANIMATION KEYFRAMES ||============================== //\n\n@keyframes wings {\n  50% {\n    transform: translateY(-40px);\n  }\n  100% {\n    transform: translateY(0px);\n  }\n}\n\n@keyframes blink {\n  50% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n\n@keyframes bounce {\n  0%,\n  20%,\n  53%,\n  to {\n    animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n    transform: translateZ(0);\n  }\n  40%,\n  43% {\n    animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);\n    transform: translate3d(0, -5px, 0);\n  }\n  70% {\n    animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);\n    transform: translate3d(0, -7px, 0);\n  }\n  80% {\n    transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n    transform: translateZ(0);\n  }\n  90% {\n    transform: translate3d(0, -2px, 0);\n  }\n}\n\n@keyframes slideY {\n  0%,\n  50%,\n  100% {\n    transform: translateY(0px);\n  }\n  25% {\n    transform: translateY(-10px);\n  }\n  75% {\n    transform: translateY(10px);\n  }\n}\n\n@keyframes slideX {\n  0%,\n  50%,\n  100% {\n    transform: translateX(0px);\n  }\n  25% {\n    transform: translateX(-10px);\n  }\n  75% {\n    transform: translateX(10px);\n  }\n}\n"
  },
  {
    "path": "web/berry/src/config.js",
    "content": "const config = {\n  // basename: only at build time to set, and Don't add '/' at end off BASENAME for breadcrumbs, also Don't put only '/' use blank('') instead,\n  // like '/berry-material-react/react/default'\n  basename: '/',\n  defaultPath: '/panel/dashboard',\n  fontFamily: `'Roboto', sans-serif, Helvetica, Arial, sans-serif`,\n  borderRadius: 12,\n  siteInfo: {\n    chat_link: '',\n    display_in_currency: true,\n    email_verification: false,\n    footer_html: '',\n    github_client_id: '',\n    github_oauth: false,\n    logo: '',\n    quota_per_unit: 500000,\n    server_address: '',\n    start_time: 0,\n    system_name: 'One API',\n    top_up_link: '',\n    turnstile_check: false,\n    turnstile_site_key: '',\n    version: '',\n    wechat_login: false,\n    wechat_qrcode: '',\n    oidc: false,\n    oidc_client_id: '',\n    oidc_authorization_endpoint: '',\n    oidc_token_endpoint: '',\n    oidc_userinfo_endpoint: '',\n  }\n};\n\nexport default config;\n"
  },
  {
    "path": "web/berry/src/constants/ChannelConstants.js",
    "content": "export const CHANNEL_OPTIONS = {\n  1: {\n    key: 1,\n    text: 'OpenAI',\n    value: 1,\n    color: 'success'\n  },\n  14: {\n    key: 14,\n    text: 'Anthropic Claude',\n    value: 14,\n    color: 'primary'\n  },\n  33: {\n    key: 33,\n    text: 'AWS',\n    value: 33,\n    color: 'primary'\n  },\n  37: {\n    key: 37,\n    text: 'Cloudflare',\n    value: 37,\n    color: 'success'\n  },\n  3: {\n    key: 3,\n    text: 'Azure OpenAI',\n    value: 3,\n    color: 'success'\n  },\n  11: {\n    key: 11,\n    text: 'Google PaLM2',\n    value: 11,\n    color: 'warning'\n  },\n  24: {\n    key: 24,\n    text: 'Google Gemini',\n    value: 24,\n    color: 'warning'\n  },\n  28: {\n    key: 28,\n    text: 'Mistral AI',\n    value: 28,\n    color: 'warning'\n  },\n  40: {\n    key: 40,\n    text: '字节火山引擎',\n    value: 40,\n    color: 'primary'\n  },\n  15: {\n    key: 15,\n    text: '百度文心千帆',\n    value: 15,\n    color: 'primary'\n  },\n  17: {\n    key: 17,\n    text: '阿里通义千问',\n    value: 17,\n    color: 'primary'\n  },\n  18: {\n    key: 18,\n    text: '讯飞星火认知',\n    value: 18,\n    color: 'primary'\n  },\n  16: {\n    key: 16,\n    text: '智谱 ChatGLM',\n    value: 16,\n    color: 'primary'\n  },\n  19: {\n    key: 19,\n    text: '360 智脑',\n    value: 19,\n    color: 'primary'\n  },\n  25: {\n    key: 25,\n    text: 'Moonshot AI',\n    value: 25,\n    color: 'primary'\n  },\n  23: {\n    key: 23,\n    text: '腾讯混元',\n    value: 23,\n    color: 'primary'\n  },\n  26: {\n    key: 26,\n    text: '百川大模型',\n    value: 26,\n    color: 'primary'\n  },\n  27: {\n    key: 27,\n    text: 'MiniMax',\n    value: 27,\n    color: 'primary'\n  },\n  29: {\n    key: 29,\n    text: 'Groq',\n    value: 29,\n    color: 'primary'\n  },\n  30: {\n    key: 30,\n    text: 'Ollama',\n    value: 30,\n    color: 'primary'\n  },\n  31: {\n    key: 31,\n    text: '零一万物',\n    value: 31,\n    color: 'primary'\n  },\n  32: {\n    key: 32,\n    text: '阶跃星辰',\n    value: 32,\n    color: 'primary'\n  },\n  34: {\n    key: 34,\n    text: 'Coze',\n    value: 34,\n    color: 'primary'\n  },\n  35: {\n    key: 35,\n    text: 'Cohere',\n    value: 35,\n    color: 'primary'\n  },\n  36: {\n    key: 36,\n    text: 'DeepSeek',\n    value: 36,\n    color: 'primary'\n  },\n  38: {\n    key: 38,\n    text: 'DeepL',\n    value: 38,\n    color: 'primary'\n  },\n  39: {\n    key: 39,\n    text: 'together.ai',\n    value: 39,\n    color: 'primary'\n  },\n  42: {\n    key: 42,\n    text: 'VertexAI',\n    value: 42,\n    color: 'primary'\n  },\n  43: {\n    key: 43,\n    text: 'Proxy',\n    value: 43,\n    color: 'primary'\n  },\n  44: {\n    key: 44,\n    text: 'SiliconFlow',\n    value: 44,\n    color: 'primary'\n  },\n  45: {\n    key: 45,\n    text: 'xAI',\n    value: 45,\n    color: 'primary'\n  },\n  45: {\n    key: 46,\n    text: 'Replicate',\n    value: 46,\n    color: 'primary'\n  },\n  41: {\n    key: 41,\n    text: 'Novita',\n    value: 41,\n    color: 'purple'\n  },\n  8: {\n    key: 8,\n    text: '自定义渠道',\n    value: 8,\n    color: 'error'\n  },\n  22: {\n    key: 22,\n    text: '知识库：FastGPT',\n    value: 22,\n    color: 'success'\n  },\n  21: {\n    key: 21,\n    text: '知识库：AI Proxy',\n    value: 21,\n    color: 'success'\n  },\n  20: {\n    key: 20,\n      text: 'OpenRouter',\n    value: 20,\n    color: 'success'\n  },\n  2: {\n    key: 2,\n    text: '代理：API2D',\n    value: 2,\n    color: 'success'\n  },\n  5: {\n    key: 5,\n    text: '代理：OpenAI-SB',\n    value: 5,\n    color: 'success'\n  },\n  7: {\n    key: 7,\n    text: '代理：OhMyGPT',\n    value: 7,\n    color: 'success'\n  },\n  10: {\n    key: 10,\n    text: '代理：AI Proxy',\n    value: 10,\n    color: 'success'\n  },\n  4: {\n    key: 4,\n    text: '代理：CloseAI',\n    value: 4,\n    color: 'success'\n  },\n  6: {\n    key: 6,\n    text: '代理：OpenAI Max',\n    value: 6,\n    color: 'success'\n  },\n  9: {\n    key: 9,\n    text: '代理：AI.LS',\n    value: 9,\n    color: 'success'\n  },\n  12: {\n    key: 12,\n    text: '代理：API2GPT',\n    value: 12,\n    color: 'success'\n  },\n  13: {\n    key: 13,\n    text: '代理：AIGC2D',\n    value: 13,\n    color: 'success'\n  }\n};\n"
  },
  {
    "path": "web/berry/src/constants/CommonConstants.js",
    "content": "export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!\n"
  },
  {
    "path": "web/berry/src/constants/SnackbarConstants.js",
    "content": "import { closeSnackbar } from 'notistack';\nimport { IconX } from '@tabler/icons-react';\nimport { IconButton } from '@mui/material';\nconst action = (snackbarId) => (\n  <>\n    <IconButton\n      onClick={() => {\n        closeSnackbar(snackbarId);\n      }}\n    >\n      <IconX stroke={1.5} size=\"1.25rem\" />\n    </IconButton>\n  </>\n);\n\nexport const snackbarConstants = {\n  Common: {\n    ERROR: {\n      variant: 'error',\n      autoHideDuration: 5000,\n      preventDuplicate: true,\n      action\n    },\n    WARNING: {\n      variant: 'warning',\n      autoHideDuration: 10000,\n      preventDuplicate: true,\n      action\n    },\n    SUCCESS: {\n      variant: 'success',\n      autoHideDuration: 1500,\n      preventDuplicate: true,\n      action\n    },\n    INFO: {\n      variant: 'info',\n      autoHideDuration: 3000,\n      preventDuplicate: true,\n      action\n    },\n    NOTICE: {\n      variant: 'info',\n      autoHideDuration: 20000,\n      preventDuplicate: true,\n      action\n    },\n    COPY: {\n      variant: 'copy',\n      persist: true,\n      preventDuplicate: true,\n      allowDownload: true,\n      action\n    }\n  },\n  Mobile: {\n    anchorOrigin: { vertical: 'bottom', horizontal: 'center' }\n  }\n};\n"
  },
  {
    "path": "web/berry/src/constants/index.js",
    "content": "export * from './SnackbarConstants';\nexport * from './CommonConstants';\nexport * from './ChannelConstants';\n"
  },
  {
    "path": "web/berry/src/contexts/StatusContext.js",
    "content": "import { useEffect, useCallback, createContext } from \"react\";\nimport { API } from \"utils/api\";\nimport { showNotice, showError } from \"utils/common\";\nimport { SET_SITE_INFO } from \"store/actions\";\nimport { useDispatch } from \"react-redux\";\n\nexport const LoadStatusContext = createContext();\n\n// eslint-disable-next-line\nconst StatusProvider = ({ children }) => {\n  const dispatch = useDispatch();\n\n  const loadStatus = useCallback(async () => {\n    const res = await API.get(\"/api/status\");\n    const { success, data } = res.data;\n    let system_name = \"\";\n    if (success) {\n      if (!data.chat_link) {\n        delete data.chat_link;\n      }\n      localStorage.setItem(\"siteInfo\", JSON.stringify(data));\n      localStorage.setItem(\"quota_per_unit\", data.quota_per_unit);\n      localStorage.setItem(\"display_in_currency\", data.display_in_currency);\n      dispatch({ type: SET_SITE_INFO, payload: data });\n      if (\n        data.version !== process.env.REACT_APP_VERSION &&\n        data.version !== \"v0.0.0\" &&\n        data.version !== \"\" &&\n        process.env.REACT_APP_VERSION !== \"\"\n      ) {\n        showNotice(\n          `新版本可用：${data.version}，请使用快捷键 Shift + F5 刷新页面`\n        );\n      }\n      if (data.system_name) {\n        system_name = data.system_name;\n      }\n    } else {\n      const backupSiteInfo = localStorage.getItem(\"siteInfo\");\n      if (backupSiteInfo) {\n        const data = JSON.parse(backupSiteInfo);\n        if (data.system_name) {\n          system_name = data.system_name;\n        }\n        dispatch({\n          type: SET_SITE_INFO,\n          payload: data,\n        });\n      }\n      showError(\"无法正常连接至服务器！\");\n    }\n\n    if (system_name) {\n      document.title = system_name;\n    }\n  }, [dispatch]);\n\n  useEffect(() => {\n    loadStatus().then();\n  }, [loadStatus]);\n\n  return (\n    <LoadStatusContext.Provider value={loadStatus}>\n      {\" \"}\n      {children}{\" \"}\n    </LoadStatusContext.Provider>\n  );\n};\n\nexport default StatusProvider;\n"
  },
  {
    "path": "web/berry/src/contexts/UserContext.js",
    "content": "// contexts/User/index.jsx\nimport React, { useEffect, useCallback, createContext, useState } from 'react';\nimport { LOGIN } from 'store/actions';\nimport { useDispatch } from 'react-redux';\n\nexport const UserContext = createContext();\n\n// eslint-disable-next-line\nconst UserProvider = ({ children }) => {\n  const dispatch = useDispatch();\n  const [isUserLoaded, setIsUserLoaded] = useState(false);\n\n  const loadUser = useCallback(() => {\n    let user = localStorage.getItem('user');\n    if (user) {\n      let data = JSON.parse(user);\n      dispatch({ type: LOGIN, payload: data });\n    }\n    setIsUserLoaded(true);\n  }, [dispatch]);\n\n  useEffect(() => {\n    loadUser();\n  }, [loadUser]);\n\n  return <UserContext.Provider value={{ loadUser, isUserLoaded }}> {children} </UserContext.Provider>;\n};\n\nexport default UserProvider;\n"
  },
  {
    "path": "web/berry/src/hooks/useAuth.js",
    "content": "import { isAdmin } from 'utils/common';\nimport { useNavigate } from 'react-router-dom';\nconst navigate = useNavigate();\n\nconst useAuth = () => {\n  const userIsAdmin = isAdmin();\n\n  if (!userIsAdmin) {\n    navigate('/panel/404');\n  }\n};\n\nexport default useAuth;\n"
  },
  {
    "path": "web/berry/src/hooks/useLogin.js",
    "content": "import { API } from 'utils/api';\nimport { useDispatch } from 'react-redux';\nimport { LOGIN } from 'store/actions';\nimport { useNavigate } from 'react-router';\nimport { showSuccess } from 'utils/common';\n\nconst useLogin = () => {\n  const dispatch = useDispatch();\n  const navigate = useNavigate();\n  const login = async (username, password) => {\n    try {\n      const res = await API.post(`/api/user/login`, {\n        username,\n        password\n      });\n      const { success, message, data } = res.data;\n      if (success) {\n        localStorage.setItem('user', JSON.stringify(data));\n        dispatch({ type: LOGIN, payload: data });\n        navigate('/panel');\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  const githubLogin = async (code, state) => {\n    try {\n      const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`);\n      const { success, message, data } = res.data;\n      if (success) {\n        if (message === 'bind') {\n          showSuccess('绑定成功！');\n          navigate('/panel');\n        } else {\n          dispatch({ type: LOGIN, payload: data });\n          localStorage.setItem('user', JSON.stringify(data));\n          showSuccess('登录成功！');\n          navigate('/panel');\n        }\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  const larkLogin = async (code, state) => {\n    try {\n      const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);\n      const { success, message, data } = res.data;\n      if (success) {\n        if (message === 'bind') {\n          showSuccess('绑定成功！');\n          navigate('/panel');\n        } else {\n          dispatch({ type: LOGIN, payload: data });\n          localStorage.setItem('user', JSON.stringify(data));\n          showSuccess('登录成功！');\n          navigate('/panel');\n        }\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  const oidcLogin = async (code, state) => {\n    try {\n      const res = await API.get(`/api/oauth/oidc?code=${code}&state=${state}`);\n      const { success, message, data } = res.data;\n      if (success) {\n        if (message === 'bind') {\n          showSuccess('绑定成功！');\n          navigate('/panel');\n        } else {\n          dispatch({ type: LOGIN, payload: data });\n          localStorage.setItem('user', JSON.stringify(data));\n          showSuccess('登录成功！');\n          navigate('/panel');\n        }\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  }\n\n  const wechatLogin = async (code) => {\n    try {\n      const res = await API.get(`/api/oauth/wechat?code=${code}`);\n      const { success, message, data } = res.data;\n      if (success) {\n        dispatch({ type: LOGIN, payload: data });\n        localStorage.setItem('user', JSON.stringify(data));\n        showSuccess('登录成功！');\n        navigate('/panel');\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  const logout = async () => {\n    await API.get('/api/user/logout');\n    localStorage.removeItem('user');\n    dispatch({ type: LOGIN, payload: null });\n    navigate('/');\n  };\n\n  return { login, logout, githubLogin, wechatLogin, larkLogin,oidcLogin };\n};\n\nexport default useLogin;\n"
  },
  {
    "path": "web/berry/src/hooks/useRegister.js",
    "content": "import { API } from 'utils/api';\nimport { useNavigate } from 'react-router';\nimport { showSuccess } from 'utils/common';\n\nconst useRegister = () => {\n  const navigate = useNavigate();\n  const register = async (input, turnstile) => {\n    try {\n      let affCode = localStorage.getItem('aff');\n      if (affCode) {\n        input = { ...input, aff_code: affCode };\n      }\n      const res = await API.post(`/api/user/register?turnstile=${turnstile}`, input);\n      const { success, message } = res.data;\n      if (success) {\n        showSuccess('注册成功！');\n        navigate('/login');\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  const sendVerificationCode = async (email, turnstile) => {\n    try {\n      const res = await API.get(`/api/verification?email=${email}&turnstile=${turnstile}`);\n      const { success, message } = res.data;\n      if (success) {\n        showSuccess('验证码发送成功，请检查你的邮箱！');\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  return { register, sendVerificationCode };\n};\n\nexport default useRegister;\n"
  },
  {
    "path": "web/berry/src/hooks/useScriptRef.js",
    "content": "import { useEffect, useRef } from 'react';\n\n// ==============================|| ELEMENT REFERENCE HOOKS  ||============================== //\n\nconst useScriptRef = () => {\n  const scripted = useRef(true);\n\n  useEffect(\n    () => () => {\n      scripted.current = true;\n    },\n    []\n  );\n\n  return scripted;\n};\n\nexport default useScriptRef;\n"
  },
  {
    "path": "web/berry/src/index.js",
    "content": "import { createRoot } from 'react-dom/client';\n\n// third party\nimport { BrowserRouter } from 'react-router-dom';\nimport { Provider } from 'react-redux';\n\n// project imports\nimport * as serviceWorker from 'serviceWorker';\nimport App from 'App';\nimport { store } from 'store';\n\n// style + assets\nimport 'assets/scss/style.scss';\nimport config from './config';\n\n// ==============================|| REACT DOM RENDER  ||============================== //\n\nconst container = document.getElementById('root');\nconst root = createRoot(container); // createRoot(container!) if you use TypeScript\nroot.render(\n  <Provider store={store}>\n    <BrowserRouter basename={config.basename}>\n      <App />\n    </BrowserRouter>\n  </Provider>\n);\n\n// If you want your app to work offline and load faster, you can change\n// unregister() to register() below. Note this comes with some pitfalls.\n// Learn more about service workers: https://bit.ly/CRA-PWA\nserviceWorker.register();\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Header/ProfileSection/index.js",
    "content": "import { useState, useRef, useEffect } from 'react';\n\nimport { useSelector } from 'react-redux';\nimport { useNavigate } from 'react-router-dom';\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport {\n  Avatar,\n  Chip,\n  ClickAwayListener,\n  List,\n  ListItemButton,\n  ListItemIcon,\n  ListItemText,\n  Paper,\n  Popper,\n  Typography\n} from '@mui/material';\n\n// project imports\nimport MainCard from 'ui-component/cards/MainCard';\nimport Transitions from 'ui-component/extended/Transitions';\nimport User1 from 'assets/images/users/user-round.svg';\nimport useLogin from 'hooks/useLogin';\n\n// assets\nimport { IconLogout, IconSettings, IconUserScan } from '@tabler/icons-react';\n\n// ==============================|| PROFILE MENU ||============================== //\n\nconst ProfileSection = () => {\n  const theme = useTheme();\n  const navigate = useNavigate();\n  const customization = useSelector((state) => state.customization);\n  const { logout } = useLogin();\n\n  const [open, setOpen] = useState(false);\n  /**\n   * anchorRef is used on different componets and specifying one type leads to other components throwing an error\n   * */\n  const anchorRef = useRef(null);\n  const handleLogout = async () => {\n    logout();\n  };\n\n  const handleClose = (event) => {\n    if (anchorRef.current && anchorRef.current.contains(event.target)) {\n      return;\n    }\n    setOpen(false);\n  };\n\n  const handleToggle = () => {\n    setOpen((prevOpen) => !prevOpen);\n  };\n\n  const prevOpen = useRef(open);\n  useEffect(() => {\n    if (prevOpen.current === true && open === false) {\n      anchorRef.current.focus();\n    }\n\n    prevOpen.current = open;\n  }, [open]);\n\n  return (\n    <>\n      <Chip\n        sx={{\n          height: '48px',\n          alignItems: 'center',\n          borderRadius: '27px',\n          transition: 'all .2s ease-in-out',\n          borderColor: theme.typography.menuChip.background,\n          backgroundColor: theme.typography.menuChip.background,\n          '&[aria-controls=\"menu-list-grow\"], &:hover': {\n            borderColor: theme.palette.primary.main,\n            background: `${theme.palette.primary.main}!important`,\n            color: theme.palette.primary.light,\n            '& svg': {\n              stroke: theme.palette.primary.light\n            }\n          },\n          '& .MuiChip-label': {\n            lineHeight: 0\n          }\n        }}\n        icon={\n          <Avatar\n            src={User1}\n            sx={{\n              ...theme.typography.mediumAvatar,\n              margin: '8px 0 8px 8px !important',\n              cursor: 'pointer'\n            }}\n            ref={anchorRef}\n            aria-controls={open ? 'menu-list-grow' : undefined}\n            aria-haspopup=\"true\"\n            color=\"inherit\"\n          />\n        }\n        label={<IconSettings stroke={1.5} size=\"1.5rem\" color={theme.palette.primary.main} />}\n        variant=\"outlined\"\n        ref={anchorRef}\n        aria-controls={open ? 'menu-list-grow' : undefined}\n        aria-haspopup=\"true\"\n        onClick={handleToggle}\n        color=\"primary\"\n      />\n      <Popper\n        placement=\"bottom-end\"\n        open={open}\n        anchorEl={anchorRef.current}\n        role={undefined}\n        transition\n        disablePortal\n        popperOptions={{\n          modifiers: [\n            {\n              name: 'offset',\n              options: {\n                offset: [0, 14]\n              }\n            }\n          ]\n        }}\n      >\n        {({ TransitionProps }) => (\n          <Transitions in={open} {...TransitionProps}>\n            <Paper>\n              <ClickAwayListener onClickAway={handleClose}>\n                <MainCard border={false} elevation={16} content={false} boxShadow shadow={theme.shadows[16]}>\n                  <List\n                    component=\"nav\"\n                    sx={{\n                      width: '100%',\n                      maxWidth: 350,\n                      minWidth: 150,\n                      backgroundColor: theme.palette.background.paper,\n                      borderRadius: '10px',\n                      [theme.breakpoints.down('md')]: {\n                        minWidth: '100%'\n                      },\n                      '& .MuiListItemButton-root': {\n                        mt: 0.5\n                      }\n                    }}\n                  >\n                    <ListItemButton sx={{ borderRadius: `${customization.borderRadius}px` }} onClick={() => navigate('/panel/profile')}>\n                      <ListItemIcon>\n                        <IconUserScan stroke={1.5} size=\"1.3rem\" />\n                      </ListItemIcon>\n                      <ListItemText primary={<Typography variant=\"body2\">设置</Typography>} />\n                    </ListItemButton>\n\n                    <ListItemButton sx={{ borderRadius: `${customization.borderRadius}px` }} onClick={handleLogout}>\n                      <ListItemIcon>\n                        <IconLogout stroke={1.5} size=\"1.3rem\" />\n                      </ListItemIcon>\n                      <ListItemText primary={<Typography variant=\"body2\">登出</Typography>} />\n                    </ListItemButton>\n                  </List>\n                </MainCard>\n              </ClickAwayListener>\n            </Paper>\n          </Transitions>\n        )}\n      </Popper>\n    </>\n  );\n};\n\nexport default ProfileSection;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Header/index.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Avatar, Box, ButtonBase } from '@mui/material';\n\n// project imports\nimport LogoSection from '../LogoSection';\nimport ProfileSection from './ProfileSection';\nimport ThemeButton from 'ui-component/ThemeButton';\n\n// assets\nimport { IconMenu2 } from '@tabler/icons-react';\n\n// ==============================|| MAIN NAVBAR / HEADER ||============================== //\n\nconst Header = ({ handleLeftDrawerToggle }) => {\n  const theme = useTheme();\n\n  return (\n    <>\n      {/* logo & toggler button */}\n      <Box\n        sx={{\n          width: 228,\n          display: 'flex',\n          [theme.breakpoints.down('md')]: {\n            width: 'auto'\n          }\n        }}\n      >\n        <Box component=\"span\" sx={{ display: { xs: 'none', md: 'block' }, flexGrow: 1 }}>\n          <LogoSection />\n        </Box>\n        <ButtonBase sx={{ borderRadius: '12px', overflow: 'hidden' }}>\n          <Avatar\n            variant=\"rounded\"\n            sx={{\n              ...theme.typography.commonAvatar,\n              ...theme.typography.mediumAvatar,\n              ...theme.typography.menuButton,\n              transition: 'all .2s ease-in-out',\n              '&:hover': {\n                background: theme.palette.secondary.dark,\n                color: theme.palette.secondary.light\n              }\n            }}\n            onClick={handleLeftDrawerToggle}\n            color=\"inherit\"\n          >\n            <IconMenu2 stroke={1.5} size=\"1.3rem\" />\n          </Avatar>\n        </ButtonBase>\n      </Box>\n\n      <Box sx={{ flexGrow: 1 }} />\n      <Box sx={{ flexGrow: 1 }} />\n      <ThemeButton />\n      <ProfileSection />\n    </>\n  );\n};\n\nHeader.propTypes = {\n  handleLeftDrawerToggle: PropTypes.func\n};\n\nexport default Header;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/LogoSection/index.js",
    "content": "import { Link } from 'react-router-dom';\nimport { useDispatch, useSelector } from 'react-redux';\n\n// material-ui\nimport { ButtonBase } from '@mui/material';\n\n// project imports\nimport Logo from 'ui-component/Logo';\nimport { MENU_OPEN } from 'store/actions';\n\n// ==============================|| MAIN LOGO ||============================== //\n\nconst LogoSection = () => {\n  const defaultId = useSelector((state) => state.customization.defaultId);\n  const dispatch = useDispatch();\n  return (\n    <ButtonBase disableRipple onClick={() => dispatch({ type: MENU_OPEN, id: defaultId })} component={Link} to=\"/\">\n      <Logo />\n    </ButtonBase>\n  );\n};\n\nexport default LogoSection;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Sidebar/MenuCard/index.js",
    "content": "// import PropTypes from 'prop-types';\nimport { useSelector } from 'react-redux';\n\n// material-ui\nimport { styled, useTheme } from '@mui/material/styles';\nimport {\n  Avatar,\n  Card,\n  CardContent,\n  // Grid,\n  // LinearProgress,\n  List,\n  ListItem,\n  ListItemAvatar,\n  ListItemText,\n  Typography\n  // linearProgressClasses\n} from '@mui/material';\nimport User1 from 'assets/images/users/user-round.svg';\nimport { useNavigate } from 'react-router-dom';\n\n// assets\n// import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined';\n\n// styles\n// const BorderLinearProgress = styled(LinearProgress)(({ theme }) => ({\n//   height: 10,\n//   borderRadius: 30,\n//   [`&.${linearProgressClasses.colorPrimary}`]: {\n//     backgroundColor: '#fff'\n//   },\n//   [`& .${linearProgressClasses.bar}`]: {\n//     borderRadius: 5,\n//     backgroundColor: theme.palette.primary.main\n//   }\n// }));\n\nconst CardStyle = styled(Card)(({ theme }) => ({\n  background: theme.typography.menuChip.background,\n  marginBottom: '22px',\n  overflow: 'hidden',\n  position: 'relative',\n  '&:after': {\n    content: '\"\"',\n    position: 'absolute',\n    width: '157px',\n    height: '157px',\n    background: theme.palette.primary[200],\n    borderRadius: '50%',\n    top: '-105px',\n    right: '-96px'\n  }\n}));\n\n// ==============================|| PROGRESS BAR WITH LABEL ||============================== //\n\n// function LinearProgressWithLabel({ value, ...others }) {\n//   const theme = useTheme();\n\n//   return (\n//     <Grid container direction=\"column\" spacing={1} sx={{ mt: 1.5 }}>\n//       <Grid item>\n//         <Grid container justifyContent=\"space-between\">\n//           <Grid item>\n//             <Typography variant=\"h6\" sx={{ color: theme.palette.primary[800] }}>\n//               Progress\n//             </Typography>\n//           </Grid>\n//           <Grid item>\n//             <Typography variant=\"h6\" color=\"inherit\">{`${Math.round(value)}%`}</Typography>\n//           </Grid>\n//         </Grid>\n//       </Grid>\n//       <Grid item>\n//         <BorderLinearProgress variant=\"determinate\" value={value} {...others} />\n//       </Grid>\n//     </Grid>\n//   );\n// }\n\n// LinearProgressWithLabel.propTypes = {\n//   value: PropTypes.number\n// };\n\n// ==============================|| SIDEBAR MENU Card ||============================== //\n\nconst MenuCard = () => {\n  const theme = useTheme();\n  const account = useSelector((state) => state.account);\n  const navigate = useNavigate();\n\n  return (\n    <CardStyle>\n      <CardContent sx={{ p: 2 }}>\n        <List sx={{ p: 0, m: 0 }}>\n          <ListItem alignItems=\"flex-start\" disableGutters sx={{ p: 0 }}>\n            <ListItemAvatar sx={{ mt: 0 }}>\n              <Avatar\n                variant=\"rounded\"\n                src={User1}\n                sx={{\n                  ...theme.typography.commonAvatar,\n                  ...theme.typography.largeAvatar,\n                  color: theme.palette.primary.main,\n                  border: 'none',\n                  borderColor: theme.palette.primary.main,\n                  background: '#fff',\n                  marginRight: '12px'\n                }}\n                onClick={() => navigate('/panel/profile')}\n              ></Avatar>\n            </ListItemAvatar>\n            <ListItemText\n              sx={{ mt: 0 }}\n              primary={\n                <Typography variant=\"subtitle1\" sx={{ color: theme.palette.primary[800] }}>\n                  {account.user?.username}\n                </Typography>\n              }\n              secondary={<Typography variant=\"caption\"> 欢迎回来 </Typography>}\n            />\n          </ListItem>\n        </List>\n      </CardContent>\n    </CardStyle>\n  );\n};\n\nexport default MenuCard;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Sidebar/MenuList/NavCollapse/index.js",
    "content": "import PropTypes from 'prop-types';\nimport { useEffect, useState } from 'react';\nimport { useSelector } from 'react-redux';\nimport { useLocation, useNavigate } from 'react-router';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Collapse, List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material';\n\n// project imports\nimport NavItem from '../NavItem';\n\n// assets\nimport FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';\nimport { IconChevronDown, IconChevronUp } from '@tabler/icons-react';\n\n// ==============================|| SIDEBAR MENU LIST COLLAPSE ITEMS ||============================== //\n\nconst NavCollapse = ({ menu, level }) => {\n  const theme = useTheme();\n  const customization = useSelector((state) => state.customization);\n  const navigate = useNavigate();\n\n  const [open, setOpen] = useState(false);\n  const [selected, setSelected] = useState(null);\n\n  const handleClick = () => {\n    setOpen(!open);\n    setSelected(!selected ? menu.id : null);\n    if (menu?.id !== 'authentication') {\n      navigate(menu.children[0]?.url);\n    }\n  };\n\n  const { pathname } = useLocation();\n  const checkOpenForParent = (child, id) => {\n    child.forEach((item) => {\n      if (item.url === pathname) {\n        setOpen(true);\n        setSelected(id);\n      }\n    });\n  };\n\n  // menu collapse for sub-levels\n  useEffect(() => {\n    setOpen(false);\n    setSelected(null);\n    if (menu.children) {\n      menu.children.forEach((item) => {\n        if (item.children?.length) {\n          checkOpenForParent(item.children, menu.id);\n        }\n        if (item.url === pathname) {\n          setSelected(menu.id);\n          setOpen(true);\n        }\n      });\n    }\n\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [pathname, menu.children]);\n\n  // menu collapse & item\n  const menus = menu.children?.map((item) => {\n    switch (item.type) {\n      case 'collapse':\n        return <NavCollapse key={item.id} menu={item} level={level + 1} />;\n      case 'item':\n        return <NavItem key={item.id} item={item} level={level + 1} />;\n      default:\n        return (\n          <Typography key={item.id} variant=\"h6\" color=\"error\" align=\"center\">\n            Menu Items Error\n          </Typography>\n        );\n    }\n  });\n\n  const Icon = menu.icon;\n  const menuIcon = menu.icon ? (\n    <Icon strokeWidth={1.5} size=\"1.3rem\" style={{ marginTop: 'auto', marginBottom: 'auto' }} />\n  ) : (\n    <FiberManualRecordIcon\n      sx={{\n        width: selected === menu.id ? 8 : 6,\n        height: selected === menu.id ? 8 : 6\n      }}\n      fontSize={level > 0 ? 'inherit' : 'medium'}\n    />\n  );\n\n  return (\n    <>\n      <ListItemButton\n        sx={{\n          borderRadius: `${customization.borderRadius}px`,\n          mb: 0.5,\n          alignItems: 'flex-start',\n          backgroundColor: level > 1 ? 'transparent !important' : 'inherit',\n          py: level > 1 ? 1 : 1.25,\n          pl: `${level * 24}px`\n        }}\n        selected={selected === menu.id}\n        onClick={handleClick}\n      >\n        <ListItemIcon sx={{ my: 'auto', minWidth: !menu.icon ? 18 : 36 }}>{menuIcon}</ListItemIcon>\n        <ListItemText\n          primary={\n            <Typography variant={selected === menu.id ? 'h5' : 'body1'} color=\"inherit\" sx={{ my: 'auto' }}>\n              {menu.title}\n            </Typography>\n          }\n          secondary={\n            menu.caption && (\n              <Typography variant=\"caption\" sx={{ ...theme.typography.subMenuCaption }} display=\"block\" gutterBottom>\n                {menu.caption}\n              </Typography>\n            )\n          }\n        />\n        {open ? (\n          <IconChevronUp stroke={1.5} size=\"1rem\" style={{ marginTop: 'auto', marginBottom: 'auto' }} />\n        ) : (\n          <IconChevronDown stroke={1.5} size=\"1rem\" style={{ marginTop: 'auto', marginBottom: 'auto' }} />\n        )}\n      </ListItemButton>\n      <Collapse in={open} timeout=\"auto\" unmountOnExit>\n        <List\n          component=\"div\"\n          disablePadding\n          sx={{\n            position: 'relative',\n            '&:after': {\n              content: \"''\",\n              position: 'absolute',\n              left: '32px',\n              top: 0,\n              height: '100%',\n              width: '1px',\n              opacity: 1,\n              background: theme.palette.primary.light\n            }\n          }}\n        >\n          {menus}\n        </List>\n      </Collapse>\n    </>\n  );\n};\n\nNavCollapse.propTypes = {\n  menu: PropTypes.object,\n  level: PropTypes.number\n};\n\nexport default NavCollapse;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Divider, List, Typography } from '@mui/material';\n\n// project imports\nimport NavItem from '../NavItem';\nimport NavCollapse from '../NavCollapse';\n\n// ==============================|| SIDEBAR MENU LIST GROUP ||============================== //\n\nconst NavGroup = ({ item }) => {\n  const theme = useTheme();\n\n  // menu list collapse & items\n  const items = item.children?.map((menu) => {\n    switch (menu.type) {\n      case 'collapse':\n        return <NavCollapse key={menu.id} menu={menu} level={1} />;\n      case 'item':\n        return <NavItem key={menu.id} item={menu} level={1} />;\n      default:\n        return (\n          <Typography key={menu.id} variant=\"h6\" color=\"error\" align=\"center\">\n            Menu Items Error\n          </Typography>\n        );\n    }\n  });\n\n  return (\n    <>\n      <List\n        subheader={\n          item.title && (\n            <Typography variant=\"caption\" sx={{ ...theme.typography.menuCaption }} display=\"block\" gutterBottom>\n              {item.title}\n              {item.caption && (\n                <Typography variant=\"caption\" sx={{ ...theme.typography.subMenuCaption }} display=\"block\" gutterBottom>\n                  {item.caption}\n                </Typography>\n              )}\n            </Typography>\n          )\n        }\n      >\n        {items}\n      </List>\n\n      {/* group divider */}\n      <Divider sx={{ mt: 0.25, mb: 1.25 }} />\n    </>\n  );\n};\n\nNavGroup.propTypes = {\n  item: PropTypes.object\n};\n\nexport default NavGroup;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.js",
    "content": "import PropTypes from 'prop-types';\nimport { forwardRef, useEffect } from 'react';\nimport { Link, useLocation } from 'react-router-dom';\nimport { useDispatch, useSelector } from 'react-redux';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, useMediaQuery } from '@mui/material';\n\n// project imports\nimport { MENU_OPEN, SET_MENU } from 'store/actions';\n\n// assets\nimport FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';\n\n// ==============================|| SIDEBAR MENU LIST ITEMS ||============================== //\n\nconst NavItem = ({ item, level }) => {\n  const theme = useTheme();\n  const dispatch = useDispatch();\n  const { pathname } = useLocation();\n  const customization = useSelector((state) => state.customization);\n  const matchesSM = useMediaQuery(theme.breakpoints.down('lg'));\n\n  const Icon = item.icon;\n  const itemIcon = item?.icon ? (\n    <Icon stroke={1.5} size=\"1.3rem\" />\n  ) : (\n    <FiberManualRecordIcon\n      sx={{\n        width: customization.isOpen.findIndex((id) => id === item?.id) > -1 ? 8 : 6,\n        height: customization.isOpen.findIndex((id) => id === item?.id) > -1 ? 8 : 6\n      }}\n      fontSize={level > 0 ? 'inherit' : 'medium'}\n    />\n  );\n\n  let itemTarget = '_self';\n  if (item.target) {\n    itemTarget = '_blank';\n  }\n\n  let listItemProps = {\n    component: forwardRef((props, ref) => <Link ref={ref} {...props} to={item.url} target={itemTarget} />)\n  };\n  if (item?.external) {\n    listItemProps = { component: 'a', href: item.url, target: itemTarget };\n  }\n\n  const itemHandler = (id) => {\n    dispatch({ type: MENU_OPEN, id });\n    if (matchesSM) dispatch({ type: SET_MENU, opened: false });\n  };\n\n  // active menu item on page load\n  useEffect(() => {\n    const currentIndex = document.location.pathname\n      .toString()\n      .split('/')\n      .findIndex((id) => id === item.id);\n    if (currentIndex > -1) {\n      dispatch({ type: MENU_OPEN, id: item.id });\n    }\n    // eslint-disable-next-line\n  }, [pathname]);\n\n  return (\n    <ListItemButton\n      {...listItemProps}\n      disabled={item.disabled}\n      sx={{\n        borderRadius: `${customization.borderRadius}px`,\n        mb: 0.5,\n        alignItems: 'flex-start',\n        backgroundColor: level > 1 ? 'transparent !important' : 'inherit',\n        py: level > 1 ? 1 : 1.25,\n        pl: `${level * 24}px`\n      }}\n      selected={customization.isOpen.findIndex((id) => id === item.id) > -1}\n      onClick={() => itemHandler(item.id)}\n    >\n      <ListItemIcon sx={{ my: 'auto', minWidth: !item?.icon ? 18 : 36 }}>{itemIcon}</ListItemIcon>\n      <ListItemText\n        primary={\n          <Typography variant={customization.isOpen.findIndex((id) => id === item.id) > -1 ? 'h5' : 'body1'} color=\"inherit\">\n            {item.title}\n          </Typography>\n        }\n        secondary={\n          item.caption && (\n            <Typography variant=\"caption\" sx={{ ...theme.typography.subMenuCaption }} display=\"block\" gutterBottom>\n              {item.caption}\n            </Typography>\n          )\n        }\n      />\n      {item.chip && (\n        <Chip\n          color={item.chip.color}\n          variant={item.chip.variant}\n          size={item.chip.size}\n          label={item.chip.label}\n          avatar={item.chip.avatar && <Avatar>{item.chip.avatar}</Avatar>}\n        />\n      )}\n    </ListItemButton>\n  );\n};\n\nNavItem.propTypes = {\n  item: PropTypes.object,\n  level: PropTypes.number\n};\n\nexport default NavItem;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Sidebar/MenuList/index.js",
    "content": "// material-ui\nimport { Typography } from '@mui/material';\n\n// project imports\nimport NavGroup from './NavGroup';\nimport menuItem from 'menu-items';\nimport { isAdmin } from 'utils/common';\n\n// ==============================|| SIDEBAR MENU LIST ||============================== //\nconst MenuList = () => {\n  const userIsAdmin = isAdmin();\n\n  return (\n    <>\n      {menuItem.items.map((item) => {\n        if (item.type !== 'group') {\n          return (\n            <Typography key={item.id} variant=\"h6\" color=\"error\" align=\"center\">\n              Menu Items Error\n            </Typography>\n          );\n        }\n\n        const filteredChildren = item.children.filter((child) => !child.isAdmin || userIsAdmin);\n\n        if (filteredChildren.length === 0) {\n          return null;\n        }\n\n        return <NavGroup key={item.id} item={{ ...item, children: filteredChildren }} />;\n      })}\n    </>\n  );\n};\n\nexport default MenuList;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/Sidebar/index.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Box, Chip, Drawer, Stack, useMediaQuery } from '@mui/material';\n\n// third-party\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport { BrowserView, MobileView } from 'react-device-detect';\n\n// project imports\nimport MenuList from './MenuList';\nimport LogoSection from '../LogoSection';\nimport MenuCard from './MenuCard';\nimport { drawerWidth } from 'store/constant';\n\n// ==============================|| SIDEBAR DRAWER ||============================== //\n\nconst Sidebar = ({ drawerOpen, drawerToggle, window }) => {\n  const theme = useTheme();\n  const matchUpMd = useMediaQuery(theme.breakpoints.up('md'));\n\n  const drawer = (\n    <>\n      <Box sx={{ display: { xs: 'block', md: 'none' } }}>\n        <Box sx={{ display: 'flex', p: 2, mx: 'auto' }}>\n          <LogoSection />\n        </Box>\n      </Box>\n      <BrowserView>\n        <PerfectScrollbar\n          component=\"div\"\n          style={{\n            height: !matchUpMd ? 'calc(100vh - 56px)' : 'calc(100vh - 88px)',\n            paddingLeft: '16px',\n            paddingRight: '16px'\n          }}\n        >\n          <MenuList />\n          <MenuCard />\n          <Stack direction=\"row\" justifyContent=\"center\" sx={{ mb: 2 }}>\n            <Chip\n              label={process.env.REACT_APP_VERSION || '未知版本号'}\n              disabled\n              chipcolor=\"secondary\"\n              size=\"small\"\n              sx={{ cursor: 'pointer' }}\n            />\n          </Stack>\n        </PerfectScrollbar>\n      </BrowserView>\n      <MobileView>\n        <Box sx={{ px: 2 }}>\n          <MenuList />\n          <MenuCard />\n          <Stack direction=\"row\" justifyContent=\"center\" sx={{ mb: 2 }}>\n            <Chip\n              label={process.env.REACT_APP_VERSION || '未知版本号'}\n              disabled\n              chipcolor=\"secondary\"\n              size=\"small\"\n              sx={{ cursor: 'pointer' }}\n            />\n          </Stack>\n        </Box>\n      </MobileView>\n    </>\n  );\n\n  const container = window !== undefined ? () => window.document.body : undefined;\n\n  return (\n    <Box component=\"nav\" sx={{ flexShrink: { md: 0 }, width: matchUpMd ? drawerWidth : 'auto' }} aria-label=\"mailbox folders\">\n      <Drawer\n        container={container}\n        variant={matchUpMd ? 'persistent' : 'temporary'}\n        anchor=\"left\"\n        open={drawerOpen}\n        onClose={drawerToggle}\n        sx={{\n          '& .MuiDrawer-paper': {\n            width: drawerWidth,\n            background: theme.palette.background.default,\n            color: theme.palette.text.primary,\n            borderRight: 'none',\n            [theme.breakpoints.up('md')]: {\n              top: '88px'\n            }\n          }\n        }}\n        ModalProps={{ keepMounted: true }}\n        color=\"inherit\"\n      >\n        {drawer}\n      </Drawer>\n    </Box>\n  );\n};\n\nSidebar.propTypes = {\n  drawerOpen: PropTypes.bool,\n  drawerToggle: PropTypes.func,\n  window: PropTypes.object\n};\n\nexport default Sidebar;\n"
  },
  {
    "path": "web/berry/src/layout/MainLayout/index.js",
    "content": "import { useDispatch, useSelector } from 'react-redux';\nimport { Outlet } from 'react-router-dom';\nimport AuthGuard from 'utils/route-guard/AuthGuard';\n\n// material-ui\nimport { styled, useTheme } from '@mui/material/styles';\nimport { AppBar, Box, CssBaseline, Toolbar, useMediaQuery } from '@mui/material';\nimport AdminContainer from 'ui-component/AdminContainer';\n\n// project imports\nimport Breadcrumbs from 'ui-component/extended/Breadcrumbs';\nimport Header from './Header';\nimport Sidebar from './Sidebar';\nimport navigation from 'menu-items';\nimport { drawerWidth } from 'store/constant';\nimport { SET_MENU } from 'store/actions';\n\n// assets\nimport { IconChevronRight } from '@tabler/icons-react';\n\n// styles\nconst Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })(({ theme, open }) => ({\n  ...theme.typography.mainContent,\n  borderBottomLeftRadius: 0,\n  borderBottomRightRadius: 0,\n  transition: theme.transitions.create(\n    'margin',\n    open\n      ? {\n          easing: theme.transitions.easing.easeOut,\n          duration: theme.transitions.duration.enteringScreen\n        }\n      : {\n          easing: theme.transitions.easing.sharp,\n          duration: theme.transitions.duration.leavingScreen\n        }\n  ),\n  [theme.breakpoints.up('md')]: {\n    marginLeft: open ? 0 : -(drawerWidth - 20),\n    width: `calc(100% - ${drawerWidth}px)`\n  },\n  [theme.breakpoints.down('md')]: {\n    marginLeft: '20px',\n    width: `calc(100% - ${drawerWidth}px)`,\n    padding: '16px'\n  },\n  [theme.breakpoints.down('sm')]: {\n    marginLeft: '10px',\n    width: `calc(100% - ${drawerWidth}px)`,\n    padding: '16px',\n    marginRight: '10px'\n  }\n}));\n\n// ==============================|| MAIN LAYOUT ||============================== //\n\nconst MainLayout = () => {\n  const theme = useTheme();\n  const matchDownMd = useMediaQuery(theme.breakpoints.down('md'));\n  // Handle left drawer\n  const leftDrawerOpened = useSelector((state) => state.customization.opened);\n  const dispatch = useDispatch();\n  const handleLeftDrawerToggle = () => {\n    dispatch({ type: SET_MENU, opened: !leftDrawerOpened });\n  };\n\n  return (\n    <Box sx={{ display: 'flex' }}>\n      <CssBaseline />\n      {/* header */}\n      <AppBar\n        enableColorOnDark\n        position=\"fixed\"\n        color=\"inherit\"\n        elevation={0}\n        sx={{\n          bgcolor: theme.palette.background.default,\n          transition: leftDrawerOpened ? theme.transitions.create('width') : 'none'\n        }}\n      >\n        <Toolbar>\n          <Header handleLeftDrawerToggle={handleLeftDrawerToggle} />\n        </Toolbar>\n      </AppBar>\n\n      {/* drawer */}\n      <Sidebar drawerOpen={!matchDownMd ? leftDrawerOpened : !leftDrawerOpened} drawerToggle={handleLeftDrawerToggle} />\n\n      {/* main content */}\n      <Main theme={theme} open={leftDrawerOpened}>\n        {/* breadcrumb */}\n        <Breadcrumbs separator={IconChevronRight} navigation={navigation} icon title rightAlign />\n        <AuthGuard>\n          <AdminContainer>\n            <Outlet />\n          </AdminContainer>\n        </AuthGuard>\n      </Main>\n    </Box>\n  );\n};\n\nexport default MainLayout;\n"
  },
  {
    "path": "web/berry/src/layout/MinimalLayout/Header/index.js",
    "content": "// material-ui\nimport { useState } from 'react';\nimport { useTheme } from '@mui/material/styles';\nimport {\n  Box,\n  Button,\n  Stack,\n  Popper,\n  IconButton,\n  List,\n  ListItemButton,\n  Paper,\n  ListItemText,\n  Typography,\n  Divider,\n  ClickAwayListener\n} from '@mui/material';\nimport LogoSection from 'layout/MainLayout/LogoSection';\nimport { Link } from 'react-router-dom';\nimport { useLocation } from 'react-router-dom';\nimport { useSelector } from 'react-redux';\nimport ThemeButton from 'ui-component/ThemeButton';\nimport ProfileSection from 'layout/MainLayout/Header/ProfileSection';\nimport { IconMenu2 } from '@tabler/icons-react';\nimport Transitions from 'ui-component/extended/Transitions';\nimport MainCard from 'ui-component/cards/MainCard';\nimport { useMediaQuery } from '@mui/material';\n\n// ==============================|| MAIN NAVBAR / HEADER ||============================== //\n\nconst Header = () => {\n  const theme = useTheme();\n  const { pathname } = useLocation();\n  const account = useSelector((state) => state.account);\n  const [open, setOpen] = useState(null);\n  const isMobile = useMediaQuery(theme.breakpoints.down('sm'));\n\n  const handleOpenMenu = (event) => {\n    setOpen(open ? null : event.currentTarget);\n  };\n\n  const handleCloseMenu = () => {\n    setOpen(null);\n  };\n\n  return (\n    <>\n      <Box\n        sx={{\n          width: 228,\n          display: 'flex',\n          [theme.breakpoints.down('md')]: {\n            width: 'auto'\n          }\n        }}\n      >\n        <Box component=\"span\" sx={{ flexGrow: 1 }}>\n          <LogoSection />\n        </Box>\n      </Box>\n\n      <Box sx={{ flexGrow: 1 }} />\n      <Box sx={{ flexGrow: 1 }} />\n      <Stack spacing={2} direction=\"row\" justifyContent=\"center\" alignItems=\"center\">\n        {isMobile ? (\n          <>\n            <ThemeButton />\n            <IconButton onClick={handleOpenMenu}>\n              <IconMenu2 />\n            </IconButton>\n          </>\n        ) : (\n          <>\n            <Button component={Link} variant=\"text\" to=\"/\" color={pathname === '/' ? 'primary' : 'inherit'}>\n              首页\n            </Button>\n            <Button component={Link} variant=\"text\" to=\"/about\" color={pathname === '/about' ? 'primary' : 'inherit'}>\n              关于\n            </Button>\n            <ThemeButton />\n            {account.user ? (\n              <>\n                <Button component={Link} variant=\"contained\" to=\"/panel\" color=\"primary\">\n                  控制台\n                </Button>\n                <ProfileSection />\n              </>\n            ) : (\n              <Button component={Link} variant=\"contained\" to=\"/login\" color=\"primary\">\n                登录\n              </Button>\n            )}\n          </>\n        )}\n      </Stack>\n\n      <Popper\n        open={!!open}\n        anchorEl={open}\n        transition\n        disablePortal\n        popperOptions={{\n          modifiers: [\n            {\n              name: 'offset',\n              options: {\n                offset: [0, 14]\n              }\n            }\n          ]\n        }}\n        style={{ width: '100vw' }}\n      >\n        {({ TransitionProps }) => (\n          <Transitions in={open} {...TransitionProps}>\n            <ClickAwayListener onClickAway={handleCloseMenu}>\n              <Paper style={{ width: '100%' }}>\n                <MainCard border={false} elevation={16} content={false} boxShadow shadow={theme.shadows[16]}>\n                  <List\n                    component=\"nav\"\n                    sx={{\n                      width: '100%',\n                      maxWidth: '100%',\n                      minWidth: '100%',\n                      backgroundColor: theme.palette.background.paper,\n\n                      '& .MuiListItemButton-root': {\n                        mt: 0.5\n                      }\n                    }}\n                    onClick={handleCloseMenu}\n                  >\n                    <ListItemButton component={Link} variant=\"text\" to=\"/\">\n                      <ListItemText primary={<Typography variant=\"body2\">首页</Typography>} />\n                    </ListItemButton>\n\n                    <ListItemButton component={Link} variant=\"text\" to=\"/about\">\n                      <ListItemText primary={<Typography variant=\"body2\">关于</Typography>} />\n                    </ListItemButton>\n                    <Divider />\n                    {account.user ? (\n                      <ListItemButton component={Link} variant=\"contained\" to=\"/panel\" color=\"primary\">\n                        控制台\n                      </ListItemButton>\n                    ) : (\n                      <ListItemButton component={Link} variant=\"contained\" to=\"/login\" color=\"primary\">\n                        登录\n                      </ListItemButton>\n                    )}\n                  </List>\n                </MainCard>\n              </Paper>\n            </ClickAwayListener>\n          </Transitions>\n        )}\n      </Popper>\n    </>\n  );\n};\n\nexport default Header;\n"
  },
  {
    "path": "web/berry/src/layout/MinimalLayout/index.js",
    "content": "import { Outlet } from 'react-router-dom';\nimport { useTheme } from '@mui/material/styles';\nimport { AppBar, Box, CssBaseline, Toolbar, Container } from '@mui/material';\nimport Header from './Header';\nimport Footer from 'ui-component/Footer';\n\n// ==============================|| MINIMAL LAYOUT ||============================== //\n\nconst MinimalLayout = () => {\n  const theme = useTheme();\n\n  return (\n    <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>\n      <CssBaseline />\n      <AppBar\n        enableColorOnDark\n        position=\"fixed\"\n        color=\"inherit\"\n        elevation={0}\n        sx={{\n          bgcolor: theme.palette.background.default,\n          flex: 'none'\n        }}\n      >\n        <Container>\n          <Toolbar>\n            <Header />\n          </Toolbar>\n        </Container>\n      </AppBar>\n      <Box sx={{ flex: '1 1 auto', overflow: 'auto' }} marginTop={'80px'}>\n        <Outlet />\n      </Box>\n      <Box sx={{ flex: 'none' }}>\n        <Footer />\n      </Box>\n    </Box>\n  );\n};\n\nexport default MinimalLayout;\n"
  },
  {
    "path": "web/berry/src/layout/NavMotion.js",
    "content": "import PropTypes from 'prop-types';\nimport { motion } from 'framer-motion';\n\n// ==============================|| ANIMATION FOR CONTENT ||============================== //\n\nconst NavMotion = ({ children }) => {\n  const motionVariants = {\n    initial: {\n      opacity: 0,\n      scale: 0.99\n    },\n    in: {\n      opacity: 1,\n      scale: 1\n    },\n    out: {\n      opacity: 0,\n      scale: 1.01\n    }\n  };\n\n  const motionTransition = {\n    type: 'tween',\n    ease: 'anticipate',\n    duration: 0.4\n  };\n\n  return (\n    <motion.div initial=\"initial\" animate=\"in\" exit=\"out\" variants={motionVariants} transition={motionTransition}>\n      {children}\n    </motion.div>\n  );\n};\n\nNavMotion.propTypes = {\n  children: PropTypes.node\n};\n\nexport default NavMotion;\n"
  },
  {
    "path": "web/berry/src/layout/NavigationScroll.js",
    "content": "import PropTypes from 'prop-types';\nimport { useEffect } from 'react';\nimport { useLocation } from 'react-router-dom';\n\n// ==============================|| NAVIGATION SCROLL TO TOP ||============================== //\n\nconst NavigationScroll = ({ children }) => {\n  const location = useLocation();\n  const { pathname } = location;\n\n  useEffect(() => {\n    window.scrollTo({\n      top: 0,\n      left: 0,\n      behavior: 'smooth'\n    });\n  }, [pathname]);\n\n  return children || null;\n};\n\nNavigationScroll.propTypes = {\n  children: PropTypes.node\n};\n\nexport default NavigationScroll;\n"
  },
  {
    "path": "web/berry/src/menu-items/index.js",
    "content": "import panel from './panel';\n\n// ==============================|| MENU ITEMS ||============================== //\n\nconst menuItems = {\n  items: [panel],\n  urlMap: {}\n};\n\n// Initialize urlMap\nmenuItems.urlMap = menuItems.items.reduce((map, item) => {\n  item.children.forEach((child) => {\n    map[child.url] = child;\n  });\n  return map;\n}, {});\n\nexport default menuItems;\n"
  },
  {
    "path": "web/berry/src/menu-items/panel.js",
    "content": "// assets\nimport {\n  IconDashboard,\n  IconSitemap,\n  IconArticle,\n  IconCoin,\n  IconAdjustments,\n  IconKey,\n  IconGardenCart,\n  IconUser,\n  IconUserScan\n} from '@tabler/icons-react';\n\n// constant\nconst icons = { IconDashboard, IconSitemap, IconArticle, IconCoin, IconAdjustments, IconKey, IconGardenCart, IconUser, IconUserScan };\n\n// ==============================|| DASHBOARD MENU ITEMS ||============================== //\n\nconst panel = {\n  id: 'panel',\n  type: 'group',\n  children: [\n    {\n      id: 'dashboard',\n      title: '总览',\n      type: 'item',\n      url: '/panel/dashboard',\n      icon: icons.IconDashboard,\n      breadcrumbs: false,\n      isAdmin: false\n    },\n    {\n      id: 'channel',\n      title: '渠道',\n      type: 'item',\n      url: '/panel/channel',\n      icon: icons.IconSitemap,\n      breadcrumbs: false,\n      isAdmin: true\n    },\n    {\n      id: 'token',\n      title: '令牌',\n      type: 'item',\n      url: '/panel/token',\n      icon: icons.IconKey,\n      breadcrumbs: false\n    },\n    {\n      id: 'log',\n      title: '日志',\n      type: 'item',\n      url: '/panel/log',\n      icon: icons.IconArticle,\n      breadcrumbs: false\n    },\n    {\n      id: 'redemption',\n      title: '兑换',\n      type: 'item',\n      url: '/panel/redemption',\n      icon: icons.IconCoin,\n      breadcrumbs: false,\n      isAdmin: true\n    },\n    {\n      id: 'topup',\n      title: '充值',\n      type: 'item',\n      url: '/panel/topup',\n      icon: icons.IconGardenCart,\n      breadcrumbs: false\n    },\n    {\n      id: 'user',\n      title: '用户',\n      type: 'item',\n      url: '/panel/user',\n      icon: icons.IconUser,\n      breadcrumbs: false,\n      isAdmin: true\n    },\n    {\n      id: 'profile',\n      title: '我的',\n      type: 'item',\n      url: '/panel/profile',\n      icon: icons.IconUserScan,\n      breadcrumbs: false,\n      isAdmin: false\n    },\n    {\n      id: 'setting',\n      title: '设置',\n      type: 'item',\n      url: '/panel/setting',\n      icon: icons.IconAdjustments,\n      breadcrumbs: false,\n      isAdmin: true\n    }\n  ]\n};\n\nexport default panel;\n"
  },
  {
    "path": "web/berry/src/routes/MainRoutes.js",
    "content": "import { lazy } from 'react';\n\n// project imports\nimport MainLayout from 'layout/MainLayout';\nimport Loadable from 'ui-component/Loadable';\n\nconst Channel = Loadable(lazy(() => import('views/Channel')));\nconst Log = Loadable(lazy(() => import('views/Log')));\nconst Redemption = Loadable(lazy(() => import('views/Redemption')));\nconst Setting = Loadable(lazy(() => import('views/Setting')));\nconst Token = Loadable(lazy(() => import('views/Token')));\nconst Topup = Loadable(lazy(() => import('views/Topup')));\nconst User = Loadable(lazy(() => import('views/User')));\nconst Profile = Loadable(lazy(() => import('views/Profile')));\nconst NotFoundView = Loadable(lazy(() => import('views/Error')));\n\n// dashboard routing\nconst Dashboard = Loadable(lazy(() => import('views/Dashboard')));\n\n// ==============================|| MAIN ROUTING ||============================== //\n\nconst MainRoutes = {\n  path: '/panel',\n  element: <MainLayout />,\n  children: [\n    {\n      path: '',\n      element: <Dashboard />\n    },\n    {\n      path: 'dashboard',\n      element: <Dashboard />\n    },\n    {\n      path: 'channel',\n      element: <Channel />\n    },\n    {\n      path: 'log',\n      element: <Log />\n    },\n    {\n      path: 'redemption',\n      element: <Redemption />\n    },\n    {\n      path: 'setting',\n      element: <Setting />\n    },\n    {\n      path: 'token',\n      element: <Token />\n    },\n    {\n      path: 'topup',\n      element: <Topup />\n    },\n    {\n      path: 'user',\n      element: <User />\n    },\n    {\n      path: 'profile',\n      element: <Profile />\n    },\n    {\n      path: '404',\n      element: <NotFoundView />\n    }\n  ]\n};\n\nexport default MainRoutes;\n"
  },
  {
    "path": "web/berry/src/routes/OtherRoutes.js",
    "content": "import { lazy } from 'react';\n\n// project imports\nimport Loadable from 'ui-component/Loadable';\nimport MinimalLayout from 'layout/MinimalLayout';\n\n// login option 3 routing\nconst AuthLogin = Loadable(lazy(() => import('views/Authentication/Auth/Login')));\nconst AuthRegister = Loadable(lazy(() => import('views/Authentication/Auth/Register')));\nconst GitHubOAuth = Loadable(lazy(() => import('views/Authentication/Auth/GitHubOAuth')));\nconst LarkOAuth = Loadable(lazy(() => import('views/Authentication/Auth/LarkOAuth')));\nconst OidcOAuth = Loadable(lazy(() => import('views/Authentication/Auth/OidcOAuth')));\nconst ForgetPassword = Loadable(lazy(() => import('views/Authentication/Auth/ForgetPassword')));\nconst ResetPassword = Loadable(lazy(() => import('views/Authentication/Auth/ResetPassword')));\nconst Home = Loadable(lazy(() => import('views/Home')));\nconst About = Loadable(lazy(() => import('views/About')));\nconst NotFoundView = Loadable(lazy(() => import('views/Error')));\n\n// ==============================|| AUTHENTICATION ROUTING ||============================== //\n\nconst OtherRoutes = {\n  path: '/',\n  element: <MinimalLayout />,\n  children: [\n    {\n      path: '',\n      element: <Home />\n    },\n    {\n      path: '/about',\n      element: <About />\n    },\n    {\n      path: '/login',\n      element: <AuthLogin />\n    },\n    {\n      path: '/register',\n      element: <AuthRegister />\n    },\n    {\n      path: '/reset',\n      element: <ForgetPassword />\n    },\n    {\n      path: '/user/reset',\n      element: <ResetPassword />\n    },\n    {\n      path: '/oauth/github',\n      element: <GitHubOAuth />\n    },\n    {\n      path: '/oauth/lark',\n      element: <LarkOAuth />\n    },\n    {\n      path: 'oauth/oidc',\n      element: <OidcOAuth />\n    },\n    {\n      path: '/404',\n      element: <NotFoundView />\n    }\n  ]\n};\n\nexport default OtherRoutes;\n"
  },
  {
    "path": "web/berry/src/routes/index.js",
    "content": "import { useRoutes } from 'react-router-dom';\n\n// routes\nimport MainRoutes from './MainRoutes';\nimport OtherRoutes from './OtherRoutes';\n\n// ==============================|| ROUTING RENDER ||============================== //\n\nexport default function ThemeRoutes() {\n  return useRoutes([MainRoutes, OtherRoutes]);\n}\n"
  },
  {
    "path": "web/berry/src/serviceWorker.js",
    "content": "// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the app load faster on subsequent visits in production, and gives\n// it offline capabilities. However, it also means that developers (and users)\n// will only see deployed updates on subsequent visits to a page, after all the\n// existing tabs open on the page have been closed, since previously cached\n// resources are updated in the background.\n\n// To learn more about the benefits of this model and instructions on how to\n// opt-in, read https://bit.ly/CRA-PWA\n\nconst isLocalhost = Boolean(\n  window.location.hostname === 'localhost' ||\n    // [::1] is the IPv6 localhost address.\n    window.location.hostname === '[::1]' ||\n    // 127.0.0.0/8 are considered localhost for IPv4.\n    window.location.hostname.match(/^127(?:\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)\n);\n\nfunction registerValidSW(swUrl, config) {\n  navigator.serviceWorker\n    .register(swUrl)\n    .then((registration) => {\n      registration.onupdatefound = () => {\n        const installingWorker = registration.installing;\n        if (installingWorker == null) {\n          return;\n        }\n        installingWorker.onstatechange = () => {\n          if (installingWorker.state === 'installed') {\n            if (navigator.serviceWorker.controller) {\n              // At this point, the updated precached content has been fetched,\n              // but the previous service worker will still serve the older\n              // content until all client tabs are closed.\n              console.log('New content is available and will be used when all tabs for this page are closed. See https://bit.ly/CRA-PWA.');\n\n              // Execute callback\n              if (config && config.onUpdate) {\n                config.onUpdate(registration);\n              }\n            } else {\n              // At this point, everything has been precached.\n              // It's the perfect time to display a\n              // \"Content is cached for offline use.\" message.\n              console.log('Content is cached for offline use.');\n\n              // Execute callback\n              if (config && config.onSuccess) {\n                config.onSuccess(registration);\n              }\n            }\n          }\n        };\n      };\n    })\n    .catch((error) => {\n      console.error('Error during service worker registration:', error);\n    });\n}\n\nfunction checkValidServiceWorker(swUrl, config) {\n  // Check if the service worker can be found. If it can't reload the page.\n  fetch(swUrl, {\n    headers: { 'Service-Worker': 'script' }\n  })\n    .then((response) => {\n      // Ensure service worker exists, and that we really are getting a JS file.\n      const contentType = response.headers.get('content-type');\n      if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {\n        // No service worker found. Probably a different app. Reload the page.\n        navigator.serviceWorker.ready.then((registration) => {\n          registration.unregister().then(() => {\n            window.location.reload();\n          });\n        });\n      } else {\n        // Service worker found. Proceed as normal.\n        registerValidSW(swUrl, config);\n      }\n    })\n    .catch(() => {\n      console.log('No internet connection found. App is running in offline mode.');\n    });\n}\n\nexport function register(config) {\n  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {\n    // The URL constructor is available in all browsers that support SW.\n    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);\n    if (publicUrl.origin !== window.location.origin) {\n      // Our service worker won't work if PUBLIC_URL is on a different origin\n      // from what our page is served on. This might happen if a CDN is used to\n      // serve assets; see https://github.com/facebook/create-react-app/issues/2374\n      return;\n    }\n\n    window.addEventListener('load', () => {\n      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;\n\n      if (isLocalhost) {\n        // This is running on localhost. Let's check if a service worker still exists or not.\n        checkValidServiceWorker(swUrl, config);\n\n        // Add some additional logging to localhost, pointing developers to the\n        // service worker/PWA documentation.\n        navigator.serviceWorker.ready.then(() => {\n          console.log('This web app is being served cache-first by a service worker. To learn more, visit https://bit.ly/CRA-PWA');\n        });\n      } else {\n        // Is not localhost. Just register service worker\n        registerValidSW(swUrl, config);\n      }\n    });\n  }\n}\n\nexport function unregister() {\n  if ('serviceWorker' in navigator) {\n    navigator.serviceWorker.ready\n      .then((registration) => {\n        registration.unregister();\n      })\n      .catch((error) => {\n        console.error(error.message);\n      });\n  }\n}\n"
  },
  {
    "path": "web/berry/src/store/accountReducer.js",
    "content": "import * as actionTypes from './actions';\n\nexport const initialState = {\n  user: undefined\n};\n\nconst accountReducer = (state = initialState, action) => {\n  switch (action.type) {\n    case actionTypes.LOGIN:\n      return {\n        ...state,\n        user: action.payload\n      };\n    case actionTypes.LOGOUT:\n      return {\n        ...state,\n        user: undefined\n      };\n    default:\n      return state;\n  }\n};\n\nexport default accountReducer;\n"
  },
  {
    "path": "web/berry/src/store/actions.js",
    "content": "// action - customization reducer\nexport const SET_MENU = '@customization/SET_MENU';\nexport const MENU_TOGGLE = '@customization/MENU_TOGGLE';\nexport const MENU_OPEN = '@customization/MENU_OPEN';\nexport const SET_FONT_FAMILY = '@customization/SET_FONT_FAMILY';\nexport const SET_BORDER_RADIUS = '@customization/SET_BORDER_RADIUS';\nexport const SET_SITE_INFO = '@siteInfo/SET_SITE_INFO';\nexport const LOGIN = '@account/LOGIN';\nexport const LOGOUT = '@account/LOGOUT';\nexport const SET_THEME = '@customization/SET_THEME';\n"
  },
  {
    "path": "web/berry/src/store/constant.js",
    "content": "// theme constant\nexport const gridSpacing = 3;\nexport const drawerWidth = 260;\nexport const appDrawerWidth = 320;\n"
  },
  {
    "path": "web/berry/src/store/customizationReducer.js",
    "content": "// project imports\nimport config from 'config';\n\n// action - state management\nimport * as actionTypes from './actions';\n\nexport const initialState = {\n  isOpen: [], // for active default menu\n  defaultId: 'default',\n  fontFamily: config.fontFamily,\n  borderRadius: config.borderRadius,\n  opened: true,\n  theme: 'light'\n};\n\n// ==============================|| CUSTOMIZATION REDUCER ||============================== //\n\nconst customizationReducer = (state = initialState, action) => {\n  let id;\n  switch (action.type) {\n    case actionTypes.MENU_OPEN:\n      id = action.id;\n      return {\n        ...state,\n        isOpen: [id]\n      };\n    case actionTypes.SET_MENU:\n      return {\n        ...state,\n        opened: action.opened\n      };\n    case actionTypes.SET_FONT_FAMILY:\n      return {\n        ...state,\n        fontFamily: action.fontFamily\n      };\n    case actionTypes.SET_BORDER_RADIUS:\n      return {\n        ...state,\n        borderRadius: action.borderRadius\n      };\n    case actionTypes.SET_THEME:\n      return {\n        ...state,\n        theme: action.theme\n      };\n    default:\n      return state;\n  }\n};\n\nexport default customizationReducer;\n"
  },
  {
    "path": "web/berry/src/store/index.js",
    "content": "import { createStore } from 'redux';\nimport reducer from './reducer';\n\n// ==============================|| REDUX - MAIN STORE ||============================== //\n\nconst store = createStore(reducer);\nconst persister = 'Free';\n\nexport { store, persister };\n"
  },
  {
    "path": "web/berry/src/store/reducer.js",
    "content": "import { combineReducers } from 'redux';\n\n// reducer import\nimport customizationReducer from './customizationReducer';\nimport accountReducer from './accountReducer';\nimport siteInfoReducer from './siteInfoReducer';\n\n// ==============================|| COMBINE REDUCER ||============================== //\n\nconst reducer = combineReducers({\n  customization: customizationReducer,\n  account: accountReducer,\n  siteInfo: siteInfoReducer\n});\n\nexport default reducer;\n"
  },
  {
    "path": "web/berry/src/store/siteInfoReducer.js",
    "content": "import config from 'config';\nimport * as actionTypes from './actions';\n\nexport const initialState = config.siteInfo;\n\nconst siteInfoReducer = (state = initialState, action) => {\n  switch (action.type) {\n    case actionTypes.SET_SITE_INFO:\n      return {\n        ...state,\n        ...action.payload\n      };\n    default:\n      return state;\n  }\n};\n\nexport default siteInfoReducer;\n"
  },
  {
    "path": "web/berry/src/themes/compStyleOverride.js",
    "content": "export default function componentStyleOverrides(theme) {\n  const bgColor = theme.mode === 'dark' ? theme.backgroundDefault : theme.colors?.grey50;\n  return {\n    MuiButton: {\n      styleOverrides: {\n        root: {\n          fontWeight: 500,\n          borderRadius: '4px',\n          '&.Mui-disabled': {\n            color: theme.colors?.grey600\n          }\n        }\n      }\n    },\n    //MuiAutocomplete-popper MuiPopover-root\n    MuiAutocomplete: {\n      styleOverrides: {\n        popper: {\n          // 继承 MuiPopover-root\n          boxShadow: '0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)',\n          borderRadius: '12px',\n          color: '#364152'\n        },\n        listbox: {\n          // 继承 MuiPopover-root\n          padding: '0px',\n          paddingTop: '8px',\n          paddingBottom: '8px'\n        },\n        option: {\n          fontSize: '16px',\n          fontWeight: '400',\n          lineHeight: '1.334em',\n          alignItems: 'center',\n          paddingTop: '6px',\n          paddingBottom: '6px',\n          paddingLeft: '16px',\n          paddingRight: '16px'\n        }\n      }\n    },\n    MuiIconButton: {\n      styleOverrides: {\n        root: {\n          color: theme.darkTextPrimary,\n          '&:hover': {\n            backgroundColor: theme.colors?.grey200\n          }\n        }\n      }\n    },\n    MuiPaper: {\n      defaultProps: {\n        elevation: 0\n      },\n      styleOverrides: {\n        root: {\n          backgroundImage: 'none'\n        },\n        rounded: {\n          borderRadius: `${theme?.customization?.borderRadius}px`\n        }\n      }\n    },\n    MuiCardHeader: {\n      styleOverrides: {\n        root: {\n          color: theme.colors?.textDark,\n          padding: '24px'\n        },\n        title: {\n          fontSize: '1.125rem'\n        }\n      }\n    },\n    MuiCardContent: {\n      styleOverrides: {\n        root: {\n          padding: '24px'\n        }\n      }\n    },\n    MuiCardActions: {\n      styleOverrides: {\n        root: {\n          padding: '24px'\n        }\n      }\n    },\n    MuiListItemButton: {\n      styleOverrides: {\n        root: {\n          color: theme.darkTextPrimary,\n          paddingTop: '10px',\n          paddingBottom: '10px',\n          '&.Mui-selected': {\n            color: theme.menuSelected,\n            backgroundColor: theme.menuSelectedBack,\n            '&:hover': {\n              backgroundColor: theme.menuSelectedBack\n            },\n            '& .MuiListItemIcon-root': {\n              color: theme.menuSelected\n            }\n          },\n          '&:hover': {\n            backgroundColor: theme.menuSelectedBack,\n            color: theme.menuSelected,\n            '& .MuiListItemIcon-root': {\n              color: theme.menuSelected\n            }\n          }\n        }\n      }\n    },\n    MuiListItemIcon: {\n      styleOverrides: {\n        root: {\n          color: theme.darkTextPrimary,\n          minWidth: '36px'\n        }\n      }\n    },\n    MuiListItemText: {\n      styleOverrides: {\n        primary: {\n          color: theme.textDark\n        }\n      }\n    },\n    MuiInputBase: {\n      styleOverrides: {\n        input: {\n          color: theme.textDark,\n          '&::placeholder': {\n            color: theme.darkTextSecondary,\n            fontSize: '0.875rem'\n          }\n        }\n      }\n    },\n    MuiOutlinedInput: {\n      styleOverrides: {\n        root: {\n          background: bgColor,\n          borderRadius: `${theme?.customization?.borderRadius}px`,\n          '& .MuiOutlinedInput-notchedOutline': {\n            borderColor: theme.colors?.grey400\n          },\n          '&:hover $notchedOutline': {\n            borderColor: theme.colors?.primaryLight\n          },\n          '&.MuiInputBase-multiline': {\n            padding: 1\n          }\n        },\n        input: {\n          fontWeight: 500,\n          background: bgColor,\n          padding: '15.5px 14px',\n          borderRadius: `${theme?.customization?.borderRadius}px`,\n          '&.MuiInputBase-inputSizeSmall': {\n            padding: '10px 14px',\n            '&.MuiInputBase-inputAdornedStart': {\n              paddingLeft: 0\n            }\n          }\n        },\n        inputAdornedStart: {\n          paddingLeft: 4\n        },\n        notchedOutline: {\n          borderRadius: `${theme?.customization?.borderRadius}px`\n        }\n      }\n    },\n    MuiSlider: {\n      styleOverrides: {\n        root: {\n          '&.Mui-disabled': {\n            color: theme.colors?.grey300\n          }\n        },\n        mark: {\n          backgroundColor: theme.paper,\n          width: '4px'\n        },\n        valueLabel: {\n          color: theme?.colors?.primaryLight\n        }\n      }\n    },\n    MuiDivider: {\n      styleOverrides: {\n        root: {\n          borderColor: theme.divider,\n          opacity: 1\n        }\n      }\n    },\n    MuiAvatar: {\n      styleOverrides: {\n        root: {\n          color: theme.colors?.primaryDark,\n          background: theme.colors?.primary200\n        }\n      }\n    },\n    MuiChip: {\n      styleOverrides: {\n        root: {\n          '&.MuiChip-deletable .MuiChip-deleteIcon': {\n            color: 'inherit'\n          }\n        }\n      }\n    },\n    MuiTableCell: {\n      styleOverrides: {\n        root: {\n          borderBottom: '1px solid ' + theme.tableBorderBottom,\n          textAlign: 'center'\n        },\n        head: {\n          color: theme.darkTextSecondary,\n          backgroundColor: theme.headBackgroundColor\n        }\n      }\n    },\n    MuiTableRow: {\n      styleOverrides: {\n        root: {\n          '&:hover': {\n            backgroundColor: theme.headBackgroundColor\n          }\n        }\n      }\n    },\n    MuiTooltip: {\n      styleOverrides: {\n        tooltip: {\n          color: theme.colors.paper,\n          background: theme.colors?.grey700\n        }\n      }\n    },\n    MuiCssBaseline: {\n      styleOverrides: `\n      .apexcharts-title-text {\n          fill: ${theme.textDark} !important\n        }\n      .apexcharts-text {\n        fill: ${theme.textDark} !important\n      }\n      .apexcharts-legend-text {\n        color: ${theme.textDark} !important\n      }\n      .apexcharts-menu {\n        background: ${theme.backgroundDefault} !important\n      }\n      .apexcharts-gridline, .apexcharts-xaxistooltip-background, .apexcharts-yaxistooltip-background {\n        stroke: ${theme.divider} !important;\n      }\n      `\n    }\n  };\n}\n"
  },
  {
    "path": "web/berry/src/themes/index.js",
    "content": "import { createTheme } from '@mui/material/styles';\n\n// assets\nimport colors from 'assets/scss/_themes-vars.module.scss';\n\n// project imports\nimport componentStyleOverrides from './compStyleOverride';\nimport themePalette from './palette';\nimport themeTypography from './typography';\n\n/**\n * Represent theme style and structure as per Material-UI\n * @param {JsonObject} customization customization parameter object\n */\n\nexport const theme = (customization) => {\n  const color = colors;\n  const options = customization.theme === 'light' ? GetLightOption() : GetDarkOption();\n  const themeOption = {\n    colors: color,\n    ...options,\n    customization\n  };\n\n  const themeOptions = {\n    direction: 'ltr',\n    palette: themePalette(themeOption),\n    mixins: {\n      toolbar: {\n        minHeight: '48px',\n        padding: '16px',\n        '@media (min-width: 600px)': {\n          minHeight: '48px'\n        }\n      }\n    },\n    typography: themeTypography(themeOption)\n  };\n\n  const themes = createTheme(themeOptions);\n  themes.components = componentStyleOverrides(themeOption);\n\n  return themes;\n};\n\nexport default theme;\n\nfunction GetDarkOption() {\n  const color = colors;\n  return {\n    mode: 'dark',\n    heading: color.darkTextTitle,\n    paper: color.darkLevel2,\n    backgroundDefault: color.darkPaper,\n    background: color.darkBackground,\n    darkTextPrimary: color.darkTextPrimary,\n    darkTextSecondary: color.darkTextSecondary,\n    textDark: color.darkTextTitle,\n    menuSelected: color.darkSecondaryMain,\n    menuSelectedBack: color.darkSelectedBack,\n    divider: color.darkDivider,\n    borderColor: color.darkBorderColor,\n    menuButton: color.darkLevel1,\n    menuButtonColor: color.darkSecondaryMain,\n    menuChip: color.darkLevel1,\n    headBackgroundColor: color.darkBackground,\n    tableBorderBottom: color.darkDivider\n  };\n}\n\nfunction GetLightOption() {\n  const color = colors;\n  return {\n    mode: 'light',\n    heading: color.grey900,\n    paper: color.paper,\n    backgroundDefault: color.paper,\n    background: color.primaryLight,\n    darkTextPrimary: color.grey700,\n    darkTextSecondary: color.grey500,\n    textDark: color.grey900,\n    menuSelected: color.secondaryDark,\n    menuSelectedBack: color.secondaryLight,\n    divider: color.grey200,\n    borderColor: color.grey300,\n    menuButton: color.secondaryLight,\n    menuButtonColor: color.secondaryDark,\n    menuChip: color.primaryLight,\n    headBackgroundColor: color.tableBackground,\n    tableBorderBottom: color.tableBorderBottom\n  };\n}\n"
  },
  {
    "path": "web/berry/src/themes/palette.js",
    "content": "/**\n * Color intention that you want to used in your theme\n * @param {JsonObject} theme Theme customization object\n */\n\nexport default function themePalette(theme) {\n  return {\n    mode: theme.mode,\n    common: {\n      black: theme.colors?.darkPaper\n    },\n    primary: {\n      light: theme.colors?.primaryLight,\n      main: theme.colors?.primaryMain,\n      dark: theme.colors?.primaryDark,\n      200: theme.colors?.primary200,\n      800: theme.colors?.primary800\n    },\n    secondary: {\n      light: theme.colors?.secondaryLight,\n      main: theme.colors?.secondaryMain,\n      dark: theme.colors?.secondaryDark,\n      200: theme.colors?.secondary200,\n      800: theme.colors?.secondary800\n    },\n    error: {\n      light: theme.colors?.errorLight,\n      main: theme.colors?.errorMain,\n      dark: theme.colors?.errorDark\n    },\n    orange: {\n      light: theme.colors?.orangeLight,\n      main: theme.colors?.orangeMain,\n      dark: theme.colors?.orangeDark\n    },\n    warning: {\n      light: theme.colors?.warningLight,\n      main: theme.colors?.warningMain,\n      dark: theme.colors?.warningDark\n    },\n    success: {\n      light: theme.colors?.successLight,\n      200: theme.colors?.success200,\n      main: theme.colors?.successMain,\n      dark: theme.colors?.successDark\n    },\n    grey: {\n      50: theme.colors?.grey50,\n      100: theme.colors?.grey100,\n      500: theme.darkTextSecondary,\n      600: theme.heading,\n      700: theme.darkTextPrimary,\n      900: theme.textDark\n    },\n    dark: {\n      light: theme.colors?.darkTextPrimary,\n      main: theme.colors?.darkLevel1,\n      dark: theme.colors?.darkLevel2,\n      800: theme.colors?.darkBackground,\n      900: theme.colors?.darkPaper\n    },\n    text: {\n      primary: theme.darkTextPrimary,\n      secondary: theme.darkTextSecondary,\n      dark: theme.textDark,\n      hint: theme.colors?.grey100\n    },\n    background: {\n      paper: theme.paper,\n      default: theme.backgroundDefault\n    }\n  };\n}\n"
  },
  {
    "path": "web/berry/src/themes/typography.js",
    "content": "/**\n * Typography used in theme\n * @param {JsonObject} theme theme customization object\n */\n\nexport default function themeTypography(theme) {\n  return {\n    fontFamily: theme?.customization?.fontFamily,\n    h6: {\n      fontWeight: 500,\n      color: theme.heading,\n      fontSize: '0.75rem'\n    },\n    h5: {\n      fontSize: '0.875rem',\n      color: theme.heading,\n      fontWeight: 500\n    },\n    h4: {\n      fontSize: '1rem',\n      color: theme.heading,\n      fontWeight: 600\n    },\n    h3: {\n      fontSize: '1.25rem',\n      color: theme.heading,\n      fontWeight: 600\n    },\n    h2: {\n      fontSize: '1.5rem',\n      color: theme.heading,\n      fontWeight: 700\n    },\n    h1: {\n      fontSize: '2.125rem',\n      color: theme.heading,\n      fontWeight: 700\n    },\n    subtitle1: {\n      fontSize: '0.875rem',\n      fontWeight: 500,\n      color: theme.textDark\n    },\n    subtitle2: {\n      fontSize: '0.75rem',\n      fontWeight: 400,\n      color: theme.darkTextSecondary\n    },\n    caption: {\n      fontSize: '0.75rem',\n      color: theme.darkTextSecondary,\n      fontWeight: 400\n    },\n    body1: {\n      fontSize: '0.875rem',\n      fontWeight: 400,\n      lineHeight: '1.334em'\n    },\n    body2: {\n      letterSpacing: '0em',\n      fontWeight: 400,\n      lineHeight: '1.5em',\n      color: theme.darkTextPrimary\n    },\n    button: {\n      textTransform: 'capitalize'\n    },\n    customInput: {\n      marginTop: 1,\n      marginBottom: 1,\n      '& > label': {\n        top: 23,\n        left: 0,\n        color: theme.grey500,\n        '&[data-shrink=\"false\"]': {\n          top: 5\n        }\n      },\n      '& > div > input': {\n        padding: '30.5px 14px 11.5px !important'\n      },\n      '& legend': {\n        display: 'none'\n      },\n      '& fieldset': {\n        top: 0\n      }\n    },\n    otherInput: {\n      marginTop: 1,\n      marginBottom: 1\n    },\n    mainContent: {\n      backgroundColor: theme.background,\n      width: '100%',\n      minHeight: 'calc(100vh - 88px)',\n      flexGrow: 1,\n      padding: '20px',\n      marginTop: '88px',\n      marginRight: '20px',\n      borderRadius: `${theme?.customization?.borderRadius}px`\n    },\n    menuCaption: {\n      fontSize: '0.875rem',\n      fontWeight: 500,\n      color: theme.heading,\n      padding: '6px',\n      textTransform: 'capitalize',\n      marginTop: '10px'\n    },\n    subMenuCaption: {\n      fontSize: '0.6875rem',\n      fontWeight: 500,\n      color: theme.darkTextSecondary,\n      textTransform: 'capitalize'\n    },\n    commonAvatar: {\n      cursor: 'pointer',\n      borderRadius: '8px'\n    },\n    smallAvatar: {\n      width: '22px',\n      height: '22px',\n      fontSize: '1rem'\n    },\n    mediumAvatar: {\n      width: '34px',\n      height: '34px',\n      fontSize: '1.2rem'\n    },\n    largeAvatar: {\n      width: '44px',\n      height: '44px',\n      fontSize: '1.5rem'\n    },\n    menuButton: {\n      color: theme.menuButtonColor,\n      background: theme.menuButton\n    },\n    menuChip: {\n      background: theme.menuChip\n    },\n    CardWrapper: {\n      backgroundColor: theme.mode === 'dark' ? theme.colors.darkLevel2 : theme.colors.primaryDark\n    },\n    SubCard: {\n      border: theme.mode === 'dark' ? '1px solid rgba(227, 232, 239, 0.2)' : '1px solid rgb(227, 232, 239)'\n    }\n  };\n}\n"
  },
  {
    "path": "web/berry/src/ui-component/AdminContainer.js",
    "content": "import { styled } from '@mui/material/styles';\nimport { Container } from '@mui/material';\n\nconst AdminContainer = styled(Container)(({ theme }) => ({\n  [theme.breakpoints.down('md')]: {\n    paddingLeft: '0px',\n    paddingRight: '0px'\n  }\n}));\n\nexport default AdminContainer;\n"
  },
  {
    "path": "web/berry/src/ui-component/Footer.js",
    "content": "// material-ui\nimport { Link, Container, Box } from '@mui/material';\nimport React from 'react';\nimport { useSelector } from 'react-redux';\n\n// ==============================|| FOOTER - AUTHENTICATION 2 & 3 ||============================== //\n\nconst Footer = () => {\n  const siteInfo = useSelector((state) => state.siteInfo);\n\n  return (\n    <Container sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '64px' }}>\n      <Box sx={{ textAlign: 'center' }}>\n        {siteInfo.footer_html ? (\n          <div className=\"custom-footer\" dangerouslySetInnerHTML={{ __html: siteInfo.footer_html }}></div>\n        ) : (\n          <>\n            <Link href=\"https://github.com/songquanpeng/one-api\" target=\"_blank\">\n              {siteInfo.system_name} {process.env.REACT_APP_VERSION}{' '}\n            </Link>\n            由{' '}\n            <Link href=\"https://github.com/songquanpeng\" target=\"_blank\">\n              JustSong\n            </Link>{' '}\n            构建，主题 berry 来自{' '}\n            <Link href=\"https://github.com/MartialBE\" target=\"_blank\">\n              MartialBE\n            </Link>{' '}，源代码遵循\n            <Link href=\"https://opensource.org/licenses/mit-license.php\"> MIT 协议</Link>\n          </>\n        )}\n      </Box>\n    </Container>\n  );\n};\n\nexport default Footer;\n"
  },
  {
    "path": "web/berry/src/ui-component/Label.js",
    "content": "/*\n * Label.js\n *\n * This file uses code from the Minimal UI project, available at\n * https://github.com/minimal-ui-kit/material-kit-react/blob/main/src/components/label/label.jsx\n *\n * Minimal UI is licensed under the MIT License. A copy of the license is included below:\n *\n * MIT License\n *\n * Copyright (c) 2021 Minimal UI (https://minimals.cc/)\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\nimport PropTypes from 'prop-types';\nimport { forwardRef } from 'react';\n\nimport Box from '@mui/material/Box';\nimport { useTheme } from '@mui/material/styles';\nimport { alpha, styled } from '@mui/material/styles';\n\n// ----------------------------------------------------------------------\n\nconst Label = forwardRef(({ children, color = 'default', variant = 'soft', startIcon, endIcon, sx, ...other }, ref) => {\n  const theme = useTheme();\n\n  const iconStyles = {\n    width: 16,\n    height: 16,\n    '& svg, img': { width: 1, height: 1, objectFit: 'cover' }\n  };\n\n  return (\n    <StyledLabel\n      ref={ref}\n      component=\"span\"\n      ownerState={{ color, variant }}\n      sx={{\n        ...(startIcon && { pl: 0.75 }),\n        ...(endIcon && { pr: 0.75 }),\n        ...sx\n      }}\n      theme={theme}\n      {...other}\n    >\n      {startIcon && <Box sx={{ mr: 0.75, ...iconStyles }}> {startIcon} </Box>}\n\n      {children}\n\n      {endIcon && <Box sx={{ ml: 0.75, ...iconStyles }}> {endIcon} </Box>}\n    </StyledLabel>\n  );\n});\n\nLabel.propTypes = {\n  children: PropTypes.node,\n  endIcon: PropTypes.object,\n  startIcon: PropTypes.object,\n  sx: PropTypes.object,\n  variant: PropTypes.oneOf(['filled', 'outlined', 'ghost', 'soft']),\n  color: PropTypes.oneOf(['default', 'primary', 'secondary', 'info', 'success', 'warning', 'orange', 'error'])\n};\n\nexport default Label;\n\nconst StyledLabel = styled(Box)(({ theme, ownerState }) => {\n  // const lightMode = theme.palette.mode === 'light';\n\n  const filledVariant = ownerState.variant === 'filled';\n\n  const outlinedVariant = ownerState.variant === 'outlined';\n\n  const softVariant = ownerState.variant === 'soft';\n\n  const ghostVariant = ownerState.variant === 'ghost';\n\n  const defaultStyle = {\n    ...(ownerState.color === 'default' && {\n      // FILLED\n      ...(filledVariant && {\n        color: theme.palette.grey[300],\n        backgroundColor: theme.palette.text.primary\n      }),\n      // OUTLINED\n      ...(outlinedVariant && {\n        color: theme.palette.grey[500],\n        border: `2px solid ${theme.palette.grey[500]}`\n      }),\n      // SOFT\n      ...(softVariant && {\n        color: theme.palette.text.secondary,\n        backgroundColor: alpha(theme.palette.grey[500], 0.16)\n      })\n    })\n  };\n\n  const colorStyle = {\n    ...(ownerState.color !== 'default' && {\n      // FILLED\n      ...(filledVariant && {\n        color: theme.palette.background.paper,\n        backgroundColor: theme.palette[ownerState.color]?.main\n      }),\n      // OUTLINED\n      ...(outlinedVariant && {\n        backgroundColor: 'transparent',\n        color: theme.palette[ownerState.color]?.main,\n        border: `2px solid ${theme.palette[ownerState.color]?.main}`\n      }),\n      // SOFT\n      ...(softVariant && {\n        color: theme.palette[ownerState.color]['dark'],\n        backgroundColor: alpha(theme.palette[ownerState.color]?.main, 0.16)\n      }),\n      // GHOST\n      ...(ghostVariant && {\n        color: theme.palette[ownerState.color]?.main\n      })\n    })\n  };\n\n  return {\n    height: 24,\n    minWidth: 24,\n    lineHeight: 0,\n    borderRadius: 6,\n    cursor: 'default',\n    alignItems: 'center',\n    whiteSpace: 'nowrap',\n    display: 'inline-flex',\n    justifyContent: 'center',\n    // textTransform: 'capitalize',\n    padding: theme.spacing(0, 0.75),\n    fontSize: theme.typography.pxToRem(12),\n    fontWeight: theme.typography.fontWeightBold,\n    transition: theme.transitions.create('all', {\n      duration: theme.transitions.duration.shorter\n    }),\n    ...defaultStyle,\n    ...colorStyle\n  };\n});\n"
  },
  {
    "path": "web/berry/src/ui-component/Loadable.js",
    "content": "import { Suspense } from 'react';\n\n// project imports\nimport Loader from './Loader';\n\n// ==============================|| LOADABLE - LAZY LOADING ||============================== //\n\nconst Loadable = (Component) => (props) =>\n  (\n    <Suspense fallback={<Loader />}>\n      <Component {...props} />\n    </Suspense>\n  );\n\nexport default Loadable;\n"
  },
  {
    "path": "web/berry/src/ui-component/Loader.js",
    "content": "// material-ui\nimport LinearProgress from '@mui/material/LinearProgress';\nimport { styled } from '@mui/material/styles';\n\n// styles\nconst LoaderWrapper = styled('div')({\n  position: 'fixed',\n  top: 0,\n  left: 0,\n  zIndex: 1301,\n  width: '100%'\n});\n\n// ==============================|| LOADER ||============================== //\nconst Loader = () => (\n  <LoaderWrapper>\n    <LinearProgress color=\"primary\" />\n  </LoaderWrapper>\n);\n\nexport default Loader;\n"
  },
  {
    "path": "web/berry/src/ui-component/Logo.js",
    "content": "// material-ui\nimport logoLight from 'assets/images/logo.svg';\nimport logoDark from 'assets/images/logo-white.svg';\nimport { useSelector } from 'react-redux';\nimport { useTheme } from '@mui/material/styles';\n\n/**\n * if you want to use image instead of <svg> uncomment following.\n *\n * import logoDark from 'assets/images/logo-dark.svg';\n * import logo from 'assets/images/logo.svg';\n *\n */\n\n// ==============================|| LOGO SVG ||============================== //\n\nconst Logo = () => {\n  const siteInfo = useSelector((state) => state.siteInfo);\n  const theme = useTheme();\n  const logo = theme.palette.mode === 'light' ? logoLight : logoDark;\n\n  return <img src={siteInfo.logo || logo} alt={siteInfo.system_name} height=\"50\" />;\n};\n\nexport default Logo;\n"
  },
  {
    "path": "web/berry/src/ui-component/SvgColor.js",
    "content": "import PropTypes from 'prop-types';\nimport { forwardRef } from 'react';\n\nimport Box from '@mui/material/Box';\n\n// ----------------------------------------------------------------------\n\nconst SvgColor = forwardRef(({ src, sx, ...other }, ref) => (\n  <Box\n    component=\"span\"\n    className=\"svg-color\"\n    ref={ref}\n    sx={{\n      width: 24,\n      height: 24,\n      display: 'inline-block',\n      bgcolor: 'currentColor',\n      mask: `url(${src}) no-repeat center / contain`,\n      WebkitMask: `url(${src}) no-repeat center / contain`,\n      ...sx\n    }}\n    {...other}\n  />\n));\n\nSvgColor.propTypes = {\n  src: PropTypes.string,\n  sx: PropTypes.object\n};\n\nexport default SvgColor;\n"
  },
  {
    "path": "web/berry/src/ui-component/Switch.js",
    "content": "import { styled } from '@mui/material/styles';\nimport Switch from '@mui/material/Switch';\n\nconst TableSwitch = styled(Switch)(({ theme }) => ({\n  padding: 8,\n  '& .MuiSwitch-track': {\n    borderRadius: 22 / 2,\n    '&:before, &:after': {\n      content: '\"\"',\n      position: 'absolute',\n      top: '50%',\n      transform: 'translateY(-50%)',\n      width: 16,\n      height: 16\n    },\n    '&:before': {\n      backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"16\" width=\"16\" viewBox=\"0 0 24 24\"><path fill=\"${encodeURIComponent(\n        theme.palette.getContrastText(theme.palette.primary.main)\n      )}\" d=\"M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z\"/></svg>')`,\n      left: 12\n    },\n    '&:after': {\n      backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"16\" width=\"16\" viewBox=\"0 0 24 24\"><path fill=\"${encodeURIComponent(\n        theme.palette.getContrastText(theme.palette.primary.main)\n      )}\" d=\"M19,13H5V11H19V13Z\" /></svg>')`,\n      right: 12\n    }\n  },\n  '& .MuiSwitch-thumb': {\n    boxShadow: 'none',\n    width: 16,\n    height: 16,\n    margin: 2\n  }\n}));\n\nexport default TableSwitch;\n"
  },
  {
    "path": "web/berry/src/ui-component/TableToolBar.js",
    "content": "import PropTypes from 'prop-types';\n\nimport Toolbar from '@mui/material/Toolbar';\nimport OutlinedInput from '@mui/material/OutlinedInput';\nimport InputAdornment from '@mui/material/InputAdornment';\n\nimport { useTheme } from '@mui/material/styles';\nimport { IconSearch } from '@tabler/icons-react';\n\n// ----------------------------------------------------------------------\n\nexport default function TableToolBar({ filterName, handleFilterName, placeholder }) {\n  const theme = useTheme();\n  const grey500 = theme.palette.grey[500];\n\n  return (\n    <Toolbar\n      sx={{\n        height: 80,\n        display: 'flex',\n        justifyContent: 'space-between',\n        p: (theme) => theme.spacing(0, 1, 0, 3)\n      }}\n    >\n      <OutlinedInput\n        id=\"keyword\"\n        sx={{\n          minWidth: '100%'\n        }}\n        value={filterName}\n        onChange={handleFilterName}\n        placeholder={placeholder}\n        startAdornment={\n          <InputAdornment position=\"start\">\n            <IconSearch stroke={1.5} size=\"20px\" color={grey500} />\n          </InputAdornment>\n        }\n      />\n    </Toolbar>\n  );\n}\n\nTableToolBar.propTypes = {\n  filterName: PropTypes.string,\n  handleFilterName: PropTypes.func,\n  placeholder: PropTypes.string\n};\n"
  },
  {
    "path": "web/berry/src/ui-component/ThemeButton.js",
    "content": "import { useDispatch, useSelector } from 'react-redux';\nimport { SET_THEME } from 'store/actions';\nimport { useTheme } from '@mui/material/styles';\nimport { Avatar, Box, ButtonBase } from '@mui/material';\nimport { IconSun, IconMoon } from '@tabler/icons-react';\n\nexport default function ThemeButton() {\n  const dispatch = useDispatch();\n\n  const defaultTheme = useSelector((state) => state.customization.theme);\n\n  const theme = useTheme();\n\n  return (\n    <Box\n      sx={{\n        ml: 2,\n        mr: 3,\n        [theme.breakpoints.down('md')]: {\n          mr: 2\n        }\n      }}\n    >\n      <ButtonBase sx={{ borderRadius: '12px' }}>\n        <Avatar\n          variant=\"rounded\"\n          sx={{\n            ...theme.typography.commonAvatar,\n            ...theme.typography.mediumAvatar,\n            transition: 'all .2s ease-in-out',\n            borderColor: theme.typography.menuChip.background,\n            backgroundColor: theme.typography.menuChip.background,\n            '&[aria-controls=\"menu-list-grow\"],&:hover': {\n              background: theme.palette.secondary.dark,\n              color: theme.palette.secondary.light\n            }\n          }}\n          onClick={() => {\n            let theme = defaultTheme === 'light' ? 'dark' : 'light';\n            dispatch({ type: SET_THEME, theme: theme });\n            localStorage.setItem('theme', theme);\n          }}\n          color=\"inherit\"\n        >\n          {defaultTheme === 'light' ? <IconSun stroke={1.5} size=\"1.3rem\" /> : <IconMoon stroke={1.5} size=\"1.3rem\" />}\n        </Avatar>\n      </ButtonBase>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/CardSecondaryAction.js",
    "content": "import PropTypes from 'prop-types';\nimport { useTheme } from '@mui/material/styles';\nimport { ButtonBase, Link, Tooltip } from '@mui/material';\n\n// project imports\nimport Avatar from '../extended/Avatar';\n\n// ==============================|| CARD SECONDARY ACTION ||============================== //\n\nconst CardSecondaryAction = ({ title, link, icon }) => {\n  const theme = useTheme();\n\n  return (\n    <Tooltip title={title || 'Reference'} placement=\"left\">\n      <ButtonBase disableRipple>\n        {!icon && (\n          <Avatar component={Link} href={link} target=\"_blank\" alt=\"MUI Logo\" size=\"badge\" color=\"primary\" outline>\n            <svg width=\"500\" height=\"500\" viewBox=\"0 0 500 500\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n              <g clipPath=\"url(#clip0)\">\n                <path d=\"M100 260.9V131L212.5 195.95V239.25L137.5 195.95V282.55L100 260.9Z\" fill={theme.palette.primary[800]} />\n                <path\n                  d=\"M212.5 195.95L325 131V260.9L250 304.2L212.5 282.55L287.5 239.25V195.95L212.5 239.25V195.95Z\"\n                  fill={theme.palette.primary.main}\n                />\n                <path d=\"M212.5 282.55V325.85L287.5 369.15V325.85L212.5 282.55Z\" fill={theme.palette.primary[800]} />\n                <path\n                  d=\"M287.5 369.15L400 304.2V217.6L362.5 239.25V282.55L287.5 325.85V369.15ZM362.5 195.95V152.65L400 131V174.3L362.5 195.95Z\"\n                  fill={theme.palette.primary.main}\n                />\n              </g>\n              <defs>\n                <clipPath id=\"clip0\">\n                  <rect width=\"300\" height=\"238.3\" fill=\"white\" transform=\"translate(100 131)\" />\n                </clipPath>\n              </defs>\n            </svg>\n          </Avatar>\n        )}\n        {icon && (\n          <Avatar component={Link} href={link} target=\"_blank\" size=\"badge\" color=\"primary\" outline>\n            {icon}\n          </Avatar>\n        )}\n      </ButtonBase>\n    </Tooltip>\n  );\n};\n\nCardSecondaryAction.propTypes = {\n  icon: PropTypes.node,\n  link: PropTypes.string,\n  title: PropTypes.string\n};\n\nexport default CardSecondaryAction;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/MainCard.js",
    "content": "import PropTypes from 'prop-types';\nimport { forwardRef } from 'react';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Card, CardContent, CardHeader, Divider, Typography } from '@mui/material';\n\n// constant\nconst headerSX = {\n  '& .MuiCardHeader-action': { mr: 0 }\n};\n\n// ==============================|| CUSTOM MAIN CARD ||============================== //\n\nconst MainCard = forwardRef(\n  (\n    {\n      border = false,\n      boxShadow,\n      children,\n      content = true,\n      contentClass = '',\n      contentSX = {},\n      darkTitle,\n      secondary,\n      shadow,\n      sx = {},\n      title,\n      ...others\n    },\n    ref\n  ) => {\n    const theme = useTheme();\n\n    return (\n      <Card\n        ref={ref}\n        {...others}\n        sx={{\n          border: border ? '1px solid' : 'none',\n          borderColor: theme.palette.primary[200] + 25,\n          ':hover': {\n            boxShadow: boxShadow ? shadow || '0 2px 14px 0 rgb(32 40 45 / 8%)' : 'inherit'\n          },\n          ...sx\n        }}\n      >\n        {/* card header and action */}\n        {title && <CardHeader sx={headerSX} title={darkTitle ? <Typography variant=\"h3\">{title}</Typography> : title} action={secondary} />}\n\n        {/* content & header divider */}\n        {title && <Divider />}\n\n        {/* card content */}\n        {content && (\n          <CardContent sx={contentSX} className={contentClass}>\n            {children}\n          </CardContent>\n        )}\n        {!content && children}\n      </Card>\n    );\n  }\n);\n\nMainCard.propTypes = {\n  border: PropTypes.bool,\n  boxShadow: PropTypes.bool,\n  children: PropTypes.node,\n  content: PropTypes.bool,\n  contentClass: PropTypes.string,\n  contentSX: PropTypes.object,\n  darkTitle: PropTypes.bool,\n  secondary: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object]),\n  shadow: PropTypes.string,\n  sx: PropTypes.object,\n  title: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object])\n};\n\nexport default MainCard;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/Skeleton/EarningCard.js",
    "content": "// material-ui\nimport { Card, CardContent, Grid } from '@mui/material';\nimport Skeleton from '@mui/material/Skeleton';\n\n// ==============================|| SKELETON - EARNING CARD ||============================== //\n\nconst EarningCard = () => (\n  <Card>\n    <CardContent>\n      <Grid container direction=\"column\">\n        <Grid item>\n          <Grid container justifyContent=\"space-between\">\n            <Grid item>\n              <Skeleton variant=\"rectangular\" width={44} height={44} />\n            </Grid>\n            <Grid item>\n              <Skeleton variant=\"rectangular\" width={34} height={34} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item>\n          <Skeleton variant=\"rectangular\" sx={{ my: 2 }} height={40} />\n        </Grid>\n        <Grid item>\n          <Skeleton variant=\"rectangular\" height={30} />\n        </Grid>\n      </Grid>\n    </CardContent>\n  </Card>\n);\n\nexport default EarningCard;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/Skeleton/ImagePlaceholder.js",
    "content": "// material-ui\nimport Skeleton from '@mui/material/Skeleton';\n\n// ==============================|| SKELETON IMAGE CARD ||============================== //\n\nconst ImagePlaceholder = ({ ...others }) => <Skeleton variant=\"rectangular\" {...others} animation=\"wave\" />;\n\nexport default ImagePlaceholder;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/Skeleton/PopularCard.js",
    "content": "// material-ui\nimport { Card, CardContent, Grid } from '@mui/material';\nimport Skeleton from '@mui/material/Skeleton';\n\n// project imports\nimport { gridSpacing } from 'store/constant';\n\n// ==============================|| SKELETON - POPULAR CARD ||============================== //\n\nconst PopularCard = () => (\n  <Card>\n    <CardContent>\n      <Grid container spacing={gridSpacing}>\n        <Grid item xs={12}>\n          <Grid container alignItems=\"center\" justifyContent=\"space-between\" spacing={gridSpacing}>\n            <Grid item xs zeroMinWidth>\n              <Skeleton variant=\"rectangular\" height={20} />\n            </Grid>\n            <Grid item>\n              <Skeleton variant=\"rectangular\" height={20} width={20} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item xs={12}>\n          <Skeleton variant=\"rectangular\" height={150} />\n        </Grid>\n        <Grid item xs={12}>\n          <Grid container spacing={1}>\n            <Grid item xs={12}>\n              <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                <Grid item xs={6}>\n                  <Skeleton variant=\"rectangular\" height={20} />\n                </Grid>\n                <Grid item xs={6}>\n                  <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                    <Grid item xs zeroMinWidth>\n                      <Skeleton variant=\"rectangular\" height={20} />\n                    </Grid>\n                    <Grid item>\n                      <Skeleton variant=\"rectangular\" height={16} width={16} />\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item xs={6}>\n              <Skeleton variant=\"rectangular\" height={20} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item xs={12}>\n          <Grid container spacing={1}>\n            <Grid item xs={12}>\n              <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                <Grid item xs={6}>\n                  <Skeleton variant=\"rectangular\" height={20} />\n                </Grid>\n                <Grid item xs={6}>\n                  <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                    <Grid item xs zeroMinWidth>\n                      <Skeleton variant=\"rectangular\" height={20} />\n                    </Grid>\n                    <Grid item>\n                      <Skeleton variant=\"rectangular\" height={16} width={16} />\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item xs={6}>\n              <Skeleton variant=\"rectangular\" height={20} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item xs={12}>\n          <Grid container spacing={1}>\n            <Grid item xs={12}>\n              <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                <Grid item xs={6}>\n                  <Skeleton variant=\"rectangular\" height={20} />\n                </Grid>\n                <Grid item xs={6}>\n                  <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                    <Grid item xs zeroMinWidth>\n                      <Skeleton variant=\"rectangular\" height={20} />\n                    </Grid>\n                    <Grid item>\n                      <Skeleton variant=\"rectangular\" height={16} width={16} />\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item xs={6}>\n              <Skeleton variant=\"rectangular\" height={20} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item xs={12}>\n          <Grid container spacing={1}>\n            <Grid item xs={12}>\n              <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                <Grid item xs={6}>\n                  <Skeleton variant=\"rectangular\" height={20} />\n                </Grid>\n                <Grid item xs={6}>\n                  <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                    <Grid item xs zeroMinWidth>\n                      <Skeleton variant=\"rectangular\" height={20} />\n                    </Grid>\n                    <Grid item>\n                      <Skeleton variant=\"rectangular\" height={16} width={16} />\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item xs={6}>\n              <Skeleton variant=\"rectangular\" height={20} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item xs={12}>\n          <Grid container spacing={1}>\n            <Grid item xs={12}>\n              <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                <Grid item xs={6}>\n                  <Skeleton variant=\"rectangular\" height={20} />\n                </Grid>\n                <Grid item xs={6}>\n                  <Grid container alignItems=\"center\" spacing={gridSpacing} justifyContent=\"space-between\">\n                    <Grid item xs zeroMinWidth>\n                      <Skeleton variant=\"rectangular\" height={20} />\n                    </Grid>\n                    <Grid item>\n                      <Skeleton variant=\"rectangular\" height={16} width={16} />\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item xs={6}>\n              <Skeleton variant=\"rectangular\" height={20} />\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </CardContent>\n    <CardContent sx={{ p: 1.25, display: 'flex', pt: 0, justifyContent: 'center' }}>\n      <Skeleton variant=\"rectangular\" height={25} width={75} />\n    </CardContent>\n  </Card>\n);\n\nexport default PopularCard;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/Skeleton/ProductPlaceholder.js",
    "content": "// material-ui\nimport { CardContent, Grid, Skeleton, Stack } from '@mui/material';\n\n// project import\nimport MainCard from '../MainCard';\n\n// ===========================|| SKELETON TOTAL GROWTH BAR CHART ||=========================== //\n\nconst ProductPlaceholder = () => (\n  <MainCard content={false} boxShadow>\n    <Skeleton variant=\"rectangular\" height={220} />\n    <CardContent sx={{ p: 2 }}>\n      <Grid container spacing={2}>\n        <Grid item xs={12}>\n          <Skeleton variant=\"rectangular\" height={20} />\n        </Grid>\n        <Grid item xs={12}>\n          <Skeleton variant=\"rectangular\" height={45} />\n        </Grid>\n        <Grid item xs={12} sx={{ pt: '8px !important' }}>\n          <Stack direction=\"row\" alignItems=\"center\" spacing={1}>\n            <Skeleton variant=\"rectangular\" height={20} width={90} />\n            <Skeleton variant=\"rectangular\" height={20} width={38} />\n          </Stack>\n        </Grid>\n        <Grid item xs={12}>\n          <Stack direction=\"row\" justifyContent=\"space-between\" alignItems=\"center\">\n            <Grid container spacing={1}>\n              <Grid item>\n                <Skeleton variant=\"rectangular\" height={20} width={40} />\n              </Grid>\n              <Grid item>\n                <Skeleton variant=\"rectangular\" height={17} width={20} />\n              </Grid>\n            </Grid>\n            <Skeleton variant=\"rectangular\" height={32} width={47} />\n          </Stack>\n        </Grid>\n      </Grid>\n    </CardContent>\n  </MainCard>\n);\n\nexport default ProductPlaceholder;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/Skeleton/TotalGrowthBarChart.js",
    "content": "// material-ui\nimport { Card, CardContent, Grid } from '@mui/material';\nimport Skeleton from '@mui/material/Skeleton';\n\n// project imports\nimport { gridSpacing } from 'store/constant';\n\n// ==============================|| SKELETON TOTAL GROWTH BAR CHART ||============================== //\n\nconst TotalGrowthBarChart = () => (\n  <Card>\n    <CardContent>\n      <Grid container spacing={gridSpacing}>\n        <Grid item xs={12}>\n          <Grid container alignItems=\"center\" justifyContent=\"space-between\" spacing={gridSpacing}>\n            <Grid item xs zeroMinWidth>\n              <Grid container spacing={1}>\n                <Grid item xs={12}>\n                  <Skeleton variant=\"text\" />\n                </Grid>\n                <Grid item xs={12}>\n                  <Skeleton variant=\"rectangular\" height={20} />\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item>\n              <Skeleton variant=\"rectangular\" height={50} width={80} />\n            </Grid>\n          </Grid>\n        </Grid>\n        <Grid item xs={12}>\n          <Skeleton variant=\"rectangular\" height={530} />\n        </Grid>\n      </Grid>\n    </CardContent>\n  </Card>\n);\n\nexport default TotalGrowthBarChart;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/Skeleton/TotalIncomeCard.js",
    "content": "// material-ui\nimport { Card, List, ListItem, ListItemAvatar, ListItemText, Skeleton } from '@mui/material';\n\n// ==============================|| SKELETON - TOTAL INCOME DARK/LIGHT CARD ||============================== //\n\nconst TotalIncomeCard = () => (\n  <Card sx={{ p: 2 }}>\n    <List sx={{ py: 0 }}>\n      <ListItem alignItems=\"center\" disableGutters sx={{ py: 0 }}>\n        <ListItemAvatar>\n          <Skeleton variant=\"rectangular\" width={44} height={44} />\n        </ListItemAvatar>\n        <ListItemText sx={{ py: 0 }} primary={<Skeleton variant=\"rectangular\" height={20} />} secondary={<Skeleton variant=\"text\" />} />\n      </ListItem>\n    </List>\n  </Card>\n);\n\nexport default TotalIncomeCard;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/SubCard.js",
    "content": "import PropTypes from 'prop-types';\nimport { forwardRef } from 'react';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Card, CardContent, CardHeader, Divider, Typography } from '@mui/material';\n\n// ==============================|| CUSTOM SUB CARD ||============================== //\n\nconst SubCard = forwardRef(\n  ({ children, content, contentClass, darkTitle, secondary, sx = {}, contentSX = {}, title, subTitle, ...others }, ref) => {\n    const theme = useTheme();\n\n    return (\n      <Card\n        ref={ref}\n        sx={{\n          border: theme.typography.SubCard.border,\n          ':hover': {\n            boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)'\n          },\n          ...sx\n        }}\n        {...others}\n      >\n        {/* card header and action */}\n        {!darkTitle && title && (\n          <CardHeader sx={{ p: 2.5 }} title={<Typography variant=\"h5\">{title}</Typography>} action={secondary} subheader={subTitle} />\n        )}\n        {darkTitle && title && (\n          <CardHeader sx={{ p: 2.5 }} title={<Typography variant=\"h4\">{title}</Typography>} action={secondary} subheader={subTitle} />\n        )}\n\n        {/* content & header divider */}\n        {title && (\n          <Divider\n            sx={{\n              opacity: 1\n              // borderColor: theme.palette.primary.light\n            }}\n          />\n        )}\n\n        {/* card content */}\n        {content && (\n          <CardContent sx={{ p: 2.5, ...contentSX }} className={contentClass || ''}>\n            {children}\n          </CardContent>\n        )}\n        {!content && children}\n      </Card>\n    );\n  }\n);\n\nSubCard.propTypes = {\n  children: PropTypes.node,\n  content: PropTypes.bool,\n  contentClass: PropTypes.string,\n  darkTitle: PropTypes.bool,\n  secondary: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object]),\n  sx: PropTypes.object,\n  contentSX: PropTypes.object,\n  title: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object]),\n  subTitle: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.object])\n};\n\nSubCard.defaultProps = {\n  content: true\n};\n\nexport default SubCard;\n"
  },
  {
    "path": "web/berry/src/ui-component/cards/UserCard.js",
    "content": "/*\n * UserCard.js\n *\n * This file uses code from the Minimal UI project, available at\n * https://github.com/minimal-ui-kit/material-kit-react/blob/main/src/sections/blog/post-card.jsx\n *\n * Minimal UI is licensed under the MIT License. A copy of the license is included below:\n *\n * MIT License\n *\n * Copyright (c) 2021 Minimal UI (https://minimals.cc/)\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\nimport { Box, Avatar } from '@mui/material';\nimport { alpha } from '@mui/material/styles';\nimport Card from '@mui/material/Card';\nimport shapeAvatar from 'assets/images/icons/shape-avatar.svg';\nimport coverAvatar from 'assets/images/invite/cover.jpg';\nimport userAvatar from 'assets/images/users/user-round.svg';\nimport SvgColor from 'ui-component/SvgColor';\n\nimport React from 'react';\n\nexport default function UserCard({ children }) {\n  const renderShape = (\n    <SvgColor\n      color=\"paper\"\n      src={shapeAvatar}\n      sx={{\n        width: '100%',\n        height: 62,\n        zIndex: 10,\n        bottom: -26,\n        position: 'absolute',\n        color: 'background.paper'\n      }}\n    />\n  );\n\n  const renderAvatar = (\n    <Avatar\n      src={userAvatar}\n      sx={{\n        zIndex: 11,\n        width: 64,\n        height: 64,\n        position: 'absolute',\n        alignItems: 'center',\n        marginLeft: 'auto',\n        marginRight: 'auto',\n        left: 0,\n        right: 0,\n        bottom: (theme) => theme.spacing(-4)\n      }}\n    />\n  );\n\n  const renderCover = (\n    <Box\n      component=\"img\"\n      src={coverAvatar}\n      sx={{\n        top: 0,\n        width: 1,\n        height: 1,\n        objectFit: 'cover',\n        position: 'absolute'\n      }}\n    />\n  );\n\n  return (\n    <Card>\n      <Box\n        sx={{\n          position: 'relative',\n          '&:after': {\n            top: 0,\n            content: \"''\",\n            width: '100%',\n            height: '100%',\n            position: 'absolute',\n            bgcolor: (theme) => alpha(theme.palette.primary.main, 0.42)\n          },\n          pt: {\n            xs: 'calc(100% / 3)',\n            sm: 'calc(100% / 4.66)'\n          }\n        }}\n      >\n        {renderShape}\n        {renderAvatar}\n        {renderCover}\n      </Box>\n      <Box\n        sx={{\n          p: (theme) => theme.spacing(4, 3, 3, 3)\n        }}\n      >\n        {children}\n      </Box>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/ui-component/extended/AnimateButton.js",
    "content": "import PropTypes from 'prop-types';\nimport { forwardRef } from 'react';\n// third-party\nimport { motion, useCycle } from 'framer-motion';\n\n// ==============================|| ANIMATION BUTTON ||============================== //\n\nconst AnimateButton = forwardRef(({ children, type, direction, offset, scale }, ref) => {\n  let offset1;\n  let offset2;\n  switch (direction) {\n    case 'up':\n    case 'left':\n      offset1 = offset;\n      offset2 = 0;\n      break;\n    case 'right':\n    case 'down':\n    default:\n      offset1 = 0;\n      offset2 = offset;\n      break;\n  }\n\n  const [x, cycleX] = useCycle(offset1, offset2);\n  const [y, cycleY] = useCycle(offset1, offset2);\n\n  switch (type) {\n    case 'rotate':\n      return (\n        <motion.div\n          ref={ref}\n          animate={{ rotate: 360 }}\n          transition={{\n            repeat: Infinity,\n            repeatType: 'loop',\n            duration: 2,\n            repeatDelay: 0\n          }}\n        >\n          {children}\n        </motion.div>\n      );\n    case 'slide':\n      if (direction === 'up' || direction === 'down') {\n        return (\n          <motion.div ref={ref} animate={{ y: y !== undefined ? y : '' }} onHoverEnd={() => cycleY()} onHoverStart={() => cycleY()}>\n            {children}\n          </motion.div>\n        );\n      }\n      return (\n        <motion.div ref={ref} animate={{ x: x !== undefined ? x : '' }} onHoverEnd={() => cycleX()} onHoverStart={() => cycleX()}>\n          {children}\n        </motion.div>\n      );\n\n    case 'scale':\n    default:\n      if (typeof scale === 'number') {\n        scale = {\n          hover: scale,\n          tap: scale\n        };\n      }\n      return (\n        <motion.div ref={ref} whileHover={{ scale: scale?.hover }} whileTap={{ scale: scale?.tap }}>\n          {children}\n        </motion.div>\n      );\n  }\n});\n\nAnimateButton.propTypes = {\n  children: PropTypes.node,\n  offset: PropTypes.number,\n  type: PropTypes.oneOf(['slide', 'scale', 'rotate']),\n  direction: PropTypes.oneOf(['up', 'down', 'left', 'right']),\n  scale: PropTypes.oneOfType([PropTypes.number, PropTypes.object])\n};\n\nAnimateButton.defaultProps = {\n  type: 'scale',\n  offset: 10,\n  direction: 'right',\n  scale: {\n    hover: 1,\n    tap: 0.9\n  }\n};\n\nexport default AnimateButton;\n"
  },
  {
    "path": "web/berry/src/ui-component/extended/Avatar.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport MuiAvatar from '@mui/material/Avatar';\n\n// ==============================|| AVATAR ||============================== //\n\nconst Avatar = ({ color, outline, size, sx, ...others }) => {\n  const theme = useTheme();\n\n  const colorSX = color && !outline && { color: theme.palette.background.paper, bgcolor: `${color}.main` };\n  const outlineSX = outline && {\n    color: color ? `${color}.main` : `primary.main`,\n    bgcolor: theme.palette.background.paper,\n    border: '2px solid',\n    borderColor: color ? `${color}.main` : `primary.main`\n  };\n  let sizeSX = {};\n  switch (size) {\n    case 'badge':\n      sizeSX = {\n        width: theme.spacing(3.5),\n        height: theme.spacing(3.5)\n      };\n      break;\n    case 'xs':\n      sizeSX = {\n        width: theme.spacing(4.25),\n        height: theme.spacing(4.25)\n      };\n      break;\n    case 'sm':\n      sizeSX = {\n        width: theme.spacing(5),\n        height: theme.spacing(5)\n      };\n      break;\n    case 'lg':\n      sizeSX = {\n        width: theme.spacing(9),\n        height: theme.spacing(9)\n      };\n      break;\n    case 'xl':\n      sizeSX = {\n        width: theme.spacing(10.25),\n        height: theme.spacing(10.25)\n      };\n      break;\n    case 'md':\n      sizeSX = {\n        width: theme.spacing(7.5),\n        height: theme.spacing(7.5)\n      };\n      break;\n    default:\n      sizeSX = {};\n  }\n\n  return <MuiAvatar sx={{ ...colorSX, ...outlineSX, ...sizeSX, ...sx }} {...others} />;\n};\n\nAvatar.propTypes = {\n  className: PropTypes.string,\n  color: PropTypes.string,\n  outline: PropTypes.bool,\n  size: PropTypes.string,\n  sx: PropTypes.object\n};\n\nexport default Avatar;\n"
  },
  {
    "path": "web/berry/src/ui-component/extended/Breadcrumbs.js",
    "content": "import PropTypes from 'prop-types';\nimport { useEffect, useState } from 'react';\nimport { Link } from 'react-router-dom';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Box, Card, Divider, Grid, Typography } from '@mui/material';\nimport MuiBreadcrumbs from '@mui/material/Breadcrumbs';\n\n// project imports\nimport config from 'config';\nimport { gridSpacing } from 'store/constant';\n\n// assets\nimport { IconTallymark1 } from '@tabler/icons-react';\nimport AccountTreeTwoToneIcon from '@mui/icons-material/AccountTreeTwoTone';\nimport HomeIcon from '@mui/icons-material/Home';\nimport HomeTwoToneIcon from '@mui/icons-material/HomeTwoTone';\n\nconst linkSX = {\n  display: 'flex',\n  color: 'grey.900',\n  textDecoration: 'none',\n  alignContent: 'center',\n  alignItems: 'center'\n};\n\n// ==============================|| BREADCRUMBS ||============================== //\n\nconst Breadcrumbs = ({ card, divider, icon, icons, maxItems, navigation, rightAlign, separator, title, titleBottom, ...others }) => {\n  const theme = useTheme();\n\n  const iconStyle = {\n    marginRight: theme.spacing(0.75),\n    marginTop: `-${theme.spacing(0.25)}`,\n    width: '1rem',\n    height: '1rem',\n    color: theme.palette.secondary.main\n  };\n\n  const [main, setMain] = useState();\n  const [item, setItem] = useState();\n\n  // set active item state\n  const getCollapse = (menu) => {\n    if (menu.children) {\n      menu.children.filter((collapse) => {\n        if (collapse.type && collapse.type === 'collapse') {\n          getCollapse(collapse);\n        } else if (collapse.type && collapse.type === 'item') {\n          if (document.location.pathname === config.basename + collapse.url) {\n            setMain(menu);\n            setItem(collapse);\n          }\n        }\n        return false;\n      });\n    }\n  };\n\n  useEffect(() => {\n    navigation?.items?.map((menu) => {\n      if (menu.type && menu.type === 'group') {\n        getCollapse(menu);\n      }\n      return false;\n    });\n  });\n\n  // item separator\n  const SeparatorIcon = separator;\n  const separatorIcon = separator ? <SeparatorIcon stroke={1.5} size=\"1rem\" /> : <IconTallymark1 stroke={1.5} size=\"1rem\" />;\n\n  let mainContent;\n  let itemContent;\n  let breadcrumbContent = <Typography />;\n  let itemTitle = '';\n  let CollapseIcon;\n  let ItemIcon;\n\n  // collapse item\n  if (main && main.type === 'collapse') {\n    CollapseIcon = main.icon ? main.icon : AccountTreeTwoToneIcon;\n    mainContent = (\n      <Typography component={Link} to=\"#\" variant=\"subtitle1\" sx={linkSX}>\n        {icons && <CollapseIcon style={iconStyle} />}\n        {main.title}\n      </Typography>\n    );\n  }\n\n  // items\n  if (item && item.type === 'item') {\n    itemTitle = item.title;\n\n    ItemIcon = item.icon ? item.icon : AccountTreeTwoToneIcon;\n    itemContent = (\n      <Typography\n        variant=\"subtitle1\"\n        sx={{\n          display: 'flex',\n          textDecoration: 'none',\n          alignContent: 'center',\n          alignItems: 'center',\n          color: 'grey.500'\n        }}\n      >\n        {icons && <ItemIcon style={iconStyle} />}\n        {itemTitle}\n      </Typography>\n    );\n\n    // main\n    if (item.breadcrumbs !== false) {\n      breadcrumbContent = (\n        <Card\n          sx={{\n            marginBottom: card === false ? 0 : theme.spacing(gridSpacing),\n            border: card === false ? 'none' : '1px solid',\n            borderColor: theme.palette.primary[200] + 75,\n            background: card === false ? 'transparent' : theme.palette.background.default\n          }}\n          {...others}\n        >\n          <Box sx={{ p: 2, pl: card === false ? 0 : 2 }}>\n            <Grid\n              container\n              direction={rightAlign ? 'row' : 'column'}\n              justifyContent={rightAlign ? 'space-between' : 'flex-start'}\n              alignItems={rightAlign ? 'center' : 'flex-start'}\n              spacing={1}\n            >\n              {title && !titleBottom && (\n                <Grid item>\n                  <Typography variant=\"h3\" sx={{ fontWeight: 500 }}>\n                    {item.title}\n                  </Typography>\n                </Grid>\n              )}\n              <Grid item>\n                <MuiBreadcrumbs\n                  sx={{ '& .MuiBreadcrumbs-separator': { width: 16, ml: 1.25, mr: 1.25 } }}\n                  aria-label=\"breadcrumb\"\n                  maxItems={maxItems || 8}\n                  separator={separatorIcon}\n                >\n                  <Typography component={Link} to=\"/\" color=\"inherit\" variant=\"subtitle1\" sx={linkSX}>\n                    {icons && <HomeTwoToneIcon sx={iconStyle} />}\n                    {icon && <HomeIcon sx={{ ...iconStyle, mr: 0 }} />}\n                    {!icon && 'Dashboard'}\n                  </Typography>\n                  {mainContent}\n                  {itemContent}\n                </MuiBreadcrumbs>\n              </Grid>\n              {title && titleBottom && (\n                <Grid item>\n                  <Typography variant=\"h3\" sx={{ fontWeight: 500 }}>\n                    {item.title}\n                  </Typography>\n                </Grid>\n              )}\n            </Grid>\n          </Box>\n          {card === false && divider !== false && <Divider sx={{ borderColor: theme.palette.primary.main, mb: gridSpacing }} />}\n        </Card>\n      );\n    }\n  }\n\n  return breadcrumbContent;\n};\n\nBreadcrumbs.propTypes = {\n  card: PropTypes.bool,\n  divider: PropTypes.bool,\n  icon: PropTypes.bool,\n  icons: PropTypes.bool,\n  maxItems: PropTypes.number,\n  navigation: PropTypes.object,\n  rightAlign: PropTypes.bool,\n  separator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),\n  title: PropTypes.bool,\n  titleBottom: PropTypes.bool\n};\n\nexport default Breadcrumbs;\n"
  },
  {
    "path": "web/berry/src/ui-component/extended/Transitions.js",
    "content": "import PropTypes from 'prop-types';\nimport { forwardRef } from 'react';\n\n// material-ui\nimport { Collapse, Fade, Box, Grow, Slide, Zoom } from '@mui/material';\n\n// ==============================|| TRANSITIONS ||============================== //\n\nconst Transitions = forwardRef(({ children, position, type, direction, ...others }, ref) => {\n  let positionSX = {\n    transformOrigin: '0 0 0'\n  };\n\n  switch (position) {\n    case 'top-right':\n      positionSX = {\n        transformOrigin: 'top right'\n      };\n      break;\n    case 'top':\n      positionSX = {\n        transformOrigin: 'top'\n      };\n      break;\n    case 'bottom-left':\n      positionSX = {\n        transformOrigin: 'bottom left'\n      };\n      break;\n    case 'bottom-right':\n      positionSX = {\n        transformOrigin: 'bottom right'\n      };\n      break;\n    case 'bottom':\n      positionSX = {\n        transformOrigin: 'bottom'\n      };\n      break;\n    case 'top-left':\n    default:\n      positionSX = {\n        transformOrigin: '0 0 0'\n      };\n      break;\n  }\n\n  return (\n    <Box ref={ref}>\n      {type === 'grow' && (\n        <Grow {...others}>\n          <Box sx={positionSX}>{children}</Box>\n        </Grow>\n      )}\n      {type === 'collapse' && (\n        <Collapse {...others} sx={positionSX}>\n          {children}\n        </Collapse>\n      )}\n      {type === 'fade' && (\n        <Fade\n          {...others}\n          timeout={{\n            appear: 500,\n            enter: 600,\n            exit: 400\n          }}\n        >\n          <Box sx={positionSX}>{children}</Box>\n        </Fade>\n      )}\n      {type === 'slide' && (\n        <Slide\n          {...others}\n          timeout={{\n            appear: 0,\n            enter: 400,\n            exit: 200\n          }}\n          direction={direction}\n        >\n          <Box sx={positionSX}>{children}</Box>\n        </Slide>\n      )}\n      {type === 'zoom' && (\n        <Zoom {...others}>\n          <Box sx={positionSX}>{children}</Box>\n        </Zoom>\n      )}\n    </Box>\n  );\n});\n\nTransitions.propTypes = {\n  children: PropTypes.node,\n  type: PropTypes.oneOf(['grow', 'fade', 'collapse', 'slide', 'zoom']),\n  position: PropTypes.oneOf(['top-left', 'top-right', 'top', 'bottom-left', 'bottom-right', 'bottom']),\n  direction: PropTypes.oneOf(['up', 'down', 'left', 'right'])\n};\n\nTransitions.defaultProps = {\n  type: 'grow',\n  position: 'top-left',\n  direction: 'up'\n};\n\nexport default Transitions;\n"
  },
  {
    "path": "web/berry/src/utils/api.js",
    "content": "import { showError } from './common';\nimport axios from 'axios';\nimport { store } from 'store/index';\nimport { LOGIN } from 'store/actions';\nimport config from 'config';\n\nexport const API = axios.create({\n  baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '/'\n});\n\nAPI.interceptors.response.use(\n  (response) => response,\n  (error) => {\n    if (error.response?.status === 401) {\n      localStorage.removeItem('user');\n      store.dispatch({ type: LOGIN, payload: null });\n      window.location.href = config.basename + 'login';\n    }\n\n    if (error.response?.data?.message) {\n      error.message = error.response.data.message;\n    }\n\n    showError(error);\n  }\n);\n"
  },
  {
    "path": "web/berry/src/utils/chart.js",
    "content": "export function getLastSevenDays() {\n  const dates = [];\n  for (let i = 6; i >= 0; i--) {\n    const d = new Date();\n    d.setDate(d.getDate() - i);\n    const month = '' + (d.getMonth() + 1);\n    const day = '' + d.getDate();\n    const year = d.getFullYear();\n\n    const formattedDate = [year, month.padStart(2, '0'), day.padStart(2, '0')].join('-');\n    dates.push(formattedDate);\n  }\n  return dates;\n}\n\nexport function getTodayDay() {\n  let today = new Date();\n  return today.toISOString().slice(0, 10);\n}\n\nexport function generateChartOptions(data, unit) {\n  const dates = data.map((item) => item.date);\n  const values = data.map((item) => item.value);\n\n  const minDate = dates[0];\n  const maxDate = dates[dates.length - 1];\n\n  const minValue = Math.min(...values);\n  const maxValue = Math.max(...values);\n\n  return {\n    series: [\n      {\n        data: values\n      }\n    ],\n    type: 'line',\n    height: 90,\n    options: {\n      chart: {\n        sparkline: {\n          enabled: true\n        },\n        background: 'transparent'\n      },\n      dataLabels: {\n        enabled: false\n      },\n      colors: ['#fff'],\n      fill: {\n        type: 'solid',\n        opacity: 1\n      },\n      stroke: {\n        curve: 'smooth',\n        width: 3\n      },\n      xaxis: {\n        categories: dates,\n        labels: {\n          show: false\n        },\n        min: minDate,\n        max: maxDate\n      },\n      yaxis: {\n        min: minValue,\n        max: maxValue,\n        labels: {\n          show: false\n        }\n      },\n      tooltip: {\n        theme: 'dark',\n        fixed: {\n          enabled: false\n        },\n        x: {\n          format: 'yyyy-MM-dd'\n        },\n        y: {\n          formatter: function (val) {\n            return val + ` ${unit}`;\n          },\n          title: {\n            formatter: function () {\n              return '';\n            }\n          }\n        },\n        marker: {\n          show: false\n        }\n      }\n    }\n  };\n}\n"
  },
  {
    "path": "web/berry/src/utils/common.js",
    "content": "import {enqueueSnackbar} from 'notistack';\nimport {snackbarConstants} from 'constants/SnackbarConstants';\nimport {API} from './api';\n\nexport function getSystemName() {\n    let system_name = localStorage.getItem('system_name');\n    if (!system_name) return 'One API';\n    return system_name;\n}\n\nexport function isMobile() {\n    return window.innerWidth <= 600;\n}\n\n// eslint-disable-next-line\nexport function SnackbarHTMLContent({htmlContent}) {\n    return <div dangerouslySetInnerHTML={{__html: htmlContent}}/>;\n}\n\nexport function getSnackbarOptions(variant) {\n    let options = snackbarConstants.Common[variant];\n    if (isMobile()) {\n        // 合并 options 和 snackbarConstants.Mobile\n        options = {...options, ...snackbarConstants.Mobile};\n    }\n    return options;\n}\n\nexport function showError(error) {\n    if (error.message) {\n        if (error.name === 'AxiosError') {\n            switch (error.response.status) {\n                case 429:\n                    enqueueSnackbar('错误：请求次数过多，请稍后再试！', getSnackbarOptions('ERROR'));\n                    break;\n                case 500:\n                    enqueueSnackbar('错误：服务器内部错误，请联系管理员！', getSnackbarOptions('ERROR'));\n                    break;\n                case 405:\n                    enqueueSnackbar('本站仅作演示之用，无服务端！', getSnackbarOptions('INFO'));\n                    break;\n                default:\n                    enqueueSnackbar('错误：' + error.message, getSnackbarOptions('ERROR'));\n            }\n            return;\n        }\n    } else {\n        enqueueSnackbar('错误：' + error, getSnackbarOptions('ERROR'));\n    }\n}\n\nexport function showNotice(message, isHTML = false) {\n    if (isHTML) {\n        enqueueSnackbar(<SnackbarHTMLContent htmlContent={message}/>, getSnackbarOptions('NOTICE'));\n    } else {\n        enqueueSnackbar(message, getSnackbarOptions('NOTICE'));\n    }\n}\n\nexport function showWarning(message) {\n    enqueueSnackbar(message, getSnackbarOptions('WARNING'));\n}\n\nexport function showSuccess(message) {\n    enqueueSnackbar(message, getSnackbarOptions('SUCCESS'));\n}\n\nexport function showInfo(message) {\n    enqueueSnackbar(message, getSnackbarOptions('INFO'));\n}\n\nexport async function getOAuthState() {\n    const res = await API.get('/api/oauth/state');\n    const {success, message, data} = res.data;\n    if (success) {\n        return data;\n    } else {\n        showError(message);\n        return '';\n    }\n}\n\nexport async function onGitHubOAuthClicked(github_client_id, openInNewTab = false) {\n    const state = await getOAuthState();\n    if (!state) return;\n    let url = `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`;\n    if (openInNewTab) {\n        window.open(url);\n    } else {\n        window.location.href = url;\n    }\n}\n\nexport async function onLarkOAuthClicked(lark_client_id) {\n    const state = await getOAuthState();\n    if (!state) return;\n    let redirect_uri = `${window.location.origin}/oauth/lark`;\n    window.open(`https://accounts.feishu.cn/open-apis/authen/v1/authorize?redirect_uri=${redirect_uri}&client_id=${lark_client_id}&state=${state}`);\n}\n\nexport async function onOidcClicked(auth_url, client_id, openInNewTab = false) {\n    const state = await getOAuthState();\n    if (!state) return;\n    const redirect_uri = `${window.location.origin}/oauth/oidc`;\n    const response_type = \"code\";\n    const scope = \"openid profile email\";\n    const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;\n    if (openInNewTab) {\n        window.open(url);\n    } else {\n        window.location.href = url;\n    }\n}\n\nexport function isAdmin() {\n    let user = localStorage.getItem('user');\n    if (!user) return false;\n    user = JSON.parse(user);\n    return user.role >= 10;\n}\n\nexport function timestamp2string(timestamp) {\n    let date = new Date(timestamp * 1000);\n    let year = date.getFullYear().toString();\n    let month = (date.getMonth() + 1).toString();\n    let day = date.getDate().toString();\n    let hour = date.getHours().toString();\n    let minute = date.getMinutes().toString();\n    let second = date.getSeconds().toString();\n    if (month.length === 1) {\n        month = '0' + month;\n    }\n    if (day.length === 1) {\n        day = '0' + day;\n    }\n    if (hour.length === 1) {\n        hour = '0' + hour;\n    }\n    if (minute.length === 1) {\n        minute = '0' + minute;\n    }\n    if (second.length === 1) {\n        second = '0' + second;\n    }\n    return year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second;\n}\n\nexport function calculateQuota(quota, digits = 2) {\n    let quotaPerUnit = localStorage.getItem('quota_per_unit');\n    quotaPerUnit = parseFloat(quotaPerUnit);\n\n    return (quota / quotaPerUnit).toFixed(digits);\n}\n\nexport function renderQuota(quota, digits = 2) {\n    let displayInCurrency = localStorage.getItem('display_in_currency');\n    displayInCurrency = displayInCurrency === 'true';\n    if (displayInCurrency) {\n        return '$' + calculateQuota(quota, digits);\n    }\n    return renderNumber(quota);\n}\n\nexport const verifyJSON = (str) => {\n    try {\n        JSON.parse(str);\n    } catch (e) {\n        return false;\n    }\n    return true;\n};\n\nexport function renderNumber(num) {\n    if (num >= 1000000000) {\n        return (num / 1000000000).toFixed(1) + 'B';\n    } else if (num >= 1000000) {\n        return (num / 1000000).toFixed(1) + 'M';\n    } else if (num >= 10000) {\n        return (num / 1000).toFixed(1) + 'k';\n    } else {\n        return num;\n    }\n}\n\nexport function renderQuotaWithPrompt(quota, digits) {\n    let displayInCurrency = localStorage.getItem('display_in_currency');\n    displayInCurrency = displayInCurrency === 'true';\n    if (displayInCurrency) {\n        return `（等价金额：${renderQuota(quota, digits)}）`;\n    }\n    return '';\n}\n\nexport function downloadTextAsFile(text, filename) {\n    let blob = new Blob([text], {type: 'text/plain;charset=utf-8'});\n    let url = URL.createObjectURL(blob);\n    let a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    a.click();\n}\n\nexport function removeTrailingSlash(url) {\n    if (url.endsWith('/')) {\n        return url.slice(0, -1);\n    } else {\n        return url;\n    }\n}\n\nlet channelModels = undefined;\n\nexport async function loadChannelModels() {\n    const res = await API.get('/api/models');\n    const {success, data} = res.data;\n    if (!success) {\n        return;\n    }\n    channelModels = data;\n    localStorage.setItem('channel_models', JSON.stringify(data));\n}\n\nexport function getChannelModels(type) {\n    if (channelModels !== undefined && type in channelModels) {\n        return channelModels[type];\n    }\n    let models = localStorage.getItem('channel_models');\n    if (!models) {\n        return [];\n    }\n    channelModels = JSON.parse(models);\n    if (type in channelModels) {\n        return channelModels[type];\n    }\n    return [];\n}\n\nexport function copy(text, name = '') {\n    if (navigator.clipboard && navigator.clipboard.writeText) {\n        navigator.clipboard.writeText(text).then(() => {\n            showNotice(`复制${name}成功！`, true);\n        }, () => {\n            text = `复制${name}失败，请手动复制：<br /><br />${text}`;\n            enqueueSnackbar(<SnackbarHTMLContent htmlContent={text}/>, getSnackbarOptions('COPY'));\n        });\n    } else {\n        const textArea = document.createElement(\"textarea\");\n        textArea.value = text;\n        document.body.appendChild(textArea);\n        textArea.select();\n        try {\n            document.execCommand('copy');\n            showNotice(`复制${name}成功！`, true);\n        } catch (err) {\n            text = `复制${name}失败，请手动复制：<br /><br />${text}`;\n            enqueueSnackbar(<SnackbarHTMLContent htmlContent={text}/>, getSnackbarOptions('COPY'));\n        }\n        document.body.removeChild(textArea);\n    }\n}\n"
  },
  {
    "path": "web/berry/src/utils/password-strength.js",
    "content": "/**\n * Password validator for login pages\n */\nimport value from 'assets/scss/_themes-vars.module.scss';\n\n// has number\nconst hasNumber = (number) => new RegExp(/[0-9]/).test(number);\n\n// has mix of small and capitals\nconst hasMixed = (number) => new RegExp(/[a-z]/).test(number) && new RegExp(/[A-Z]/).test(number);\n\n// has special chars\nconst hasSpecial = (number) => new RegExp(/[!#@$%^&*)(+=._-]/).test(number);\n\n// set color based on password strength\nexport const strengthColor = (count) => {\n  if (count < 2) return { label: 'Poor', color: value.errorMain };\n  if (count < 3) return { label: 'Weak', color: value.warningDark };\n  if (count < 4) return { label: 'Normal', color: value.orangeMain };\n  if (count < 5) return { label: 'Good', color: value.successMain };\n  if (count < 6) return { label: 'Strong', color: value.successDark };\n  return { label: 'Poor', color: value.errorMain };\n};\n\n// password strength indicator\nexport const strengthIndicator = (number) => {\n  let strengths = 0;\n  if (number.length > 5) strengths += 1;\n  if (number.length > 7) strengths += 1;\n  if (hasNumber(number)) strengths += 1;\n  if (hasSpecial(number)) strengths += 1;\n  if (hasMixed(number)) strengths += 1;\n  return strengths;\n};\n"
  },
  {
    "path": "web/berry/src/utils/route-guard/AuthGuard.js",
    "content": "import { useSelector } from 'react-redux';\nimport { useEffect, useContext } from 'react';\nimport { UserContext } from 'contexts/UserContext';\nimport { useNavigate } from 'react-router-dom';\n\nconst AuthGuard = ({ children }) => {\n  const account = useSelector((state) => state.account);\n  const { isUserLoaded } = useContext(UserContext);\n  const navigate = useNavigate();\n  useEffect(() => {\n    if (isUserLoaded && !account.user) {\n      navigate('/login');\n      return;\n    }\n  }, [account, navigate, isUserLoaded]);\n\n  return children;\n};\n\nexport default AuthGuard;\n"
  },
  {
    "path": "web/berry/src/views/About/index.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { API } from 'utils/api';\nimport { showError } from 'utils/common';\nimport { marked } from 'marked';\nimport { Box, Container, Typography } from '@mui/material';\nimport MainCard from 'ui-component/cards/MainCard';\n\nconst About = () => {\n  const [about, setAbout] = useState('');\n  const [aboutLoaded, setAboutLoaded] = useState(false);\n\n  const displayAbout = async () => {\n    setAbout(localStorage.getItem('about') || '');\n    const res = await API.get('/api/about');\n    const { success, message, data } = res.data;\n    if (success) {\n      let aboutContent = data;\n      if (!data.startsWith('https://')) {\n        aboutContent = marked.parse(data);\n      }\n      setAbout(aboutContent);\n      localStorage.setItem('about', aboutContent);\n    } else {\n      showError(message);\n      setAbout('加载关于内容失败...');\n    }\n    setAboutLoaded(true);\n  };\n\n  useEffect(() => {\n    displayAbout().then();\n  }, []);\n\n  return (\n    <>\n      {aboutLoaded && about === '' ? (\n        <>\n          <Box>\n            <Container sx={{ paddingTop: '40px' }}>\n              <MainCard title=\"关于\">\n                <Typography variant=\"body2\">\n                  可在设置页面设置关于内容，支持 HTML & Markdown <br />\n                  项目仓库地址：\n                  <a href=\"https://github.com/songquanpeng/one-api\">https://github.com/songquanpeng/one-api</a>\n                </Typography>\n              </MainCard>\n            </Container>\n          </Box>\n        </>\n      ) : (\n        <>\n          <Box>\n            {about.startsWith('https://') ? (\n              <iframe title=\"about\" src={about} style={{ width: '100%', height: '100vh', border: 'none' }} />\n            ) : (\n              <>\n                <Container>\n                  <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>\n                </Container>\n              </>\n            )}\n          </Box>\n        </>\n      )}\n    </>\n  );\n};\n\nexport default About;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/ForgetPassword.js",
    "content": "import { Link } from 'react-router-dom';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport ForgetPasswordForm from '../AuthForms/ForgetPasswordForm';\nimport Logo from 'ui-component/Logo';\n\n// assets\n\n// ================================|| AUTH3 - LOGIN ||================================ //\n\nconst ForgetPassword = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            密码重置\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <ForgetPasswordForm />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Divider />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid item container direction=\"column\" alignItems=\"center\" xs={12}>\n                      <Typography component={Link} to=\"/login\" variant=\"subtitle1\" sx={{ textDecoration: 'none' }}>\n                        登录\n                      </Typography>\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default ForgetPassword;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/GitHubOAuth.js",
    "content": "import { Link, useNavigate, useSearchParams } from 'react-router-dom';\nimport React, { useEffect, useState } from 'react';\nimport { showError } from 'utils/common';\nimport useLogin from 'hooks/useLogin';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Grid, Stack, Typography, useMediaQuery, CircularProgress } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport Logo from 'ui-component/Logo';\n\n// assets\n\n// ================================|| AUTH3 - LOGIN ||================================ //\n\nconst GitHubOAuth = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  const [searchParams] = useSearchParams();\n  const [prompt, setPrompt] = useState('处理中...');\n  const { githubLogin } = useLogin();\n\n  let navigate = useNavigate();\n\n  const sendCode = async (code, state, count) => {\n    const { success, message } = await githubLogin(code, state);\n    if (!success) {\n      if (message) {\n        showError(message);\n      }\n      if (count === 0) {\n        setPrompt(`操作失败，重定向至登录界面中...`);\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n        navigate('/login');\n        return;\n      }\n      count++;\n      setPrompt(`出现错误，第 ${count} 次重试中...`);\n      await new Promise((resolve) => setTimeout(resolve, 2000));\n      await sendCode(code, state, count);\n    }\n  };\n\n  useEffect(() => {\n    let code = searchParams.get('code');\n    let state = searchParams.get('state');\n    sendCode(code, state, 0).then();\n  }, []);\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            GitHub 登录\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12} container direction=\"column\" justifyContent=\"center\" alignItems=\"center\" style={{ height: '200px' }}>\n                    <CircularProgress />\n                    <Typography variant=\"h3\" paddingTop={'20px'}>\n                      {prompt}\n                    </Typography>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default GitHubOAuth;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/LarkOAuth.js",
    "content": "import { Link, useNavigate, useSearchParams } from 'react-router-dom';\nimport React, { useEffect, useState } from 'react';\nimport { showError } from 'utils/common';\nimport useLogin from 'hooks/useLogin';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Grid, Stack, Typography, useMediaQuery, CircularProgress } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport Logo from 'ui-component/Logo';\n\n// assets\n\n// ================================|| AUTH3 - LOGIN ||================================ //\n\nconst LarkOAuth = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  const [searchParams] = useSearchParams();\n  const [prompt, setPrompt] = useState('处理中...');\n  const { larkLogin } = useLogin();\n\n  let navigate = useNavigate();\n\n  const sendCode = async (code, state, count) => {\n    const { success, message } = await larkLogin(code, state);\n    if (!success) {\n      if (message) {\n        showError(message);\n      }\n      if (count === 0) {\n        setPrompt(`操作失败，重定向至登录界面中...`);\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n        navigate('/login');\n        return;\n      }\n      count++;\n      setPrompt(`出现错误，第 ${count} 次重试中...`);\n      await new Promise((resolve) => setTimeout(resolve, 2000));\n      await sendCode(code, state, count);\n    }\n  };\n\n  useEffect(() => {\n    let code = searchParams.get('code');\n    let state = searchParams.get('state');\n    sendCode(code, state, 0).then();\n  }, []);\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            飞书 登录\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12} container direction=\"column\" justifyContent=\"center\" alignItems=\"center\" style={{ height: '200px' }}>\n                    <CircularProgress />\n                    <Typography variant=\"h3\" paddingTop={'20px'}>\n                      {prompt}\n                    </Typography>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default LarkOAuth;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/Login.js",
    "content": "import { Link } from 'react-router-dom';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport AuthLogin from '../AuthForms/AuthLogin';\nimport Logo from 'ui-component/Logo';\n\n// ================================|| AUTH3 - LOGIN ||================================ //\n\nconst Login = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            登录\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <AuthLogin />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Divider />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid item container direction=\"column\" alignItems=\"center\" xs={12}>\n                      <Typography component={Link} to=\"/register\" variant=\"subtitle1\" sx={{ textDecoration: 'none' }}>\n                        注册\n                      </Typography>\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default Login;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/OidcOAuth.js",
    "content": "import { Link, useNavigate, useSearchParams } from 'react-router-dom';\nimport React, { useEffect, useState } from 'react';\nimport { showError } from 'utils/common';\nimport useLogin from 'hooks/useLogin';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Grid, Stack, Typography, useMediaQuery, CircularProgress } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport Logo from 'ui-component/Logo';\n\n// assets\n\n// ================================|| AUTH3 - LOGIN ||================================ //\n\nconst OidcOAuth = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  const [searchParams] = useSearchParams();\n  const [prompt, setPrompt] = useState('处理中...');\n  const { oidcLogin } = useLogin();\n\n  let navigate = useNavigate();\n\n  const sendCode = async (code, state, count) => {\n    const { success, message } = await oidcLogin(code, state);\n    if (!success) {\n      if (message) {\n        showError(message);\n      }\n      if (count === 0) {\n        setPrompt(`操作失败，重定向至登录界面中...`);\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n        navigate('/login');\n        return;\n      }\n      count++;\n      setPrompt(`出现错误，第 ${count} 次重试中...`);\n      await new Promise((resolve) => setTimeout(resolve, 2000));\n      await sendCode(code, state, count);\n    }\n  };\n\n  useEffect(() => {\n    let code = searchParams.get('code');\n    let state = searchParams.get('state');\n    sendCode(code, state, 0).then();\n  }, []);\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            OIDC 登录\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12} container direction=\"column\" justifyContent=\"center\" alignItems=\"center\" style={{ height: '200px' }}>\n                    <CircularProgress />\n                    <Typography variant=\"h3\" paddingTop={'20px'}>\n                      {prompt}\n                    </Typography>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default OidcOAuth;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/Register.js",
    "content": "import { Link } from 'react-router-dom';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport Logo from 'ui-component/Logo';\nimport AuthRegister from '../AuthForms/AuthRegister';\n\n// assets\n\n// ===============================|| AUTH3 - REGISTER ||=============================== //\n\nconst Register = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            注册\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <AuthRegister />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Divider />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid item container direction=\"column\" alignItems=\"center\" xs={12}>\n                      <Typography component={Link} to=\"/login\" variant=\"subtitle1\" sx={{ textDecoration: 'none' }}>\n                        已经有帐号了？点击登录\n                      </Typography>\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n        {/* <Grid item xs={12} sx={{ m: 3, mt: 1 }}>\n          <AuthFooter />\n        </Grid> */}\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default Register;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/Auth/ResetPassword.js",
    "content": "import { Link } from 'react-router-dom';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport { Divider, Grid, Stack, Typography, useMediaQuery } from '@mui/material';\n\n// project imports\nimport AuthWrapper from '../AuthWrapper';\nimport AuthCardWrapper from '../AuthCardWrapper';\nimport ResetPasswordForm from '../AuthForms/ResetPasswordForm';\nimport Logo from 'ui-component/Logo';\n\n// ================================|| AUTH3 - LOGIN ||================================ //\n\nconst ResetPassword = () => {\n  const theme = useTheme();\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n\n  return (\n    <AuthWrapper>\n      <Grid container direction=\"column\" justifyContent=\"flex-end\">\n        <Grid item xs={12}>\n          <Grid container justifyContent=\"center\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 136px)' }}>\n            <Grid item sx={{ m: { xs: 1, sm: 3 }, mb: 0 }}>\n              <AuthCardWrapper>\n                <Grid container spacing={2} alignItems=\"center\" justifyContent=\"center\">\n                  <Grid item sx={{ mb: 3 }}>\n                    <Link to=\"#\">\n                      <Logo />\n                    </Link>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid container direction={matchDownSM ? 'column-reverse' : 'row'} alignItems=\"center\" justifyContent=\"center\">\n                      <Grid item>\n                        <Stack alignItems=\"center\" justifyContent=\"center\" spacing={1}>\n                          <Typography color={theme.palette.primary.main} gutterBottom variant={matchDownSM ? 'h3' : 'h2'}>\n                            密码重置确认\n                          </Typography>\n                        </Stack>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={12}>\n                    <ResetPasswordForm />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Divider />\n                  </Grid>\n                  <Grid item xs={12}>\n                    <Grid item container direction=\"column\" alignItems=\"center\" xs={12}>\n                      <Typography component={Link} to=\"/login\" variant=\"subtitle1\" sx={{ textDecoration: 'none' }}>\n                        登录\n                      </Typography>\n                    </Grid>\n                  </Grid>\n                </Grid>\n              </AuthCardWrapper>\n            </Grid>\n          </Grid>\n        </Grid>\n      </Grid>\n    </AuthWrapper>\n  );\n};\n\nexport default ResetPassword;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthCardWrapper.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { Box } from '@mui/material';\n\n// project import\nimport MainCard from 'ui-component/cards/MainCard';\n\n// ==============================|| AUTHENTICATION CARD WRAPPER ||============================== //\n\nconst AuthCardWrapper = ({ children, ...other }) => (\n  <MainCard\n    sx={{\n      maxWidth: { xs: 400, lg: 475 },\n      margin: { xs: 2.5, md: 3 },\n      '& > *': {\n        flexGrow: 1,\n        flexBasis: '50%'\n      }\n    }}\n    content={false}\n    {...other}\n  >\n    <Box sx={{ p: { xs: 2, sm: 3, xl: 5 } }}>{children}</Box>\n  </MainCard>\n);\n\nAuthCardWrapper.propTypes = {\n  children: PropTypes.node\n};\n\nexport default AuthCardWrapper;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthForms/AuthLogin.js",
    "content": "import { useState } from 'react';\nimport { useSelector } from 'react-redux';\nimport { Link } from 'react-router-dom';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport {\n  Box,\n  Button,\n  Divider,\n  FormControl,\n  FormHelperText,\n  Grid,\n  IconButton,\n  InputAdornment,\n  InputLabel,\n  OutlinedInput,\n  Stack,\n  Typography,\n  useMediaQuery\n} from '@mui/material';\n\n// third party\nimport * as Yup from 'yup';\nimport { Formik } from 'formik';\n\n// project imports\nimport useLogin from 'hooks/useLogin';\nimport AnimateButton from 'ui-component/extended/AnimateButton';\nimport WechatModal from 'views/Authentication/AuthForms/WechatModal';\n\n// assets\nimport Visibility from '@mui/icons-material/Visibility';\nimport VisibilityOff from '@mui/icons-material/VisibilityOff';\n\nimport Github from 'assets/images/icons/github.svg';\nimport Wechat from 'assets/images/icons/wechat.svg';\nimport Lark from 'assets/images/icons/lark.svg';\nimport OIDC from 'assets/images/icons/oidc.svg';\nimport { onGitHubOAuthClicked, onLarkOAuthClicked, onOidcClicked } from 'utils/common';\n\n// ============================|| FIREBASE - LOGIN ||============================ //\n\nconst LoginForm = ({ ...others }) => {\n  const theme = useTheme();\n  const { login, wechatLogin } = useLogin();\n  const [openWechat, setOpenWechat] = useState(false);\n  const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));\n  const customization = useSelector((state) => state.customization);\n  const siteInfo = useSelector((state) => state.siteInfo);\n  // const [checked, setChecked] = useState(true);\n\n  let tripartiteLogin = false;\n  if (siteInfo.github_oauth || siteInfo.wechat_login || siteInfo.lark_client_id || siteInfo.oidc) {\n    tripartiteLogin = true;\n  }\n\n  const handleWechatOpen = () => {\n    setOpenWechat(true);\n  };\n\n  const handleWechatClose = () => {\n    setOpenWechat(false);\n  };\n\n  const [showPassword, setShowPassword] = useState(false);\n  const handleClickShowPassword = () => {\n    setShowPassword(!showPassword);\n  };\n\n  const handleMouseDownPassword = (event) => {\n    event.preventDefault();\n  };\n\n  return (\n    <>\n      {tripartiteLogin && (\n        <Grid container direction=\"column\" justifyContent=\"center\" spacing={2}>\n          {siteInfo.github_oauth && (\n            <Grid item xs={12}>\n              <AnimateButton>\n                <Button\n                  disableElevation\n                  fullWidth\n                  onClick={() => onGitHubOAuthClicked(siteInfo.github_client_id)}\n                  size=\"large\"\n                  variant=\"outlined\"\n                  sx={{\n                    color: 'grey.700',\n                    backgroundColor: theme.palette.grey[50],\n                    borderColor: theme.palette.grey[100]\n                  }}\n                >\n                  <Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>\n                    <img src={Github} alt=\"github\" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />\n                  </Box>\n                  使用 GitHub 登录\n                </Button>\n              </AnimateButton>\n            </Grid>\n          )}\n          {siteInfo.wechat_login && (\n            <Grid item xs={12}>\n              <AnimateButton>\n                <Button\n                  disableElevation\n                  fullWidth\n                  onClick={handleWechatOpen}\n                  size=\"large\"\n                  variant=\"outlined\"\n                  sx={{\n                    color: 'grey.700',\n                    backgroundColor: theme.palette.grey[50],\n                    borderColor: theme.palette.grey[100]\n                  }}\n                >\n                  <Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>\n                    <img src={Wechat} alt=\"Wechat\" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />\n                  </Box>\n                  使用微信登录\n                </Button>\n              </AnimateButton>\n              <WechatModal open={openWechat} handleClose={handleWechatClose} wechatLogin={wechatLogin} qrCode={siteInfo.wechat_qrcode} />\n            </Grid>\n          )}\n          {siteInfo.lark_client_id && (\n            <Grid item xs={12}>\n              <AnimateButton>\n                <Button\n                  disableElevation\n                  fullWidth\n                  onClick={() => onLarkOAuthClicked(siteInfo.lark_client_id)}\n                  size=\"large\"\n                  variant=\"outlined\"\n                  sx={{\n                    color: 'grey.700',\n                    backgroundColor: theme.palette.grey[50],\n                    borderColor: theme.palette.grey[100]\n                  }}\n                >\n                  <Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>\n                    <img src={Lark} alt=\"Lark\" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />\n                  </Box>\n                  使用飞书登录\n                </Button>\n              </AnimateButton>\n            </Grid>\n          )}\n          {siteInfo.oidc && (\n            <Grid item xs={12}>\n              <AnimateButton>\n                <Button\n                  disableElevation\n                  fullWidth\n                  onClick={() => onOidcClicked(siteInfo.oidc_authorization_endpoint,siteInfo.oidc_client_id)}\n                  size=\"large\"\n                  variant=\"outlined\"\n                  sx={{\n                    color: 'grey.700',\n                    backgroundColor: theme.palette.grey[50],\n                    borderColor: theme.palette.grey[100]\n                  }}\n                >\n                  <Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>\n                    <img src={OIDC} alt=\"Lark\" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />\n                  </Box>\n                  使用 OIDC 登录\n                </Button>\n              </AnimateButton>\n            </Grid>\n          )}\n          <Grid item xs={12}>\n            <Box\n              sx={{\n                alignItems: 'center',\n                display: 'flex'\n              }}\n            >\n              <Divider sx={{ flexGrow: 1 }} orientation=\"horizontal\" />\n\n              <Button\n                variant=\"outlined\"\n                sx={{\n                  cursor: 'unset',\n                  m: 2,\n                  py: 0.5,\n                  px: 7,\n                  borderColor: `${theme.palette.grey[100]} !important`,\n                  color: `${theme.palette.grey[900]}!important`,\n                  fontWeight: 500,\n                  borderRadius: `${customization.borderRadius}px`\n                }}\n                disableRipple\n                disabled\n              >\n                OR\n              </Button>\n\n              <Divider sx={{ flexGrow: 1 }} orientation=\"horizontal\" />\n            </Box>\n          </Grid>\n        </Grid>\n      )}\n\n      <Formik\n        initialValues={{\n          username: '',\n          password: '',\n          submit: null\n        }}\n        validationSchema={Yup.object().shape({\n          username: Yup.string().max(255).required('Username is required'),\n          password: Yup.string().max(255).required('Password is required')\n        })}\n        onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {\n          const { success, message } = await login(values.username, values.password);\n          if (success) {\n            setStatus({ success: true });\n          } else {\n            setStatus({ success: false });\n            if (message) {\n              setErrors({ submit: message });\n            }\n          }\n          setSubmitting(false);\n        }}\n      >\n        {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => (\n          <form noValidate onSubmit={handleSubmit} {...others}>\n            <FormControl fullWidth error={Boolean(touched.username && errors.username)} sx={{ ...theme.typography.customInput }}>\n              <InputLabel htmlFor=\"outlined-adornment-username-login\">用户名 / 邮箱</InputLabel>\n              <OutlinedInput\n                id=\"outlined-adornment-username-login\"\n                type=\"text\"\n                value={values.username}\n                name=\"username\"\n                onBlur={handleBlur}\n                onChange={handleChange}\n                label=\"用户名\"\n                inputProps={{ autoComplete: 'username' }}\n              />\n              {touched.username && errors.username && (\n                <FormHelperText error id=\"standard-weight-helper-text-username-login\">\n                  {errors.username}\n                </FormHelperText>\n              )}\n            </FormControl>\n\n            <FormControl fullWidth error={Boolean(touched.password && errors.password)} sx={{ ...theme.typography.customInput }}>\n              <InputLabel htmlFor=\"outlined-adornment-password-login\">密码</InputLabel>\n              <OutlinedInput\n                id=\"outlined-adornment-password-login\"\n                type={showPassword ? 'text' : 'password'}\n                value={values.password}\n                name=\"password\"\n                onBlur={handleBlur}\n                onChange={handleChange}\n                endAdornment={\n                  <InputAdornment position=\"end\">\n                    <IconButton\n                      aria-label=\"toggle password visibility\"\n                      onClick={handleClickShowPassword}\n                      onMouseDown={handleMouseDownPassword}\n                      edge=\"end\"\n                      size=\"large\"\n                    >\n                      {showPassword ? <Visibility /> : <VisibilityOff />}\n                    </IconButton>\n                  </InputAdornment>\n                }\n                label=\"Password\"\n              />\n              {touched.password && errors.password && (\n                <FormHelperText error id=\"standard-weight-helper-text-password-login\">\n                  {errors.password}\n                </FormHelperText>\n              )}\n            </FormControl>\n            <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"space-between\" spacing={1}>\n              {/* <FormControlLabel\n                control={\n                  <Checkbox checked={checked} onChange={(event) => setChecked(event.target.checked)} name=\"checked\" color=\"primary\" />\n                }\n                label=\"记住我\"\n              /> */}\n              <Typography\n                component={Link}\n                to=\"/reset\"\n                variant=\"subtitle1\"\n                color=\"primary\"\n                sx={{ textDecoration: 'none', cursor: 'pointer' }}\n              >\n                忘记密码?\n              </Typography>\n            </Stack>\n            {errors.submit && (\n              <Box sx={{ mt: 3 }}>\n                <FormHelperText error>{errors.submit}</FormHelperText>\n              </Box>\n            )}\n\n            <Box sx={{ mt: 2 }}>\n              <AnimateButton>\n                <Button disableElevation disabled={isSubmitting} fullWidth size=\"large\" type=\"submit\" variant=\"contained\" color=\"primary\">\n                  登录\n                </Button>\n              </AnimateButton>\n            </Box>\n          </form>\n        )}\n      </Formik>\n    </>\n  );\n};\n\nexport default LoginForm;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthForms/AuthRegister.js",
    "content": "import { useState, useEffect } from 'react';\nimport { useSelector } from 'react-redux';\nimport useRegister from 'hooks/useRegister';\nimport Turnstile from 'react-turnstile';\nimport { useSearchParams } from 'react-router-dom';\n// import { useSelector } from 'react-redux';\n\n// material-ui\nimport { useTheme } from '@mui/material/styles';\nimport {\n  Box,\n  Button,\n  FormControl,\n  FormHelperText,\n  Grid,\n  IconButton,\n  InputAdornment,\n  InputLabel,\n  OutlinedInput,\n  Typography\n} from '@mui/material';\n\n// third party\nimport * as Yup from 'yup';\nimport { Formik } from 'formik';\n\n// project imports\nimport AnimateButton from 'ui-component/extended/AnimateButton';\nimport { strengthColor, strengthIndicator } from 'utils/password-strength';\n\n// assets\nimport Visibility from '@mui/icons-material/Visibility';\nimport VisibilityOff from '@mui/icons-material/VisibilityOff';\nimport { showError, showInfo } from 'utils/common';\n\n// ===========================|| FIREBASE - REGISTER ||=========================== //\n\nconst RegisterForm = ({ ...others }) => {\n  const theme = useTheme();\n  const { register, sendVerificationCode } = useRegister();\n  const siteInfo = useSelector((state) => state.siteInfo);\n  const [showPassword, setShowPassword] = useState(false);\n  const [searchParams] = useSearchParams();\n\n  const [showEmailVerification, setShowEmailVerification] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n\n  const [strength, setStrength] = useState(0);\n  const [level, setLevel] = useState();\n\n  const handleClickShowPassword = () => {\n    setShowPassword(!showPassword);\n  };\n\n  const handleMouseDownPassword = (event) => {\n    event.preventDefault();\n  };\n\n  const changePassword = (value) => {\n    const temp = strengthIndicator(value);\n    setStrength(temp);\n    setLevel(strengthColor(temp));\n  };\n\n  const handleSendCode = async (email) => {\n    if (email === '') {\n      showError('请输入邮箱');\n      return;\n    }\n    if (turnstileEnabled && turnstileToken === '') {\n      showError('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n\n    const { success, message } = await sendVerificationCode(email, turnstileToken);\n    if (!success) {\n      showError(message);\n      return;\n    }\n  };\n\n  useEffect(() => {\n    let affCode = searchParams.get('aff');\n    if (affCode) {\n      localStorage.setItem('aff', affCode);\n    }\n\n    setShowEmailVerification(siteInfo.email_verification);\n    if (siteInfo.turnstile_check) {\n      setTurnstileEnabled(true);\n      setTurnstileSiteKey(siteInfo.turnstile_site_key);\n    }\n  }, [siteInfo]);\n\n  return (\n    <>\n      <Formik\n        initialValues={{\n          username: '',\n          password: '',\n          confirmPassword: '',\n          email: showEmailVerification ? '' : undefined,\n          verification_code: showEmailVerification ? '' : undefined,\n          submit: null\n        }}\n        validationSchema={Yup.object().shape({\n          username: Yup.string().max(255).required('用户名是必填项'),\n          password: Yup.string().max(255).required('密码是必填项'),\n          confirmPassword: Yup.string()\n            .required('确认密码是必填项')\n            .oneOf([Yup.ref('password'), null], '两次输入的密码不一致'),\n          email: showEmailVerification ? Yup.string().email('必须是有效的Email地址').max(255).required('Email是必填项') : Yup.mixed(),\n          verification_code: showEmailVerification ? Yup.string().max(255).required('验证码是必填项') : Yup.mixed()\n        })}\n        onSubmit={async (values, { setErrors, setStatus, setSubmitting }) => {\n          if (turnstileEnabled && turnstileToken === '') {\n            showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n            setSubmitting(false);\n            return;\n          }\n\n          const { success, message } = await register(values, turnstileToken);\n          if (success) {\n            setStatus({ success: true });\n          } else {\n            setStatus({ success: false });\n            if (message) {\n              setErrors({ submit: message });\n            }\n          }\n        }}\n      >\n        {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values }) => (\n          <form noValidate onSubmit={handleSubmit} {...others}>\n            <FormControl fullWidth error={Boolean(touched.username && errors.username)} sx={{ ...theme.typography.customInput }}>\n              <InputLabel htmlFor=\"outlined-adornment-username-register\">用户名</InputLabel>\n              <OutlinedInput\n                id=\"outlined-adornment-username-register\"\n                type=\"text\"\n                value={values.username}\n                name=\"username\"\n                onBlur={handleBlur}\n                onChange={handleChange}\n                inputProps={{ autoComplete: 'username' }}\n              />\n              {touched.username && errors.username && (\n                <FormHelperText error id=\"standard-weight-helper-text--register\">\n                  {errors.username}\n                </FormHelperText>\n              )}\n            </FormControl>\n\n            <FormControl fullWidth error={Boolean(touched.password && errors.password)} sx={{ ...theme.typography.customInput }}>\n              <InputLabel htmlFor=\"outlined-adornment-password-register\">密码</InputLabel>\n              <OutlinedInput\n                id=\"outlined-adornment-password-register\"\n                type={showPassword ? 'text' : 'password'}\n                value={values.password}\n                name=\"password\"\n                label=\"Password\"\n                onBlur={handleBlur}\n                onChange={(e) => {\n                  handleChange(e);\n                  changePassword(e.target.value);\n                }}\n                endAdornment={\n                  <InputAdornment position=\"end\">\n                    <IconButton\n                      aria-label=\"toggle password visibility\"\n                      onClick={handleClickShowPassword}\n                      onMouseDown={handleMouseDownPassword}\n                      edge=\"end\"\n                      size=\"large\"\n                      color={'primary'}\n                    >\n                      {showPassword ? <Visibility /> : <VisibilityOff />}\n                    </IconButton>\n                  </InputAdornment>\n                }\n                inputProps={{}}\n              />\n              {touched.password && errors.password && (\n                <FormHelperText error id=\"standard-weight-helper-text-password-register\">\n                  {errors.password}\n                </FormHelperText>\n              )}\n            </FormControl>\n            <FormControl\n              fullWidth\n              error={Boolean(touched.confirmPassword && errors.confirmPassword)}\n              sx={{ ...theme.typography.customInput }}\n            >\n              <InputLabel htmlFor=\"outlined-adornment-confirm-password-register\">确认密码</InputLabel>\n              <OutlinedInput\n                id=\"outlined-adornment-confirm-password-register\"\n                type={showPassword ? 'text' : 'password'}\n                value={values.confirmPassword}\n                name=\"confirmPassword\"\n                label=\"Confirm Password\"\n                onBlur={handleBlur}\n                onChange={handleChange}\n                inputProps={{}}\n              />\n              {touched.confirmPassword && errors.confirmPassword && (\n                <FormHelperText error id=\"standard-weight-helper-text-confirm-password-register\">\n                  {errors.confirmPassword}\n                </FormHelperText>\n              )}\n            </FormControl>\n\n            {strength !== 0 && (\n              <FormControl fullWidth>\n                <Box sx={{ mb: 2 }}>\n                  <Grid container spacing={2} alignItems=\"center\">\n                    <Grid item>\n                      <Box style={{ backgroundColor: level?.color }} sx={{ width: 85, height: 8, borderRadius: '7px' }} />\n                    </Grid>\n                    <Grid item>\n                      <Typography variant=\"subtitle1\" fontSize=\"0.75rem\">\n                        {level?.label}\n                      </Typography>\n                    </Grid>\n                  </Grid>\n                </Box>\n              </FormControl>\n            )}\n\n            {showEmailVerification && (\n              <>\n                <FormControl fullWidth error={Boolean(touched.email && errors.email)} sx={{ ...theme.typography.customInput }}>\n                  <InputLabel htmlFor=\"outlined-adornment-email-register\">Email</InputLabel>\n                  <OutlinedInput\n                    id=\"outlined-adornment-email-register\"\n                    type=\"text\"\n                    value={values.email}\n                    name=\"email\"\n                    onBlur={handleBlur}\n                    onChange={handleChange}\n                    endAdornment={\n                      <InputAdornment position=\"end\">\n                        <Button variant=\"contained\" color=\"primary\" onClick={() => handleSendCode(values.email)}>\n                          发送验证码\n                        </Button>\n                      </InputAdornment>\n                    }\n                    inputProps={{}}\n                  />\n                  {touched.email && errors.email && (\n                    <FormHelperText error id=\"standard-weight-helper-text--register\">\n                      {errors.email}\n                    </FormHelperText>\n                  )}\n                </FormControl>\n                <FormControl\n                  fullWidth\n                  error={Boolean(touched.verification_code && errors.verification_code)}\n                  sx={{ ...theme.typography.customInput }}\n                >\n                  <InputLabel htmlFor=\"outlined-adornment-verification_code-register\">验证码</InputLabel>\n                  <OutlinedInput\n                    id=\"outlined-adornment-verification_code-register\"\n                    type=\"text\"\n                    value={values.verification_code}\n                    name=\"verification_code\"\n                    onBlur={handleBlur}\n                    onChange={handleChange}\n                    inputProps={{}}\n                  />\n                  {touched.verification_code && errors.verification_code && (\n                    <FormHelperText error id=\"standard-weight-helper-text--register\">\n                      {errors.verification_code}\n                    </FormHelperText>\n                  )}\n                </FormControl>\n              </>\n            )}\n\n            {errors.submit && (\n              <Box sx={{ mt: 3 }}>\n                <FormHelperText error>{errors.submit}</FormHelperText>\n              </Box>\n            )}\n            {turnstileEnabled ? (\n              <Turnstile\n                sitekey={turnstileSiteKey}\n                onVerify={(token) => {\n                  setTurnstileToken(token);\n                }}\n              />\n            ) : (\n              <></>\n            )}\n\n            <Box sx={{ mt: 2 }}>\n              <AnimateButton>\n                <Button disableElevation disabled={isSubmitting} fullWidth size=\"large\" type=\"submit\" variant=\"contained\" color=\"primary\">\n                  注册\n                </Button>\n              </AnimateButton>\n            </Box>\n          </form>\n        )}\n      </Formik>\n    </>\n  );\n};\n\nexport default RegisterForm;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthForms/ForgetPasswordForm.js",
    "content": "import { useState, useEffect } from \"react\";\nimport { useSelector } from \"react-redux\";\nimport Turnstile from \"react-turnstile\";\nimport { API } from \"utils/api\";\n\n// material-ui\nimport { useTheme } from \"@mui/material/styles\";\nimport {\n  Box,\n  Button,\n  FormControl,\n  FormHelperText,\n  InputLabel,\n  OutlinedInput,\n  Typography,\n} from \"@mui/material\";\n\n// third party\nimport * as Yup from \"yup\";\nimport { Formik } from \"formik\";\n\n// project imports\nimport AnimateButton from \"ui-component/extended/AnimateButton\";\n\n// assets\nimport { showError, showInfo, showSuccess } from \"utils/common\";\n\n// ===========================|| FIREBASE - REGISTER ||=========================== //\n\nconst ForgetPasswordForm = ({ ...others }) => {\n  const theme = useTheme();\n  const siteInfo = useSelector((state) => state.siteInfo);\n\n  const [sendEmail, setSendEmail] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState(\"\");\n  const [turnstileToken, setTurnstileToken] = useState(\"\");\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n\n  const submit = async (values, { setSubmitting }) => {\n    setDisableButton(true);\n    setSubmitting(true);\n    if (turnstileEnabled && turnstileToken === \"\") {\n      showInfo(\"请稍后几秒重试，Turnstile 正在检查用户环境！\");\n      setSubmitting(false);\n      return;\n    }\n    const res = await API.get(\n      `/api/reset_password?email=${values.email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(\"重置邮件发送成功，请检查邮箱！\");\n      setSendEmail(true);\n    } else {\n      showError(message);\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    setSubmitting(false);\n  };\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval);\n  }, [disableButton, countdown]);\n\n  useEffect(() => {\n    if (siteInfo.turnstile_check) {\n      setTurnstileEnabled(true);\n      setTurnstileSiteKey(siteInfo.turnstile_site_key);\n    }\n  }, [siteInfo]);\n\n  return (\n    <>\n      {sendEmail ? (\n        <Typography variant=\"h3\" padding={\"20px\"}>\n          重置邮件发送成功，请检查邮箱！\n        </Typography>\n      ) : (\n        <Formik\n          initialValues={{\n            email: \"\",\n          }}\n          validationSchema={Yup.object().shape({\n            email: Yup.string()\n              .email(\"必须是有效的Email地址\")\n              .max(255)\n              .required(\"Email是必填项\"),\n          })}\n          onSubmit={submit}\n        >\n          {({\n            errors,\n            handleBlur,\n            handleChange,\n            handleSubmit,\n            isSubmitting,\n            touched,\n            values,\n          }) => (\n            <form noValidate onSubmit={handleSubmit} {...others}>\n              <FormControl\n                fullWidth\n                error={Boolean(touched.email && errors.email)}\n                sx={{ ...theme.typography.customInput }}\n              >\n                <InputLabel htmlFor=\"outlined-adornment-email-register\">\n                  Email\n                </InputLabel>\n                <OutlinedInput\n                  id=\"outlined-adornment-email-register\"\n                  type=\"text\"\n                  value={values.email}\n                  name=\"email\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{}}\n                />\n                {touched.email && errors.email && (\n                  <FormHelperText\n                    error\n                    id=\"standard-weight-helper-text--register\"\n                  >\n                    {errors.email}\n                  </FormHelperText>\n                )}\n              </FormControl>\n\n              {turnstileEnabled ? (\n                <Turnstile\n                  sitekey={turnstileSiteKey}\n                  onVerify={(token) => {\n                    setTurnstileToken(token);\n                  }}\n                />\n              ) : (\n                <></>\n              )}\n\n              <Box sx={{ mt: 2 }}>\n                <AnimateButton>\n                  <Button\n                    disableElevation\n                    disabled={isSubmitting || disableButton}\n                    fullWidth\n                    size=\"large\"\n                    type=\"submit\"\n                    variant=\"contained\"\n                    color=\"primary\"\n                  >\n                    {disableButton ? `重试 (${countdown})` : \"提交\"}\n                  </Button>\n                </AnimateButton>\n              </Box>\n            </form>\n          )}\n        </Formik>\n      )}\n    </>\n  );\n};\n\nexport default ForgetPasswordForm;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthForms/ResetPasswordForm.js",
    "content": "import { useState, useEffect } from 'react';\nimport { useSearchParams } from 'react-router-dom';\n\n// material-ui\nimport { Button, Stack, Typography, Alert } from '@mui/material';\n\n// assets\nimport { showError, copy } from 'utils/common';\nimport { API } from 'utils/api';\n\n// ===========================|| FIREBASE - REGISTER ||=========================== //\n\nconst ResetPasswordForm = () => {\n  const [searchParams] = useSearchParams();\n  const [inputs, setInputs] = useState({\n    email: '',\n    token: ''\n  });\n  const [newPassword, setNewPassword] = useState('');\n\n  const submit = async () => {\n    const res = await API.post(`/api/user/reset`, inputs);\n    const { success, message } = res.data;\n    if (success) {\n      let password = res.data.data;\n      setNewPassword(password);\n      copy(password, '新密码');\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    let email = searchParams.get('email');\n    let token = searchParams.get('token');\n    setInputs({\n      token,\n      email\n    });\n  }, []);\n\n  return (\n    <Stack spacing={3} padding={'24px'} justifyContent={'center'} alignItems={'center'}>\n      {!inputs.email || !inputs.token ? (\n        <Typography variant=\"h3\" sx={{ textDecoration: 'none' }}>\n          无效的链接\n        </Typography>\n      ) : newPassword ? (\n        <Alert severity=\"error\">\n          你的新密码是: <b>{newPassword}</b> <br />\n          请登录后及时修改密码\n        </Alert>\n      ) : (\n        <Button fullWidth onClick={submit} size=\"large\" type=\"submit\" variant=\"contained\" color=\"primary\">\n          点击重置密码\n        </Button>\n      )}\n    </Stack>\n  );\n};\n\nexport default ResetPasswordForm;\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthForms/WechatModal.js",
    "content": "// WechatModal.js\nimport PropTypes from 'prop-types';\nimport React from 'react';\nimport { Dialog, DialogTitle, DialogContent, TextField, Button, Typography, Grid } from '@mui/material';\nimport { Formik, Form, Field } from 'formik';\nimport { showError } from 'utils/common';\nimport * as Yup from 'yup';\n\nconst validationSchema = Yup.object().shape({\n  code: Yup.string().required('验证码不能为空')\n});\n\nconst WechatModal = ({ open, handleClose, wechatLogin, qrCode }) => {\n  const handleSubmit = (values) => {\n    const { success, message } = wechatLogin(values.code);\n    if (success) {\n      handleClose();\n    } else {\n      showError(message || '未知错误');\n    }\n  };\n\n  return (\n    <Dialog open={open} onClose={handleClose}>\n      <DialogTitle>微信验证码登录</DialogTitle>\n      <DialogContent>\n        <Grid container direction=\"column\" alignItems=\"center\">\n          <img src={qrCode} alt=\"二维码\" style={{ maxWidth: '300px', maxHeight: '300px', width: 'auto', height: 'auto' }} />\n          <Typography\n            variant=\"body2\"\n            color=\"text.secondary\"\n            style={{ marginTop: '10px', textAlign: 'center', wordWrap: 'break-word', maxWidth: '300px' }}\n          >\n            请使用微信扫描二维码关注公众号，输入「验证码」获取验证码（三分钟内有效）\n          </Typography>\n          <Formik initialValues={{ code: '' }} validationSchema={validationSchema} onSubmit={handleSubmit}>\n            {({ errors, touched }) => (\n              <Form style={{ width: '100%' }}>\n                <Grid item xs={12}>\n                  <Field\n                    as={TextField}\n                    name=\"code\"\n                    label=\"验证码\"\n                    error={touched.code && Boolean(errors.code)}\n                    helperText={touched.code && errors.code}\n                    fullWidth\n                  />\n                </Grid>\n                <Grid item xs={12}>\n                  <Button type=\"submit\" fullWidth>\n                    提交\n                  </Button>\n                </Grid>\n              </Form>\n            )}\n          </Formik>\n        </Grid>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default WechatModal;\n\nWechatModal.propTypes = {\n  open: PropTypes.bool,\n  handleClose: PropTypes.func,\n  wechatLogin: PropTypes.func,\n  qrCode: PropTypes.string\n};\n"
  },
  {
    "path": "web/berry/src/views/Authentication/AuthWrapper.js",
    "content": "// material-ui\nimport { styled } from '@mui/material/styles';\nimport { useSelector } from 'react-redux';\nimport { useNavigate } from 'react-router';\nimport { useEffect, useContext } from 'react';\nimport { UserContext } from 'contexts/UserContext';\n\n// ==============================|| AUTHENTICATION 1 WRAPPER ||============================== //\n\nconst AuthStyle = styled('div')(({ theme }) => ({\n  backgroundColor: theme.palette.background.default\n}));\n\n// eslint-disable-next-line\nconst AuthWrapper = ({ children }) => {\n  const account = useSelector((state) => state.account);\n  const { isUserLoaded } = useContext(UserContext);\n  const navigate = useNavigate();\n  useEffect(() => {\n    if (isUserLoaded && account.user) {\n      navigate('/panel');\n    }\n  }, [account, navigate, isUserLoaded]);\n\n  return <AuthStyle> {children} </AuthStyle>;\n};\n\nexport default AuthWrapper;\n"
  },
  {
    "path": "web/berry/src/views/Channel/component/EditModal.js",
    "content": "import PropTypes from 'prop-types';\nimport { useState, useEffect } from 'react';\nimport { CHANNEL_OPTIONS } from 'constants/ChannelConstants';\nimport { useTheme } from '@mui/material/styles';\nimport { API } from 'utils/api';\nimport { showError, showSuccess, getChannelModels } from 'utils/common';\nimport {\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  TextField,\n  Button,\n  Divider,\n  Select,\n  MenuItem,\n  FormControl,\n  InputLabel,\n  OutlinedInput,\n  ButtonGroup,\n  Container,\n  Autocomplete,\n  FormHelperText,\n  Switch,\n  Checkbox\n} from '@mui/material';\n\nimport { Formik } from 'formik';\nimport * as Yup from 'yup';\nimport { defaultConfig, typeConfig } from '../type/Config'; //typeConfig\nimport { createFilterOptions } from '@mui/material/Autocomplete';\nimport CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';\nimport CheckBoxIcon from '@mui/icons-material/CheckBox';\n\nconst icon = <CheckBoxOutlineBlankIcon fontSize=\"small\" />;\nconst checkedIcon = <CheckBoxIcon fontSize=\"small\" />;\n\nconst filter = createFilterOptions();\nconst validationSchema = Yup.object().shape({\n  is_edit: Yup.boolean(),\n  name: Yup.string().required('名称 不能为空'),\n  type: Yup.number().required('渠道 不能为空'),\n  key: Yup.string().when(['is_edit', 'type'], {\n    is: (is_edit, type) => !is_edit && type !== 33,\n    then: Yup.string().required('密钥 不能为空')\n  }),\n  other: Yup.string(),\n  models: Yup.array().min(1, '模型 不能为空'),\n  groups: Yup.array().min(1, '用户组 不能为空'),\n  base_url: Yup.string().when('type', {\n    is: (value) => [3, 8].includes(value),\n    then: Yup.string().required('渠道API地址 不能为空'), // base_url 是必需的\n    otherwise: Yup.string() // 在其他情况下，base_url 可以是任意字符串\n  }),\n  model_mapping: Yup.string().test('is-json', '必须是有效的JSON字符串', function (value) {\n    try {\n      if (value === '' || value === null || value === undefined) {\n        return true;\n      }\n      const parsedValue = JSON.parse(value);\n      if (typeof parsedValue === 'object') {\n        return true;\n      }\n    } catch (e) {\n      return false;\n    }\n    return false;\n  })\n});\n\nconst EditModal = ({ open, channelId, onCancel, onOk }) => {\n  const theme = useTheme();\n  // const [loading, setLoading] = useState(false);\n  const [initialInput, setInitialInput] = useState(defaultConfig.input);\n  const [inputLabel, setInputLabel] = useState(defaultConfig.inputLabel); //\n  const [inputPrompt, setInputPrompt] = useState(defaultConfig.prompt);\n  const [groupOptions, setGroupOptions] = useState([]);\n  const [modelOptions, setModelOptions] = useState([]);\n  const [batchAdd, setBatchAdd] = useState(false);\n  const [basicModels, setBasicModels] = useState([]);\n\n  const initChannel = (typeValue) => {\n    if (typeConfig[typeValue]?.inputLabel) {\n      setInputLabel({\n        ...defaultConfig.inputLabel,\n        ...typeConfig[typeValue].inputLabel\n      });\n    } else {\n      setInputLabel(defaultConfig.inputLabel);\n    }\n\n    if (typeConfig[typeValue]?.prompt) {\n      setInputPrompt({\n        ...defaultConfig.prompt,\n        ...typeConfig[typeValue].prompt\n      });\n    } else {\n      setInputPrompt(defaultConfig.prompt);\n    }\n\n    return typeConfig[typeValue]?.input;\n  };\n  const handleTypeChange = (setFieldValue, typeValue, values) => {\n    initChannel(typeValue);\n    let localModels = getChannelModels(typeValue);\n    setBasicModels(localModels);\n    if (localModels.length > 0 && Array.isArray(values['models']) && values['models'].length == 0) {\n      setFieldValue('models', initialModel(localModels));\n    }\n\n    setFieldValue('config', {});\n  };\n\n  const fetchGroups = async () => {\n    try {\n      let res = await API.get(`/api/group/`);\n      setGroupOptions(res.data.data);\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n\n  const fetchModels = async () => {\n    try {\n      let res = await API.get(`/api/channel/models`);\n      const { data } = res.data;\n      data.forEach((item) => {\n        if (!item.owned_by) {\n          item.owned_by = '未知';\n        }\n      });\n      // 先对data排序\n      data.sort((a, b) => {\n        const ownedByComparison = a.owned_by.localeCompare(b.owned_by);\n        if (ownedByComparison === 0) {\n          return a.id.localeCompare(b.id);\n        }\n        return ownedByComparison;\n      });\n\n      setModelOptions(\n        data.map((model) => {\n          return {\n            id: model.id,\n            group: model.owned_by\n          };\n        })\n      );\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n\n  const submit = async (values, { setErrors, setStatus, setSubmitting }) => {\n    setSubmitting(true);\n    if (values.base_url && values.base_url.endsWith('/')) {\n      values.base_url = values.base_url.slice(0, values.base_url.length - 1);\n    }\n    if (values.type === 3 && values.other === '') {\n      values.other = '2023-09-01-preview';\n    }\n    if (values.type === 18 && values.other === '') {\n      values.other = 'v2.1';\n    }\n    if (values.key === '') {\n      if (values.config.ak && values.config.sk && values.config.region) {\n        values.key = `${values.config.ak}|${values.config.sk}|${values.config.region}`;\n      } else if (values.config.region && values.config.vertex_ai_project_id && values.config.vertex_ai_adc) {\n        values.key = `${values.config.region}|${values.config.vertex_ai_project_id}|${values.config.vertex_ai_adc}`;\n      }\n    }\n\n    let res;\n    const modelsStr = values.models.map((model) => model.id).join(',');\n    const configStr = JSON.stringify(values.config);\n    values.group = values.groups.join(',');\n    if (channelId) {\n      res = await API.put(`/api/channel/`, {\n        ...values,\n        id: parseInt(channelId),\n        models: modelsStr,\n        config: configStr\n      });\n    } else {\n      res = await API.post(`/api/channel/`, { ...values, models: modelsStr, config: configStr });\n    }\n    const { success, message } = res.data;\n    if (success) {\n      if (channelId) {\n        showSuccess('渠道更新成功！');\n      } else {\n        showSuccess('渠道创建成功！');\n      }\n      setSubmitting(false);\n      setStatus({ success: true });\n      onOk(true);\n    } else {\n      setStatus({ success: false });\n      showError(message);\n      setErrors({ submit: message });\n    }\n  };\n\n  function initialModel(channelModel) {\n    if (!channelModel) {\n      return [];\n    }\n\n    // 如果 channelModel 是一个字符串\n    if (typeof channelModel === 'string') {\n      channelModel = channelModel.split(',');\n    }\n    let modelList = channelModel.map((model) => {\n      const modelOption = modelOptions.find((option) => option.id === model);\n      if (modelOption) {\n        return modelOption;\n      }\n      return { id: model, group: '自定义：点击或回车输入' };\n    });\n    return modelList;\n  }\n\n  const loadChannel = async () => {\n    let res = await API.get(`/api/channel/${channelId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (data.models === '') {\n        data.models = [];\n      } else {\n        data.models = initialModel(data.models);\n      }\n      if (data.group === '') {\n        data.groups = [];\n      } else {\n        data.groups = data.group.split(',');\n      }\n      if (data.model_mapping !== '') {\n        data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);\n      }\n      if (data.config !== '') {\n        data.config = JSON.parse(data.config);\n      }\n\n      data.base_url = data.base_url ?? '';\n      data.is_edit = true;\n      initChannel(data.type);\n      setInitialInput(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    fetchGroups().then();\n    fetchModels().then();\n  }, []);\n\n  useEffect(() => {\n    setBatchAdd(false);\n    if (channelId) {\n      loadChannel().then();\n    } else {\n      initChannel(1);\n      setInitialInput({ ...defaultConfig.input, is_edit: false });\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [channelId]);\n\n  return (\n    <Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>\n      <DialogTitle\n        sx={{\n          margin: '0px',\n          fontWeight: 700,\n          lineHeight: '1.55556',\n          padding: '24px',\n          fontSize: '1.125rem'\n        }}\n      >\n        {channelId ? '编辑渠道' : '新建渠道'}\n      </DialogTitle>\n      <Divider />\n      <DialogContent>\n        <Formik initialValues={initialInput} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>\n          {({ errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values, setFieldValue }) => (\n            <form noValidate onSubmit={handleSubmit}>\n              <FormControl fullWidth error={Boolean(touched.type && errors.type)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-type-label\">{inputLabel.type}</InputLabel>\n                <Select\n                  id=\"channel-type-label\"\n                  label={inputLabel.type}\n                  value={values.type}\n                  name=\"type\"\n                  onBlur={handleBlur}\n                  onChange={(e) => {\n                    handleChange(e);\n                    handleTypeChange(setFieldValue, e.target.value, values);\n                  }}\n                  MenuProps={{\n                    PaperProps: {\n                      style: {\n                        maxHeight: 200\n                      }\n                    }\n                  }}\n                >\n                  {Object.values(CHANNEL_OPTIONS)\n                    .sort((a, b) => {\n                      return a.text.localeCompare(b.text);\n                    })\n                    .map((option) => {\n                      return (\n                        <MenuItem key={option.value} value={option.value}>\n                          {option.text}\n                        </MenuItem>\n                      );\n                    })}\n                </Select>\n                {touched.type && errors.type ? (\n                  <FormHelperText error id=\"helper-tex-channel-type-label\">\n                    {errors.type}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-type-label\"> {inputPrompt.type} </FormHelperText>\n                )}\n              </FormControl>\n\n              <FormControl fullWidth error={Boolean(touched.name && errors.name)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-name-label\">{inputLabel.name}</InputLabel>\n                <OutlinedInput\n                  id=\"channel-name-label\"\n                  label={inputLabel.name}\n                  type=\"text\"\n                  value={values.name}\n                  name=\"name\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'name' }}\n                  aria-describedby=\"helper-text-channel-name-label\"\n                />\n                {touched.name && errors.name ? (\n                  <FormHelperText error id=\"helper-tex-channel-name-label\">\n                    {errors.name}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-name-label\"> {inputPrompt.name} </FormHelperText>\n                )}\n              </FormControl>\n\n              <FormControl fullWidth error={Boolean(touched.base_url && errors.base_url)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-base_url-label\">{inputLabel.base_url}</InputLabel>\n                <OutlinedInput\n                  id=\"channel-base_url-label\"\n                  label={inputLabel.base_url}\n                  type=\"text\"\n                  value={values.base_url}\n                  name=\"base_url\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{}}\n                  aria-describedby=\"helper-text-channel-base_url-label\"\n                />\n                {touched.base_url && errors.base_url ? (\n                  <FormHelperText error id=\"helper-tex-channel-base_url-label\">\n                    {errors.base_url}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-base_url-label\"> {inputPrompt.base_url} </FormHelperText>\n                )}\n              </FormControl>\n\n              {inputPrompt.other && (\n                <FormControl fullWidth error={Boolean(touched.other && errors.other)} sx={{ ...theme.typography.otherInput }}>\n                  <InputLabel htmlFor=\"channel-other-label\">{inputLabel.other}</InputLabel>\n                  <OutlinedInput\n                    id=\"channel-other-label\"\n                    label={inputLabel.other}\n                    type=\"text\"\n                    value={values.other}\n                    name=\"other\"\n                    onBlur={handleBlur}\n                    onChange={handleChange}\n                    inputProps={{}}\n                    aria-describedby=\"helper-text-channel-other-label\"\n                  />\n                  {touched.other && errors.other ? (\n                    <FormHelperText error id=\"helper-tex-channel-other-label\">\n                      {errors.other}\n                    </FormHelperText>\n                  ) : (\n                    <FormHelperText id=\"helper-tex-channel-other-label\"> {inputPrompt.other} </FormHelperText>\n                  )}\n                </FormControl>\n              )}\n\n              <FormControl fullWidth sx={{ ...theme.typography.otherInput }}>\n                <Autocomplete\n                  multiple\n                  id=\"channel-groups-label\"\n                  options={groupOptions}\n                  value={values.groups}\n                  onChange={(e, value) => {\n                    const event = {\n                      target: {\n                        name: 'groups',\n                        value: value\n                      }\n                    };\n                    handleChange(event);\n                  }}\n                  onBlur={handleBlur}\n                  filterSelectedOptions\n                  renderInput={(params) => <TextField {...params} name=\"groups\" error={Boolean(errors.groups)} label={inputLabel.groups} />}\n                  aria-describedby=\"helper-text-channel-groups-label\"\n                />\n                {errors.groups ? (\n                  <FormHelperText error id=\"helper-tex-channel-groups-label\">\n                    {errors.groups}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-groups-label\"> {inputPrompt.groups} </FormHelperText>\n                )}\n              </FormControl>\n\n              <FormControl fullWidth sx={{ ...theme.typography.otherInput }}>\n                <Autocomplete\n                  multiple\n                  freeSolo\n                  id=\"channel-models-label\"\n                  options={modelOptions}\n                  value={values.models}\n                  onChange={(e, value) => {\n                    const event = {\n                      target: {\n                        name: 'models',\n                        value: value.map((item) => (typeof item === 'string' ? { id: item, group: '自定义：点击或回车输入' } : item))\n                      }\n                    };\n                    handleChange(event);\n                  }}\n                  onBlur={handleBlur}\n                  // filterSelectedOptions\n                  disableCloseOnSelect\n                  renderInput={(params) => <TextField {...params} name=\"models\" error={Boolean(errors.models)} label={inputLabel.models} />}\n                  groupBy={(option) => option.group}\n                  getOptionLabel={(option) => {\n                    if (typeof option === 'string') {\n                      return option;\n                    }\n                    if (option.inputValue) {\n                      return option.inputValue;\n                    }\n                    return option.id;\n                  }}\n                  filterOptions={(options, params) => {\n                    const filtered = filter(options, params);\n                    const { inputValue } = params;\n                    const isExisting = options.some((option) => inputValue === option.id);\n                    if (inputValue !== '' && !isExisting) {\n                      filtered.push({\n                        id: inputValue,\n                        group: '自定义：点击或回车输入'\n                      });\n                    }\n                    return filtered;\n                  }}\n                  renderOption={(props, option, { selected }) => (\n                    <li {...props}>\n                      <Checkbox icon={icon} checkedIcon={checkedIcon} style={{ marginRight: 8 }} checked={selected} />\n                      {option.id}\n                    </li>\n                  )}\n                />\n                {errors.models ? (\n                  <FormHelperText error id=\"helper-tex-channel-models-label\">\n                    {errors.models}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-models-label\"> {inputPrompt.models} </FormHelperText>\n                )}\n              </FormControl>\n              <Container\n                sx={{\n                  textAlign: 'right'\n                }}\n              >\n                <ButtonGroup variant=\"outlined\" aria-label=\"small outlined primary button group\">\n                  <Button\n                    onClick={() => {\n                      setFieldValue('models', initialModel(basicModels));\n                    }}\n                  >\n                    填入相关模型\n                  </Button>\n                  <Button\n                    onClick={() => {\n                      setFieldValue('models', modelOptions);\n                    }}\n                  >\n                    填入所有模型\n                  </Button>\n                </ButtonGroup>\n              </Container>\n              {inputLabel.key && (\n                <>\n                  <FormControl fullWidth error={Boolean(touched.key && errors.key)} sx={{ ...theme.typography.otherInput }}>\n                    {!batchAdd ? (\n                      <>\n                        <InputLabel htmlFor=\"channel-key-label\">{inputLabel.key}</InputLabel>\n                        <OutlinedInput\n                          id=\"channel-key-label\"\n                          label={inputLabel.key}\n                          type=\"text\"\n                          value={values.key}\n                          name=\"key\"\n                          onBlur={handleBlur}\n                          onChange={handleChange}\n                          inputProps={{}}\n                          aria-describedby=\"helper-text-channel-key-label\"\n                        />\n                      </>\n                    ) : (\n                      <TextField\n                        multiline\n                        id=\"channel-key-label\"\n                        label={inputLabel.key}\n                        value={values.key}\n                        name=\"key\"\n                        onBlur={handleBlur}\n                        onChange={handleChange}\n                        aria-describedby=\"helper-text-channel-key-label\"\n                        minRows={5}\n                        placeholder={inputPrompt.key + '，一行一个密钥'}\n                      />\n                    )}\n\n                    {touched.key && errors.key ? (\n                      <FormHelperText error id=\"helper-tex-channel-key-label\">\n                        {errors.key}\n                      </FormHelperText>\n                    ) : (\n                      <FormHelperText id=\"helper-tex-channel-key-label\"> {inputPrompt.key} </FormHelperText>\n                    )}\n                  </FormControl>\n                  {channelId === 0 && (\n                    <Container\n                      sx={{\n                        textAlign: 'right'\n                      }}\n                    >\n                      <Switch checked={batchAdd} onChange={(e) => setBatchAdd(e.target.checked)} />\n                      批量添加\n                    </Container>\n                  )}\n                </>\n              )}\n\n              {inputLabel.config &&\n                Object.keys(inputLabel.config).map((configName) => {\n                  return (\n                    <FormControl key={'config.' + configName} fullWidth sx={{ ...theme.typography.otherInput }}>\n                      <TextField\n                        multiline\n                        key={'config.' + configName}\n                        name={'config.' + configName}\n                        value={values.config?.[configName] || ''}\n                        label={configName}\n                        placeholder={inputPrompt.config[configName]}\n                        onChange={handleChange}\n                      />\n                      <FormHelperText id={`helper-tex-config.${configName}-label`}> {inputPrompt.config[configName]} </FormHelperText>\n                    </FormControl>\n                  );\n                })}\n\n              <FormControl fullWidth error={Boolean(touched.model_mapping && errors.model_mapping)} sx={{ ...theme.typography.otherInput }}>\n                {/* <InputLabel htmlFor=\"channel-model_mapping-label\">{inputLabel.model_mapping}</InputLabel> */}\n                <TextField\n                  multiline\n                  id=\"channel-model_mapping-label\"\n                  label={inputLabel.model_mapping}\n                  value={values.model_mapping}\n                  name=\"model_mapping\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  aria-describedby=\"helper-text-channel-model_mapping-label\"\n                  minRows={5}\n                  placeholder={inputPrompt.model_mapping}\n                />\n                {touched.model_mapping && errors.model_mapping ? (\n                  <FormHelperText error id=\"helper-tex-channel-model_mapping-label\">\n                    {errors.model_mapping}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-model_mapping-label\"> {inputPrompt.model_mapping} </FormHelperText>\n                )}\n              </FormControl>\n              <FormControl fullWidth error={Boolean(touched.system_prompt && errors.system_prompt)} sx={{ ...theme.typography.otherInput }}>\n                {/* <InputLabel htmlFor=\"channel-model_mapping-label\">{inputLabel.model_mapping}</InputLabel> */}\n                <TextField\n                  multiline\n                  id=\"channel-system_prompt-label\"\n                  label={inputLabel.system_prompt}\n                  value={values.system_prompt}\n                  name=\"system_prompt\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  aria-describedby=\"helper-text-channel-system_prompt-label\"\n                  minRows={5}\n                  placeholder={inputPrompt.system_prompt}\n                />\n                {touched.system_prompt && errors.system_prompt ? (\n                  <FormHelperText error id=\"helper-tex-channel-system_prompt-label\">\n                    {errors.system_prompt}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-system_prompt-label\"> {inputPrompt.system_prompt} </FormHelperText>\n                )}\n              </FormControl>\n              <DialogActions>\n                <Button onClick={onCancel}>取消</Button>\n                <Button disableElevation disabled={isSubmitting} type=\"submit\" variant=\"contained\" color=\"primary\">\n                  提交\n                </Button>\n              </DialogActions>\n            </form>\n          )}\n        </Formik>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default EditModal;\n\nEditModal.propTypes = {\n  open: PropTypes.bool,\n  channelId: PropTypes.number,\n  onCancel: PropTypes.func,\n  onOk: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/Channel/component/GroupLabel.js",
    "content": "import PropTypes from \"prop-types\";\nimport Label from \"ui-component/Label\";\nimport Stack from \"@mui/material/Stack\";\nimport Divider from \"@mui/material/Divider\";\n\nfunction name2color(name) {\n  switch (name) {\n    case \"default\":\n      return \"info\";\n    case \"vip\":\n      return \"warning\"\n    case \"svip\":\n      return \"error\"\n    default:\n      return \"info\"\n  }\n}\n\nconst GroupLabel = ({ group }) => {\n  let groups = [];\n  if (group === \"\") {\n    groups = [\"default\"];\n  } else {\n    groups = group.split(\",\");\n    groups.sort();\n  }\n  return (\n    <Stack divider={<Divider orientation=\"vertical\" flexItem />} spacing={0.5}>\n      {groups.map((group, index) => {\n        return <Label key={index} color={name2color(group)}>{group}</Label>;\n      })}\n    </Stack>\n  );\n};\n\nGroupLabel.propTypes = {\n  group: PropTypes.string,\n};\n\nexport default GroupLabel;\n"
  },
  {
    "path": "web/berry/src/views/Channel/component/NameLabel.js",
    "content": "import PropTypes from 'prop-types';\nimport { Tooltip, Stack, Container } from '@mui/material';\nimport Label from 'ui-component/Label';\nimport { styled } from '@mui/material/styles';\nimport { showSuccess, copy } from 'utils/common';\n\nconst TooltipContainer = styled(Container)({\n  maxHeight: '250px',\n  overflow: 'auto',\n  '&::-webkit-scrollbar': {\n    width: '0px' // Set the width to 0 to hide the scrollbar\n  }\n});\n\nconst NameLabel = ({ name, models }) => {\n  let modelMap = [];\n  modelMap = models.split(',');\n  modelMap.sort();\n\n  return (\n    <Tooltip\n      title={\n        <TooltipContainer>\n          <Stack spacing={1}>\n            {modelMap.map((item, index) => {\n              return (\n                <Label\n                  variant=\"ghost\"\n                  key={index}\n                  onClick={() => {\n                    copy(item, '模型名称');\n                  }}\n                >\n                  {item}\n                </Label>\n              );\n            })}\n          </Stack>\n        </TooltipContainer>\n      }\n      placement=\"top\"\n    >\n      <span>{name}</span>\n    </Tooltip>\n  );\n};\n\nNameLabel.propTypes = {\n  name: PropTypes.string,\n  models: PropTypes.string\n};\n\nexport default NameLabel;\n"
  },
  {
    "path": "web/berry/src/views/Channel/component/ResponseTimeLabel.js",
    "content": "import PropTypes from 'prop-types';\nimport Label from 'ui-component/Label';\nimport Tooltip from '@mui/material/Tooltip';\nimport { timestamp2string } from 'utils/common';\n\nconst ResponseTimeLabel = ({ test_time, response_time, handle_action }) => {\n  let color = 'default';\n  let time = response_time / 1000;\n  time = time.toFixed(2) + ' 秒';\n\n  if (response_time === 0) {\n    color = 'default';\n  } else if (response_time <= 1000) {\n    color = 'success';\n  } else if (response_time <= 3000) {\n    color = 'primary';\n  } else if (response_time <= 5000) {\n    color = 'secondary';\n  } else {\n    color = 'error';\n  }\n  let title = (\n    <>\n      点击测速\n      <br />\n      {test_time != 0 ? '上次测速时间：' + timestamp2string(test_time) : '未测试'}\n    </>\n  );\n\n  return (\n    <Tooltip title={title} placement=\"top\" onClick={handle_action}>\n      <Label color={color}> {response_time == 0 ? '未测试' : time} </Label>\n    </Tooltip>\n  );\n};\n\nResponseTimeLabel.propTypes = {\n  test_time: PropTypes.number,\n  response_time: PropTypes.number,\n  handle_action: PropTypes.func\n};\n\nexport default ResponseTimeLabel;\n"
  },
  {
    "path": "web/berry/src/views/Channel/component/TableHead.js",
    "content": "import { TableCell, TableHead, TableRow } from '@mui/material';\n\nconst ChannelTableHead = () => {\n  return (\n    <TableHead>\n      <TableRow>\n        <TableCell>ID</TableCell>\n        <TableCell>名称</TableCell>\n        <TableCell>分组</TableCell>\n        <TableCell>类型</TableCell>\n        <TableCell>状态</TableCell>\n        <TableCell>响应时间</TableCell>\n        <TableCell>已消耗</TableCell>\n        <TableCell>余额</TableCell>\n        <TableCell>优先级</TableCell>\n        <TableCell>操作</TableCell>\n      </TableRow>\n    </TableHead>\n  );\n};\n\nexport default ChannelTableHead;\n"
  },
  {
    "path": "web/berry/src/views/Channel/component/TableRow.js",
    "content": "import PropTypes from \"prop-types\";\nimport { useState } from \"react\";\n\nimport { showInfo, showError, renderNumber } from \"utils/common\";\nimport { API } from \"utils/api\";\nimport { CHANNEL_OPTIONS } from \"constants/ChannelConstants\";\n\nimport {\n  Popover,\n  TableRow,\n  MenuItem,\n  TableCell,\n  IconButton,\n  TextField,\n  Dialog,\n  DialogActions,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Tooltip,\n  Button,\n} from \"@mui/material\";\n\nimport Label from \"ui-component/Label\";\nimport TableSwitch from \"ui-component/Switch\";\n\nimport ResponseTimeLabel from \"./ResponseTimeLabel\";\nimport GroupLabel from \"./GroupLabel\";\nimport NameLabel from \"./NameLabel\";\n\nimport { IconDotsVertical, IconEdit, IconTrash } from \"@tabler/icons-react\";\n\nexport default function ChannelTableRow({\n  item,\n  manageChannel,\n  handleOpenModal,\n  setModalChannelId,\n}) {\n  const [open, setOpen] = useState(null);\n  const [openDelete, setOpenDelete] = useState(false);\n  const [statusSwitch, setStatusSwitch] = useState(item.status);\n  const [priorityValve, setPriority] = useState(item.priority);\n  const [responseTimeData, setResponseTimeData] = useState({\n    test_time: item.test_time,\n    response_time: item.response_time,\n  });\n  const [itemBalance, setItemBalance] = useState(item.balance);\n\n  const handleDeleteOpen = () => {\n    handleCloseMenu();\n    setOpenDelete(true);\n  };\n\n  const handleDeleteClose = () => {\n    setOpenDelete(false);\n  };\n\n  const handleOpenMenu = (event) => {\n    setOpen(event.currentTarget);\n  };\n\n  const handleCloseMenu = () => {\n    setOpen(null);\n  };\n\n  const handleStatus = async () => {\n    const switchVlue = statusSwitch === 1 ? 2 : 1;\n    const { success } = await manageChannel(item.id, \"status\", switchVlue);\n    if (success) {\n      setStatusSwitch(switchVlue);\n    }\n  };\n\n  const handlePriority = async (event) => {\n    const currentValue = parseInt(event.target.value);\n    if (isNaN(currentValue) || currentValue === priorityValve) {\n      return;\n    }\n\n    if (currentValue < 0) {\n      showError(\"优先级不能小于 0\");\n      return;\n    }\n\n    await manageChannel(item.id, \"priority\", currentValue);\n    setPriority(currentValue);\n  };\n\n  const handleResponseTime = async () => {\n    const { success, time } = await manageChannel(item.id, \"test\", \"\");\n    if (success) {\n      setResponseTimeData({\n        test_time: Date.now() / 1000,\n        response_time: time * 1000,\n      });\n      showInfo(`渠道 ${item.name} 测试成功，耗时 ${time.toFixed(2)} 秒。`);\n    }\n  };\n\n  const updateChannelBalance = async () => {\n    const res = await API.get(`/api/channel/update_balance/${item.id}`);\n    const { success, message, balance } = res.data;\n    if (success) {\n      setItemBalance(balance);\n\n      showInfo(`余额更新成功！`);\n    } else {\n      showError(message);\n    }\n  };\n\n  const handleDelete = async () => {\n    handleCloseMenu();\n    await manageChannel(item.id, \"delete\", \"\");\n  };\n\n  return (\n    <>\n      <TableRow tabIndex={item.id}>\n        <TableCell>{item.id}</TableCell>\n\n        <TableCell>\n          <NameLabel name={item.name} models={item.models} />\n        </TableCell>\n\n        <TableCell>\n          <GroupLabel group={item.group} />\n        </TableCell>\n\n        <TableCell>\n          {!CHANNEL_OPTIONS[item.type] ? (\n            <Label color=\"error\" variant=\"outlined\">\n              未知\n            </Label>\n          ) : (\n            <Label color={CHANNEL_OPTIONS[item.type].color} variant=\"outlined\">\n              {CHANNEL_OPTIONS[item.type].text}\n            </Label>\n          )}\n        </TableCell>\n\n        <TableCell>\n          <Tooltip\n            title={(() => {\n              switch (statusSwitch) {\n                case 1:\n                  return \"已启用\";\n                case 2:\n                  return \"本渠道被手动禁用\";\n                case 3:\n                  return \"本渠道被程序自动禁用\";\n                default:\n                  return \"未知\";\n              }\n            })()}\n            placement=\"top\"\n          >\n            <TableSwitch\n              id={`switch-${item.id}`}\n              checked={statusSwitch === 1}\n              onChange={handleStatus}\n            />\n          </Tooltip>\n        </TableCell>\n\n        <TableCell>\n          <ResponseTimeLabel\n            test_time={responseTimeData.test_time}\n            response_time={responseTimeData.response_time}\n            handle_action={handleResponseTime}\n          />\n        </TableCell>\n        <TableCell>{renderNumber(item.used_quota)}</TableCell>\n        <TableCell>\n          <Tooltip\n            title={\"点击更新余额\"}\n            placement=\"top\"\n            onClick={updateChannelBalance}\n          >\n            {renderBalance(item.type, itemBalance)}\n          </Tooltip>\n        </TableCell>\n        <TableCell>\n          <TextField\n            id={`priority-${item.id}`}\n            onBlur={handlePriority}\n            type=\"number\"\n            label=\"优先级\"\n            variant=\"standard\"\n            defaultValue={item.priority}\n            inputProps={{ min: \"0\" }}\n            sx={{ width: 80 }}\n          />\n        </TableCell>\n\n        <TableCell>\n          <IconButton\n            onClick={handleOpenMenu}\n            sx={{ color: \"rgb(99, 115, 129)\" }}\n          >\n            <IconDotsVertical />\n          </IconButton>\n        </TableCell>\n      </TableRow>\n\n      <Popover\n        open={!!open}\n        anchorEl={open}\n        onClose={handleCloseMenu}\n        anchorOrigin={{ vertical: \"top\", horizontal: \"left\" }}\n        transformOrigin={{ vertical: \"top\", horizontal: \"right\" }}\n        PaperProps={{\n          sx: { width: 140 },\n        }}\n      >\n        <MenuItem\n          onClick={() => {\n            handleCloseMenu();\n            handleOpenModal();\n            setModalChannelId(item.id);\n          }}\n        >\n          <IconEdit style={{ marginRight: \"16px\" }} />\n          编辑\n        </MenuItem>\n        <MenuItem onClick={handleDeleteOpen} sx={{ color: \"error.main\" }}>\n          <IconTrash style={{ marginRight: \"16px\" }} />\n          删除\n        </MenuItem>\n      </Popover>\n\n      <Dialog open={openDelete} onClose={handleDeleteClose}>\n        <DialogTitle>删除渠道</DialogTitle>\n        <DialogContent>\n          <DialogContentText>是否删除渠道 {item.name}？</DialogContentText>\n        </DialogContent>\n        <DialogActions>\n          <Button onClick={handleDeleteClose}>关闭</Button>\n          <Button onClick={handleDelete} sx={{ color: \"error.main\" }} autoFocus>\n            删除\n          </Button>\n        </DialogActions>\n      </Dialog>\n    </>\n  );\n}\n\nChannelTableRow.propTypes = {\n  item: PropTypes.object,\n  manageChannel: PropTypes.func,\n  handleOpenModal: PropTypes.func,\n  setModalChannelId: PropTypes.func,\n};\n\nfunction renderBalance(type, balance) {\n  switch (type) {\n    case 1: // OpenAI\n      return <span>${balance.toFixed(2)}</span>;\n    case 4: // CloseAI\n      return <span>¥{balance.toFixed(2)}</span>;\n    case 8: // 自定义\n      return <span>${balance.toFixed(2)}</span>;\n    case 5: // OpenAI-SB\n      return <span>¥{(balance / 10000).toFixed(2)}</span>;\n    case 10: // AI Proxy\n      return <span>{renderNumber(balance)}</span>;\n    case 12: // API2GPT\n      return <span>¥{balance.toFixed(2)}</span>;\n    case 13: // AIGC2D\n      return <span>{renderNumber(balance)}</span>;\n    case 36: // DeepSeek\n      return <span>¥{balance.toFixed(2)}</span>;\n    case 44: // SiliconFlow\n      return <span>¥{balance.toFixed(2)}</span>;\n    default:\n      return <span>不支持</span>;\n  }\n}\n"
  },
  {
    "path": "web/berry/src/views/Channel/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport { showError, showSuccess, showInfo, loadChannelModels } from 'utils/common';\n\nimport { useTheme } from '@mui/material/styles';\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableContainer from '@mui/material/TableContainer';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport TablePagination from '@mui/material/TablePagination';\nimport LinearProgress from '@mui/material/LinearProgress';\nimport ButtonGroup from '@mui/material/ButtonGroup';\nimport Toolbar from '@mui/material/Toolbar';\nimport useMediaQuery from '@mui/material/useMediaQuery';\n\nimport { Button, IconButton, Card, Box, Stack, Container, Typography, Divider } from '@mui/material';\nimport ChannelTableRow from './component/TableRow';\nimport ChannelTableHead from './component/TableHead';\nimport TableToolBar from 'ui-component/TableToolBar';\nimport { API } from 'utils/api';\nimport { ITEMS_PER_PAGE } from 'constants';\nimport { IconRefresh, IconHttpDelete, IconPlus, IconBrandSpeedtest, IconCoinYuan } from '@tabler/icons-react';\nimport EditeModal from './component/EditModal';\n\n// ----------------------------------------------------------------------\n// CHANNEL_OPTIONS,\nexport default function ChannelPage() {\n  const [channels, setChannels] = useState([]);\n  const [activePage, setActivePage] = useState(0);\n  const [searching, setSearching] = useState(false);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const theme = useTheme();\n  const matchUpMd = useMediaQuery(theme.breakpoints.up('sm'));\n  const [openModal, setOpenModal] = useState(false);\n  const [editChannelId, setEditChannelId] = useState(0);\n\n  const loadChannels = async (startIdx) => {\n    setSearching(true);\n    const res = await API.get(`/api/channel/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setChannels(data);\n      } else {\n        let newChannels = [...channels];\n        newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setChannels(newChannels);\n      }\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const onPaginationChange = (event, activePage) => {\n    (async () => {\n      if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE)) {\n        // In this case we have to load more data and then append them.\n        await loadChannels(activePage);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const searchChannels = async (event) => {\n    event.preventDefault();\n    if (searchKeyword === '') {\n      await loadChannels(0);\n      setActivePage(0);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setChannels(data);\n      setActivePage(0);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleSearchKeyword = (event) => {\n    setSearchKeyword(event.target.value);\n  };\n\n  const manageChannel = async (id, action, value) => {\n    const url = '/api/channel/';\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(url + id);\n        break;\n      case 'status':\n        res = await API.put(url, {\n          ...data,\n          status: value\n        });\n        break;\n      case 'priority':\n        if (value === '') {\n          return;\n        }\n        res = await API.put(url, {\n          ...data,\n          priority: parseInt(value)\n        });\n        break;\n      case 'test':\n        res = await API.get(url + `test/${id}`);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      if (action === 'delete') {\n        await handleRefresh();\n      }\n    } else {\n      showError(message);\n    }\n\n    return res.data;\n  };\n\n  // 处理刷新\n  const handleRefresh = async () => {\n    await loadChannels(activePage);\n  };\n\n  // 处理测试所有启用渠道\n  const testAllChannels = async () => {\n    const res = await API.get(`/api/channel/test`);\n    const { success, message } = res.data;\n    if (success) {\n      showInfo('已成功开始测试所有渠道，请刷新页面查看结果。');\n    } else {\n      showError(message);\n    }\n  };\n\n  // 处理删除所有禁用渠道\n  const deleteAllDisabledChannels = async () => {\n    const res = await API.delete(`/api/channel/disabled`);\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`已删除所有禁用渠道，共计 ${data} 个`);\n      await handleRefresh();\n    } else {\n      showError(message);\n    }\n  };\n\n  // 处理更新所有启用渠道余额\n  const updateAllChannelsBalance = async () => {\n    setSearching(true);\n    const res = await API.get(`/api/channel/update_balance`);\n    const { success, message } = res.data;\n    if (success) {\n      showInfo('已更新完毕所有已启用渠道余额！');\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleOpenModal = (channelId) => {\n    setEditChannelId(channelId);\n    setOpenModal(true);\n  };\n\n  const handleCloseModal = () => {\n    setOpenModal(false);\n    setEditChannelId(0);\n  };\n\n  const handleOkModal = (status) => {\n    if (status === true) {\n      handleCloseModal();\n      handleRefresh();\n    }\n  };\n\n  useEffect(() => {\n    loadChannels(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n    loadChannelModels().then();\n  }, []);\n\n  return (\n    <>\n      <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"space-between\" mb={2.5}>\n        <Typography variant=\"h4\">渠道</Typography>\n        <Button variant=\"contained\" color=\"primary\" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>\n          新建渠道\n        </Button>\n      </Stack>\n      <Card>\n        <Box component=\"form\" onSubmit={searchChannels} noValidate sx={{ marginTop: 2 }}>\n          <TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} placeholder={'搜索渠道的 ID，名称和密钥 ...'} />\n        </Box>\n        <Toolbar\n          sx={{\n            textAlign: 'right',\n            height: 50,\n            display: 'flex',\n            justifyContent: 'space-between',\n            p: (theme) => theme.spacing(0, 1, 0, 3)\n          }}\n        >\n          <Container>\n            {matchUpMd ? (\n              <ButtonGroup variant=\"outlined\" aria-label=\"outlined small primary button group\" sx={{ marginBottom: 2 }}>\n                <Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>\n                  刷新\n                </Button>\n                <Button onClick={testAllChannels} startIcon={<IconBrandSpeedtest width={'18px'} />}>\n                  测试启用渠道\n                </Button>\n                {/*<Button onClick={updateAllChannelsBalance} startIcon={<IconCoinYuan width={'18px'} />}>*/}\n                {/*  更新启用余额*/}\n                {/*</Button>*/}\n                <Button onClick={deleteAllDisabledChannels} startIcon={<IconHttpDelete width={'18px'} />}>\n                  删除禁用渠道\n                </Button>\n              </ButtonGroup>\n            ) : (\n              <Stack\n                direction=\"row\"\n                spacing={1}\n                divider={<Divider orientation=\"vertical\" flexItem />}\n                justifyContent=\"space-around\"\n                alignItems=\"center\"\n              >\n                <IconButton onClick={handleRefresh} size=\"large\">\n                  <IconRefresh />\n                </IconButton>\n                <IconButton onClick={testAllChannels} size=\"large\">\n                  <IconBrandSpeedtest />\n                </IconButton>\n                <IconButton onClick={updateAllChannelsBalance} size=\"large\">\n                  <IconCoinYuan />\n                </IconButton>\n                <IconButton onClick={deleteAllDisabledChannels} size=\"large\">\n                  <IconHttpDelete />\n                </IconButton>\n              </Stack>\n            )}\n          </Container>\n        </Toolbar>\n        {searching && <LinearProgress />}\n        <PerfectScrollbar component=\"div\">\n          <TableContainer sx={{ overflow: 'unset' }}>\n            <Table sx={{ minWidth: 800 }}>\n              <ChannelTableHead />\n              <TableBody>\n                {channels.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (\n                  <ChannelTableRow\n                    item={row}\n                    manageChannel={manageChannel}\n                    key={row.id}\n                    handleOpenModal={handleOpenModal}\n                    setModalChannelId={setEditChannelId}\n                  />\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n        </PerfectScrollbar>\n        <TablePagination\n          page={activePage}\n          component=\"div\"\n          count={channels.length + (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}\n          rowsPerPage={ITEMS_PER_PAGE}\n          onPageChange={onPaginationChange}\n          rowsPerPageOptions={[ITEMS_PER_PAGE]}\n        />\n      </Card>\n      <EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} channelId={editChannelId} />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/views/Channel/type/Config.js",
    "content": "const defaultConfig = {\n  input: {\n    name: '',\n    type: 1,\n    key: '',\n    base_url: '',\n    other: '',\n    model_mapping: '',\n    models: [],\n    groups: ['default'],\n    config: {}\n  },\n  inputLabel: {\n    name: '渠道名称',\n    type: '渠道类型',\n    base_url: '渠道API地址',\n    key: '密钥',\n    other: '其他参数',\n    models: '模型',\n    model_mapping: '模型映射关系',\n    system_prompt: '系统提示词',\n    groups: '用户组',\n    config: null\n  },\n  prompt: {\n    type: '请选择渠道类型',\n    name: '请为渠道命名',\n    base_url: '可空，请输入中转API地址，例如通过cloudflare中转',\n    key: '请输入渠道对应的鉴权密钥',\n    other: '',\n    models: '请选择该渠道所支持的模型',\n    model_mapping:\n      '请输入要修改的模型映射关系，格式为：api请求模型ID:实际转发给渠道的模型ID，使用JSON数组表示，例如：{\"gpt-3.5\": \"gpt-35\"}',\n    system_prompt:\"此项可选，用于强制设置给定的系统提示词，请配合自定义模型 & 模型重定向使用，首先创建一个唯一的自定义模型名称并在上面填入，之后将该自定义模型重定向映射到该渠道一个原生支持的模型此项可选，用于强制设置给定的系统提示词，请配合自定义模型 & 模型重定向使用，首先创建一个唯一的自定义模型名称并在上面填入，之后将该自定义模型重定向映射到该渠道一个原生支持的模型\",\n    groups: '请选择该渠道所支持的用户组',\n    config: null\n  },\n  modelGroup: 'openai'\n};\n\nconst typeConfig = {\n  3: {\n    inputLabel: {\n      base_url: 'AZURE_OPENAI_ENDPOINT',\n      other: '默认 API 版本'\n    },\n    prompt: {\n      base_url: '请填写AZURE_OPENAI_ENDPOINT',\n      other: '请输入默认API版本，例如：2024-03-01-preview'\n    }\n  },\n  11: {\n    input: {\n      models: ['PaLM-2']\n    },\n    modelGroup: 'google palm'\n  },\n  14: {\n    input: {\n      models: ['claude-instant-1', 'claude-2', 'claude-2.0', 'claude-2.1']\n    },\n    modelGroup: 'anthropic'\n  },\n  15: {\n    input: {\n      models: ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'ERNIE-Bot-4', 'Embedding-V1']\n    },\n    prompt: {\n      key: '按照如下格式输入：APIKey|SecretKey'\n    },\n    modelGroup: 'baidu'\n  },\n  16: {\n    input: {\n      models: ['glm-4', 'glm-4v', 'glm-3-turbo', 'chatglm_turbo', 'chatglm_pro', 'chatglm_std', 'chatglm_lite']\n    },\n    modelGroup: 'zhipu'\n  },\n  17: {\n    inputLabel: {\n      other: '插件参数'\n    },\n    input: {\n      models: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-max-longcontext', 'text-embedding-v1']\n    },\n    prompt: {\n      other: '请输入插件参数，即 X-DashScope-Plugin 请求头的取值'\n    },\n    modelGroup: 'ali'\n  },\n  18: {\n    inputLabel: {\n      other: '版本号'\n    },\n    input: {\n      models: ['SparkDesk', 'SparkDesk-v1.1', 'SparkDesk-v2.1', 'SparkDesk-v3.1', 'SparkDesk-v3.1-128K', 'SparkDesk-v3.5', 'SparkDesk-v3.5-32K', 'SparkDesk-v4.0']\n    },\n    prompt: {\n      key: '按照如下格式输入：APPID|APISecret|APIKey',\n      other: '请输入版本号，例如：v3.1'\n    },\n    modelGroup: 'xunfei'\n  },\n  19: {\n    input: {\n      models: ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1']\n    },\n    modelGroup: '360'\n  },\n  22: {\n    prompt: {\n      key: '按照如下格式输入：APIKey-AppId，例如：fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041'\n    }\n  },\n  23: {\n    input: {\n      models: ['hunyuan']\n    },\n    prompt: {\n      key: '按照如下格式输入：AppId|SecretId|SecretKey'\n    },\n    modelGroup: 'tencent'\n  },\n  24: {\n    inputLabel: {\n      other: '版本号'\n    },\n    input: {\n      models: ['gemini-pro']\n    },\n    prompt: {\n      other: '请输入版本号，例如：v1'\n    },\n    modelGroup: 'google gemini'\n  },\n  25: {\n    input: {\n      models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k']\n    },\n    modelGroup: 'moonshot'\n  },\n  26: {\n    input: {\n      models: ['Baichuan2-Turbo', 'Baichuan2-Turbo-192k', 'Baichuan-Text-Embedding']\n    },\n    modelGroup: 'baichuan'\n  },\n  27: {\n    input: {\n      models: ['abab5.5s-chat', 'abab5.5-chat', 'abab6-chat']\n    },\n    modelGroup: 'minimax'\n  },\n  29: {\n    modelGroup: 'groq'\n  },\n  30: {\n    modelGroup: 'ollama'\n  },\n  31: {\n    modelGroup: 'lingyiwanwu'\n  },\n  33: {\n    inputLabel: {\n      key: '',\n      config: {\n        region: 'Region',\n        ak: 'Access Key',\n        sk: 'Secret Key'\n      }\n    },\n    prompt: {\n      key: '',\n      config: {\n        region: 'region，e.g. us-west-2',\n        ak: 'AWS IAM Access Key',\n        sk: 'AWS IAM Secret Key'\n      }\n    },\n    modelGroup: 'anthropic'\n  },\n  37: {\n    inputLabel: {\n      config: {\n        user_id: 'Account ID'\n      }\n    },\n    prompt: {\n      config: {\n        user_id: '请输入 Account ID，例如：d8d7c61dbc334c32d3ced580e4bf42b4'\n      }\n    },\n    modelGroup: 'Cloudflare'\n  },\n  34: {\n    inputLabel: {\n      config: {\n        user_id: 'User ID'\n      }\n    },\n    prompt: {\n      models: '对于 Coze 而言，模型名称即 Bot ID，你可以添加一个前缀 `bot-`，例如：`bot-123456`',\n      config: {\n        user_id: '生成该密钥的用户 ID'\n      }\n    },\n    modelGroup: 'Coze'\n  },\n  42: {\n    inputLabel: {\n      key: '',\n      config: {\n        region: 'Vertex AI Region',\n        vertex_ai_project_id: 'Vertex AI Project ID',\n        vertex_ai_adc: 'Google Cloud Application Default Credentials JSON'\n      }\n    },\n    prompt: {\n      key: '',\n      config: {\n        region: 'Vertex AI Region.g. us-east5',\n        vertex_ai_project_id: 'Vertex AI Project ID',\n        vertex_ai_adc: 'Google Cloud Application Default Credentials JSON: https://cloud.google.com/docs/authentication/application-default-credentials'\n      }\n    },\n    modelGroup: 'anthropic'\n  },\n  45: {\n    modelGroup: 'xai'\n  },\n};\n\nexport { defaultConfig, typeConfig };\n"
  },
  {
    "path": "web/berry/src/views/Dashboard/component/StatisticalBarChart.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { Grid, Typography } from '@mui/material';\n\n// third-party\nimport Chart from 'react-apexcharts';\n\n// project imports\nimport SkeletonTotalGrowthBarChart from 'ui-component/cards/Skeleton/TotalGrowthBarChart';\nimport MainCard from 'ui-component/cards/MainCard';\nimport { gridSpacing } from 'store/constant';\nimport { Box } from '@mui/material';\n\n// ==============================|| DASHBOARD DEFAULT - TOTAL GROWTH BAR CHART ||============================== //\n\nconst StatisticalBarChart = ({ isLoading, chartDatas }) => {\n  chartData.options.xaxis.categories = chartDatas.xaxis;\n  chartData.series = chartDatas.data;\n\n  return (\n    <>\n      {isLoading ? (\n        <SkeletonTotalGrowthBarChart />\n      ) : (\n        <MainCard>\n          <Grid container spacing={gridSpacing}>\n            <Grid item xs={12}>\n              <Grid container alignItems=\"center\" justifyContent=\"space-between\">\n                <Grid item>\n                  <Typography variant=\"h3\">统计</Typography>\n                </Grid>\n              </Grid>\n            </Grid>\n            <Grid item xs={12}>\n              {chartData.series ? (\n                <Chart {...chartData} />\n              ) : (\n                <Box\n                  sx={{\n                    minHeight: '490px',\n                    display: 'flex',\n                    alignItems: 'center',\n                    justifyContent: 'center'\n                  }}\n                >\n                  <Typography variant=\"h3\" color={'#697586'}>\n                    暂无数据\n                  </Typography>\n                </Box>\n              )}\n            </Grid>\n          </Grid>\n        </MainCard>\n      )}\n    </>\n  );\n};\n\nStatisticalBarChart.propTypes = {\n  isLoading: PropTypes.bool\n};\n\nexport default StatisticalBarChart;\n\nconst chartData = {\n  height: 480,\n  type: 'bar',\n  options: {\n    colors: [\n      '#008FFB',\n      '#00E396',\n      '#FEB019',\n      '#FF4560',\n      '#775DD0',\n      '#55efc4',\n      '#81ecec',\n      '#74b9ff',\n      '#a29bfe',\n      '#00b894',\n      '#00cec9',\n      '#0984e3',\n      '#6c5ce7',\n      '#ffeaa7',\n      '#fab1a0',\n      '#ff7675',\n      '#fd79a8',\n      '#fdcb6e',\n      '#e17055',\n      '#d63031',\n      '#e84393'\n    ],\n    chart: {\n      id: 'bar-chart',\n      stacked: true,\n      toolbar: {\n        show: true\n      },\n      zoom: {\n        enabled: true\n      }\n    },\n    responsive: [\n      {\n        breakpoint: 480,\n        options: {\n          legend: {\n            position: 'bottom',\n            offsetX: -10,\n            offsetY: 0\n          }\n        }\n      }\n    ],\n    plotOptions: {\n      bar: {\n        horizontal: false,\n        columnWidth: '50%'\n      }\n    },\n    xaxis: {\n      type: 'category',\n      categories: []\n    },\n    legend: {\n      show: true,\n      fontSize: '14px',\n      fontFamily: `'Roboto', sans-serif`,\n      position: 'bottom',\n      offsetX: 20,\n      labels: {\n        useSeriesColors: false\n      },\n      markers: {\n        width: 16,\n        height: 16,\n        radius: 5\n      },\n      itemMargin: {\n        horizontal: 15,\n        vertical: 8\n      }\n    },\n    fill: {\n      type: 'solid'\n    },\n    dataLabels: {\n      enabled: false\n    },\n    grid: {\n      show: true\n    },\n    tooltip: {\n      theme: 'dark',\n      fixed: {\n        enabled: false\n      },\n      y: {\n        formatter: function (val) {\n          return '$' + val;\n        }\n      },\n      marker: {\n        show: false\n      }\n    }\n  },\n  series: []\n};\n"
  },
  {
    "path": "web/berry/src/views/Dashboard/component/StatisticalCard.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { styled, useTheme } from '@mui/material/styles';\nimport { Avatar, Box, List, ListItem, ListItemAvatar, ListItemText, Typography } from '@mui/material';\n\n// project imports\nimport MainCard from 'ui-component/cards/MainCard';\nimport TotalIncomeCard from 'ui-component/cards/Skeleton/TotalIncomeCard';\n\n// assets\nimport TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined';\n\n// styles\nconst CardWrapper = styled(MainCard)(({ theme }) => ({\n  backgroundColor: theme.palette.primary.dark,\n  color: theme.palette.primary.light,\n  overflow: 'hidden',\n  position: 'relative',\n  '&:after': {\n    content: '\"\"',\n    position: 'absolute',\n    width: 210,\n    height: 210,\n    background: `linear-gradient(210.04deg, ${theme.palette.primary[200]} -50.94%, rgba(144, 202, 249, 0) 83.49%)`,\n    borderRadius: '50%',\n    top: -30,\n    right: -180\n  },\n  '&:before': {\n    content: '\"\"',\n    position: 'absolute',\n    width: 210,\n    height: 210,\n    background: `linear-gradient(140.9deg, ${theme.palette.primary[200]} -14.02%, rgba(144, 202, 249, 0) 77.58%)`,\n    borderRadius: '50%',\n    top: -160,\n    right: -130\n  }\n}));\n\n// ==============================|| DASHBOARD - TOTAL INCOME DARK CARD ||============================== //\n\nconst StatisticalCard = ({ isLoading }) => {\n  const theme = useTheme();\n\n  return (\n    <>\n      {isLoading ? (\n        <TotalIncomeCard />\n      ) : (\n        <CardWrapper border={false} content={false}>\n          <Box sx={{ p: 2 }}>\n            <List sx={{ py: 0 }}>\n              <ListItem alignItems=\"center\" disableGutters sx={{ py: 0 }}>\n                <ListItemAvatar>\n                  <Avatar\n                    variant=\"rounded\"\n                    sx={{\n                      ...theme.typography.commonAvatar,\n                      ...theme.typography.largeAvatar,\n                      backgroundColor: theme.palette.primary[800],\n                      color: '#fff'\n                    }}\n                  >\n                    <TableChartOutlinedIcon fontSize=\"inherit\" />\n                  </Avatar>\n                </ListItemAvatar>\n                <ListItemText\n                  sx={{\n                    py: 0,\n                    mt: 0.45,\n                    mb: 0.45\n                  }}\n                  primary={\n                    <Typography variant=\"h4\" sx={{ color: '#fff' }}>\n                      $203k\n                    </Typography>\n                  }\n                  secondary={\n                    <Typography variant=\"subtitle2\" sx={{ color: 'primary.light', mt: 0.25 }}>\n                      Total Income\n                    </Typography>\n                  }\n                />\n              </ListItem>\n            </List>\n          </Box>\n        </CardWrapper>\n      )}\n    </>\n  );\n};\n\nStatisticalCard.propTypes = {\n  isLoading: PropTypes.bool\n};\n\nexport default StatisticalCard;\n"
  },
  {
    "path": "web/berry/src/views/Dashboard/component/StatisticalLineChartCard.js",
    "content": "import PropTypes from 'prop-types';\n\n// material-ui\nimport { useTheme, styled } from '@mui/material/styles';\nimport { Box, Grid, Typography } from '@mui/material';\n\n// third-party\nimport Chart from 'react-apexcharts';\n\n// project imports\nimport MainCard from 'ui-component/cards/MainCard';\nimport SkeletonTotalOrderCard from 'ui-component/cards/Skeleton/EarningCard';\n\nconst CardWrapper = styled(MainCard)(({ theme }) => ({\n  ...theme.typography.CardWrapper,\n  color: '#fff',\n  overflow: 'hidden',\n  position: 'relative',\n  '&>div': {\n    position: 'relative',\n    zIndex: 5\n  },\n  '&:after': {\n    content: '\"\"',\n    position: 'absolute',\n    width: 210,\n    height: 210,\n    background: theme.palette.primary[800],\n    borderRadius: '50%',\n    zIndex: 1,\n    top: -85,\n    right: -95,\n    [theme.breakpoints.down('sm')]: {\n      top: -105,\n      right: -140\n    }\n  },\n  '&:before': {\n    content: '\"\"',\n    position: 'absolute',\n    zIndex: 1,\n    width: 210,\n    height: 210,\n    background: theme.palette.primary[800],\n    borderRadius: '50%',\n    top: -125,\n    right: -15,\n    opacity: 0.5,\n    [theme.breakpoints.down('sm')]: {\n      top: -155,\n      right: -70\n    }\n  }\n}));\n\n// ==============================|| DASHBOARD - TOTAL ORDER LINE CHART CARD ||============================== //\n\nconst StatisticalLineChartCard = ({ isLoading, title, chartData, todayValue }) => {\n  const theme = useTheme();\n\n  return (\n    <>\n      {isLoading ? (\n        <SkeletonTotalOrderCard />\n      ) : (\n        <CardWrapper border={false} content={false}>\n          <Box sx={{ p: 2.25 }}>\n            <Grid>\n              <Grid item sx={{ mb: 0.75 }}>\n                <Grid container alignItems=\"center\">\n                  <Grid item xs={6}>\n                    <Grid container alignItems=\"center\">\n                      <Grid item>\n                        <Typography sx={{ fontSize: '2.125rem', fontWeight: 500, mr: 1, mt: 1.75, mb: 0.75 }}>\n                          {todayValue || '0'}\n                        </Typography>\n                      </Grid>\n                      <Grid item></Grid>\n                      <Grid item xs={12}>\n                        <Typography\n                          sx={{\n                            fontSize: '1rem',\n                            fontWeight: 500,\n                            color: theme.palette.primary[200]\n                          }}\n                        >\n                          {title}\n                        </Typography>\n                      </Grid>\n                    </Grid>\n                  </Grid>\n                  <Grid item xs={6}>\n                    {chartData ? (\n                      <Chart {...chartData} />\n                    ) : (\n                      <Typography\n                        sx={{\n                          fontSize: '1rem',\n                          fontWeight: 500,\n                          color: theme.palette.primary[200]\n                        }}\n                      >\n                        无数据\n                      </Typography>\n                    )}\n                  </Grid>\n                </Grid>\n              </Grid>\n            </Grid>\n          </Box>\n        </CardWrapper>\n      )}\n    </>\n  );\n};\n\nStatisticalLineChartCard.propTypes = {\n  isLoading: PropTypes.bool,\n  title: PropTypes.string\n};\n\nexport default StatisticalLineChartCard;\n"
  },
  {
    "path": "web/berry/src/views/Dashboard/index.js",
    "content": "import { useEffect, useState } from 'react';\nimport { Grid, Typography } from '@mui/material';\nimport { gridSpacing } from 'store/constant';\nimport StatisticalLineChartCard from './component/StatisticalLineChartCard';\nimport StatisticalBarChart from './component/StatisticalBarChart';\nimport { generateChartOptions, getLastSevenDays } from 'utils/chart';\nimport { API } from 'utils/api';\nimport { showError, calculateQuota, renderNumber } from 'utils/common';\nimport UserCard from 'ui-component/cards/UserCard';\n\nconst Dashboard = () => {\n  const [isLoading, setLoading] = useState(true);\n  const [statisticalData, setStatisticalData] = useState([]);\n  const [requestChart, setRequestChart] = useState(null);\n  const [quotaChart, setQuotaChart] = useState(null);\n  const [tokenChart, setTokenChart] = useState(null);\n  const [users, setUsers] = useState([]);\n\n  const userDashboard = async () => {\n    const res = await API.get('/api/user/dashboard');\n    const { success, message, data } = res.data;\n    if (success) {\n      if (data) {\n        let lineData = getLineDataGroup(data);\n        setRequestChart(getLineCardOption(lineData, 'RequestCount'));\n        setQuotaChart(getLineCardOption(lineData, 'Quota'));\n        setTokenChart(getLineCardOption(lineData, 'PromptTokens'));\n        setStatisticalData(getBarDataGroup(data));\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const loadUser = async () => {\n    let res = await API.get(`/api/user/self`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setUsers(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    userDashboard();\n    loadUser();\n  }, []);\n\n  return (\n    <Grid container spacing={gridSpacing}>\n      <Grid item xs={12}>\n        <Grid container spacing={gridSpacing}>\n          <Grid item lg={4} xs={12}>\n            <StatisticalLineChartCard\n              isLoading={isLoading}\n              title=\"今日请求量\"\n              chartData={requestChart?.chartData}\n              todayValue={requestChart?.todayValue}\n            />\n          </Grid>\n          <Grid item lg={4} xs={12}>\n            <StatisticalLineChartCard\n              isLoading={isLoading}\n              title=\"今日消费\"\n              chartData={quotaChart?.chartData}\n              todayValue={quotaChart?.todayValue}\n            />\n          </Grid>\n          <Grid item lg={4} xs={12}>\n            <StatisticalLineChartCard\n              isLoading={isLoading}\n              title=\"今日 token\"\n              chartData={tokenChart?.chartData}\n              todayValue={tokenChart?.todayValue}\n            />\n          </Grid>\n        </Grid>\n      </Grid>\n      <Grid item xs={12}>\n        <Grid container spacing={gridSpacing}>\n          <Grid item lg={8} xs={12}>\n            <StatisticalBarChart isLoading={isLoading} chartDatas={statisticalData} />\n          </Grid>\n          <Grid item lg={4} xs={12}>\n            <UserCard>\n              <Grid container spacing={gridSpacing} justifyContent=\"center\" alignItems=\"center\" paddingTop={'20px'}>\n                <Grid item xs={4}>\n                  <Typography variant=\"h4\">余额：</Typography>\n                </Grid>\n                <Grid item xs={8}>\n                  <Typography variant=\"h3\"> {users?.quota ? '$' + calculateQuota(users.quota) : '未知'}</Typography>\n                </Grid>\n                <Grid item xs={4}>\n                  <Typography variant=\"h4\">已使用：</Typography>\n                </Grid>\n                <Grid item xs={8}>\n                  <Typography variant=\"h3\"> {users?.used_quota ? '$' + calculateQuota(users.used_quota) : '未知'}</Typography>\n                </Grid>\n                <Grid item xs={4}>\n                  <Typography variant=\"h4\">调用次数：</Typography>\n                </Grid>\n                <Grid item xs={8}>\n                  <Typography variant=\"h3\"> {users?.request_count || '未知'}</Typography>\n                </Grid>\n              </Grid>\n            </UserCard>\n          </Grid>\n        </Grid>\n      </Grid>\n    </Grid>\n  );\n};\nexport default Dashboard;\n\nfunction getLineDataGroup(statisticalData) {\n  let groupedData = statisticalData.reduce((acc, cur) => {\n    if (!acc[cur.Day]) {\n      acc[cur.Day] = {\n        date: cur.Day,\n        RequestCount: 0,\n        Quota: 0,\n        PromptTokens: 0,\n        CompletionTokens: 0\n      };\n    }\n    acc[cur.Day].RequestCount += cur.RequestCount;\n    acc[cur.Day].Quota += cur.Quota;\n    acc[cur.Day].PromptTokens += cur.PromptTokens;\n    acc[cur.Day].CompletionTokens += cur.CompletionTokens;\n    return acc;\n  }, {});\n  let lastSevenDays = getLastSevenDays();\n  return lastSevenDays.map((day) => {\n    if (!groupedData[day]) {\n      return {\n        date: day,\n        RequestCount: 0,\n        Quota: 0,\n        PromptTokens: 0,\n        CompletionTokens: 0\n      };\n    } else {\n      return groupedData[day];\n    }\n  });\n}\n\nfunction getBarDataGroup(data) {\n  const lastSevenDays = getLastSevenDays();\n  const result = [];\n  const map = new Map();\n\n  for (const item of data) {\n    if (!map.has(item.ModelName)) {\n      const newData = { name: item.ModelName, data: new Array(7) };\n      map.set(item.ModelName, newData);\n      result.push(newData);\n    }\n    const index = lastSevenDays.indexOf(item.Day);\n    if (index !== -1) {\n      map.get(item.ModelName).data[index] = calculateQuota(item.Quota, 3);\n    }\n  }\n\n  for (const item of result) {\n    for (let i = 0; i < 7; i++) {\n      if (item.data[i] === undefined) {\n        item.data[i] = 0;\n      }\n    }\n  }\n\n  return { data: result, xaxis: lastSevenDays };\n}\n\nfunction getLineCardOption(lineDataGroup, field) {\n  let todayValue = 0;\n  let chartData = null;\n  const lastItem = lineDataGroup.length - 1;\n  let lineData = lineDataGroup.map((item, index) => {\n    let tmp = {\n      date: item.date,\n      value: item[field]\n    };\n    switch (field) {\n      case 'Quota':\n        tmp.value = calculateQuota(item.Quota, 3);\n        break;\n      case 'PromptTokens':\n        tmp.value += item.CompletionTokens;\n        break;\n    }\n\n    if (index == lastItem) {\n      todayValue = tmp.value;\n    }\n    return tmp;\n  });\n\n  switch (field) {\n    case 'RequestCount':\n      chartData = generateChartOptions(lineData, '次');\n      todayValue = renderNumber(todayValue);\n      break;\n    case 'Quota':\n      chartData = generateChartOptions(lineData, '美元');\n      todayValue = '$' + renderNumber(todayValue);\n      break;\n    case 'PromptTokens':\n      chartData = generateChartOptions(lineData, '');\n      todayValue = renderNumber(todayValue);\n      break;\n  }\n\n  return { chartData: chartData, todayValue: todayValue };\n}\n"
  },
  {
    "path": "web/berry/src/views/Error/index.js",
    "content": "import Box from '@mui/material/Box';\nimport Button from '@mui/material/Button';\nimport Container from '@mui/material/Container';\nimport NotFound from 'assets/images/404.svg';\nimport { useNavigate } from 'react-router';\n\n// ----------------------------------------------------------------------\n\nexport default function NotFoundView() {\n  const navigate = useNavigate();\n  const goBack = () => {\n    navigate(-1);\n  };\n  return (\n    <>\n      <Container>\n        <Box\n          sx={{\n            py: 12,\n            maxWidth: 480,\n            mx: 'auto',\n            display: 'flex',\n            minHeight: 'calc(100vh - 136px)',\n            textAlign: 'center',\n            alignItems: 'center',\n            flexDirection: 'column',\n            justifyContent: 'center'\n          }}\n        >\n          <Box\n            component=\"img\"\n            src={NotFound}\n            sx={{\n              mx: 'auto',\n              height: 260,\n              my: { xs: 5, sm: 10 }\n            }}\n          />\n\n          <Button size=\"large\" variant=\"contained\" onClick={goBack}>\n            返回\n          </Button>\n        </Box>\n      </Container>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/views/Home/baseIndex.js",
    "content": "import { Box, Typography, Button, Container, Stack } from '@mui/material';\nimport Grid from '@mui/material/Unstable_Grid2';\nimport { GitHub } from '@mui/icons-material';\n\nconst BaseIndex = () => (\n  <Box\n    sx={{\n      minHeight: 'calc(100vh - 136px)',\n      backgroundImage: 'linear-gradient(to right, #ff9966, #ff5e62)',\n      color: 'white',\n      p: 4\n    }}\n  >\n    <Container maxWidth=\"lg\">\n      <Grid container columns={12} wrap=\"nowrap\" alignItems=\"center\" sx={{ minHeight: 'calc(100vh - 230px)' }}>\n        <Grid md={7} lg={6}>\n          <Stack spacing={3}>\n            <Typography variant=\"h1\" sx={{ fontSize: '4rem', color: '#fff', lineHeight: 1.5 }}>\n              One API\n            </Typography>\n            <Typography variant=\"h4\" sx={{ fontSize: '1.5rem', color: '#fff', lineHeight: 1.5 }}>\n              All in one 的 OpenAI 接口 <br />\n              整合各种 API 访问方式 <br />\n              一键部署，开箱即用\n            </Typography>\n            <Button\n              variant=\"contained\"\n              startIcon={<GitHub />}\n              href=\"https://github.com/songquanpeng/one-api\"\n              target=\"_blank\"\n              sx={{ backgroundColor: '#24292e', color: '#fff', width: 'fit-content', boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)' }}\n            >\n              GitHub\n            </Button>\n          </Stack>\n        </Grid>\n      </Grid>\n    </Container>\n  </Box>\n);\n\nexport default BaseIndex;\n"
  },
  {
    "path": "web/berry/src/views/Home/index.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { showError, showNotice } from 'utils/common';\nimport { API } from 'utils/api';\nimport { marked } from 'marked';\nimport BaseIndex from './baseIndex';\nimport { Box, Container } from '@mui/material';\n\nconst Home = () => {\n  const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);\n  const [homePageContent, setHomePageContent] = useState('');\n  const displayNotice = async () => {\n    const res = await API.get('/api/notice');\n    const { success, message, data } = res.data;\n    if (success) {\n      let oldNotice = localStorage.getItem('notice');\n      if (data !== oldNotice && data !== '') {\n        const htmlNotice = marked(data);\n        showNotice(htmlNotice, true);\n        localStorage.setItem('notice', data);\n      }\n    } else {\n      showError(message);\n    }\n  };\n\n  const displayHomePageContent = async () => {\n    setHomePageContent(localStorage.getItem('home_page_content') || '');\n    const res = await API.get('/api/home_page_content');\n    const { success, message, data } = res.data;\n    if (success) {\n      let content = data;\n      if (!data.startsWith('https://')) {\n        content = marked.parse(data);\n      }\n      setHomePageContent(content);\n      localStorage.setItem('home_page_content', content);\n    } else {\n      showError(message);\n      setHomePageContent('加载首页内容失败...');\n    }\n    setHomePageContentLoaded(true);\n  };\n\n  useEffect(() => {\n    displayNotice().then();\n    displayHomePageContent().then();\n  }, []);\n\n  return (\n    <>\n      {homePageContentLoaded && homePageContent === '' ? (\n        <BaseIndex />\n      ) : (\n        <>\n          <Box>\n            {homePageContent.startsWith('https://') ? (\n              <iframe title=\"home_page_content\" src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} />\n            ) : (\n              <>\n                <Container>\n                  <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>\n                </Container>\n              </>\n            )}\n          </Box>\n        </>\n      )}\n    </>\n  );\n};\n\nexport default Home;\n"
  },
  {
    "path": "web/berry/src/views/Log/component/TableHead.js",
    "content": "import PropTypes from 'prop-types';\nimport { TableCell, TableHead, TableRow } from '@mui/material';\n\nconst LogTableHead = ({ userIsAdmin }) => {\n  return (\n    <TableHead>\n      <TableRow>\n        <TableCell>时间</TableCell>\n        {userIsAdmin && <TableCell>渠道</TableCell>}\n        {userIsAdmin && <TableCell>用户</TableCell>}\n        <TableCell>令牌</TableCell>\n        <TableCell>类型</TableCell>\n        <TableCell>模型</TableCell>\n        <TableCell>提示</TableCell>\n        <TableCell>补全</TableCell>\n        <TableCell>额度</TableCell>\n        <TableCell>详情</TableCell>\n      </TableRow>\n    </TableHead>\n  );\n};\n\nexport default LogTableHead;\n\nLogTableHead.propTypes = {\n  userIsAdmin: PropTypes.bool\n};\n"
  },
  {
    "path": "web/berry/src/views/Log/component/TableRow.js",
    "content": "import PropTypes from 'prop-types';\n\nimport { TableRow, TableCell } from '@mui/material';\n\nimport { timestamp2string, renderQuota } from 'utils/common';\nimport Label from 'ui-component/Label';\nimport LogType from '../type/LogType';\n\nfunction renderType(type) {\n  const typeOption = LogType[type];\n  if (typeOption) {\n    return (\n      <Label variant=\"filled\" color={typeOption.color}>\n        {' '}\n        {typeOption.text}{' '}\n      </Label>\n    );\n  } else {\n    return (\n      <Label variant=\"filled\" color=\"error\">\n        {' '}\n        未知{' '}\n      </Label>\n    );\n  }\n}\n\nexport default function LogTableRow({ item, userIsAdmin }) {\n  return (\n    <>\n      <TableRow tabIndex={item.id}>\n        <TableCell>{timestamp2string(item.created_at)}</TableCell>\n\n        {userIsAdmin && <TableCell>{item.channel || ''}</TableCell>}\n        {userIsAdmin && (\n          <TableCell>\n            <Label color=\"default\" variant=\"outlined\">\n              {item.username}\n            </Label>\n          </TableCell>\n        )}\n        <TableCell>\n          {item.token_name && (\n            <Label color=\"default\" variant=\"soft\">\n              {item.token_name}\n            </Label>\n          )}\n        </TableCell>\n        <TableCell>{renderType(item.type)}</TableCell>\n        <TableCell>\n          {item.model_name && (\n            <Label color=\"primary\" variant=\"outlined\">\n              {item.model_name}\n            </Label>\n          )}\n        </TableCell>\n        <TableCell>{item.prompt_tokens || ''}</TableCell>\n        <TableCell>{item.completion_tokens || ''}</TableCell>\n        <TableCell>{item.quota ? renderQuota(item.quota, 6) : ''}</TableCell>\n        <TableCell>{item.content}</TableCell>\n      </TableRow>\n    </>\n  );\n}\n\nLogTableRow.propTypes = {\n  item: PropTypes.object,\n  userIsAdmin: PropTypes.bool\n};\n"
  },
  {
    "path": "web/berry/src/views/Log/component/TableToolBar.js",
    "content": "import PropTypes from \"prop-types\";\nimport { useTheme } from \"@mui/material/styles\";\nimport {\n  IconUser,\n  IconKey,\n  IconBrandGithubCopilot,\n  IconSitemap,\n} from \"@tabler/icons-react\";\nimport {\n  InputAdornment,\n  OutlinedInput,\n  Stack,\n  FormControl,\n  InputLabel,\n  Select,\n  MenuItem,\n} from \"@mui/material\";\nimport { LocalizationProvider, DateTimePicker } from \"@mui/x-date-pickers\";\nimport { AdapterDayjs } from \"@mui/x-date-pickers/AdapterDayjs\";\nimport dayjs from \"dayjs\";\nimport LogType from \"../type/LogType\";\nrequire(\"dayjs/locale/zh-cn\");\n// ----------------------------------------------------------------------\n\nexport default function TableToolBar({\n  filterName,\n  handleFilterName,\n  userIsAdmin,\n}) {\n  const theme = useTheme();\n  const grey500 = theme.palette.grey[500];\n\n  return (\n    <>\n      <Stack\n        direction={{ xs: \"column\", sm: \"row\" }}\n        spacing={{ xs: 3, sm: 2, md: 4 }}\n        padding={\"24px\"}\n        paddingBottom={\"0px\"}\n      >\n        <FormControl>\n          <InputLabel htmlFor=\"channel-token_name-label\">令牌名称</InputLabel>\n          <OutlinedInput\n            id=\"token_name\"\n            name=\"token_name\"\n            sx={{\n              minWidth: \"100%\",\n            }}\n            label=\"令牌名称\"\n            value={filterName.token_name}\n            onChange={handleFilterName}\n            placeholder=\"令牌名称\"\n            startAdornment={\n              <InputAdornment position=\"start\">\n                <IconKey stroke={1.5} size=\"20px\" color={grey500} />\n              </InputAdornment>\n            }\n          />\n        </FormControl>\n        <FormControl>\n          <InputLabel htmlFor=\"channel-model_name-label\">模型名称</InputLabel>\n          <OutlinedInput\n            id=\"model_name\"\n            name=\"model_name\"\n            sx={{\n              minWidth: \"100%\",\n            }}\n            label=\"模型名称\"\n            value={filterName.model_name}\n            onChange={handleFilterName}\n            placeholder=\"模型名称\"\n            startAdornment={\n              <InputAdornment position=\"start\">\n                <IconBrandGithubCopilot\n                  stroke={1.5}\n                  size=\"20px\"\n                  color={grey500}\n                />\n              </InputAdornment>\n            }\n          />\n        </FormControl>\n\n        <FormControl>\n          <LocalizationProvider\n            dateAdapter={AdapterDayjs}\n            adapterLocale={\"zh-cn\"}\n          >\n            <DateTimePicker\n              label=\"起始时间\"\n              ampm={false}\n              name=\"start_timestamp\"\n              value={\n                filterName.start_timestamp === 0\n                  ? null\n                  : dayjs.unix(filterName.start_timestamp)\n              }\n              onChange={(value) => {\n                if (value === null) {\n                  handleFilterName({\n                    target: { name: \"start_timestamp\", value: 0 },\n                  });\n                  return;\n                }\n                handleFilterName({\n                  target: { name: \"start_timestamp\", value: value.unix() },\n                });\n              }}\n              slotProps={{\n                actionBar: {\n                  actions: [\"clear\", \"today\", \"accept\"],\n                },\n              }}\n            />\n          </LocalizationProvider>\n        </FormControl>\n\n        <FormControl>\n          <LocalizationProvider\n            dateAdapter={AdapterDayjs}\n            adapterLocale={\"zh-cn\"}\n          >\n            <DateTimePicker\n              label=\"结束时间\"\n              name=\"end_timestamp\"\n              ampm={false}\n              value={\n                filterName.end_timestamp === 0\n                  ? null\n                  : dayjs.unix(filterName.end_timestamp)\n              }\n              onChange={(value) => {\n                if (value === null) {\n                  handleFilterName({\n                    target: { name: \"end_timestamp\", value: 0 },\n                  });\n                  return;\n                }\n                handleFilterName({\n                  target: { name: \"end_timestamp\", value: value.unix() },\n                });\n              }}\n              slotProps={{\n                actionBar: {\n                  actions: [\"clear\", \"today\", \"accept\"],\n                },\n              }}\n            />\n          </LocalizationProvider>\n        </FormControl>\n      </Stack>\n\n      <Stack\n        direction={{ xs: \"column\", sm: \"row\" }}\n        spacing={{ xs: 3, sm: 2, md: 4 }}\n        padding={\"24px\"}\n      >\n        {userIsAdmin && (\n          <FormControl>\n            <InputLabel htmlFor=\"channel-channel-label\">渠道ID</InputLabel>\n            <OutlinedInput\n              id=\"channel\"\n              name=\"channel\"\n              sx={{\n                minWidth: \"100%\",\n              }}\n              label=\"渠道ID\"\n              value={filterName.channel}\n              onChange={handleFilterName}\n              placeholder=\"渠道ID\"\n              startAdornment={\n                <InputAdornment position=\"start\">\n                  <IconSitemap stroke={1.5} size=\"20px\" color={grey500} />\n                </InputAdornment>\n              }\n            />\n          </FormControl>\n        )}\n\n        {userIsAdmin && (\n          <FormControl>\n            <InputLabel htmlFor=\"channel-username-label\">用户名称</InputLabel>\n            <OutlinedInput\n              id=\"username\"\n              name=\"username\"\n              sx={{\n                minWidth: \"100%\",\n              }}\n              label=\"用户名称\"\n              value={filterName.username}\n              onChange={handleFilterName}\n              placeholder=\"用户名称\"\n              startAdornment={\n                <InputAdornment position=\"start\">\n                  <IconUser stroke={1.5} size=\"20px\" color={grey500} />\n                </InputAdornment>\n              }\n            />\n          </FormControl>\n        )}\n\n        <FormControl sx={{ minWidth: \"22%\" }}>\n          <InputLabel htmlFor=\"channel-type-label\">类型</InputLabel>\n          <Select\n            id=\"channel-type-label\"\n            label=\"类型\"\n            value={filterName.type}\n            name=\"type\"\n            onChange={handleFilterName}\n            sx={{\n              minWidth: \"100%\",\n            }}\n            MenuProps={{\n              PaperProps: {\n                style: {\n                  maxHeight: 200,\n                },\n              },\n            }}\n          >\n            {Object.values(LogType).map((option) => {\n              return (\n                <MenuItem key={option.value} value={option.value}>\n                  {option.text}\n                </MenuItem>\n              );\n            })}\n          </Select>\n        </FormControl>\n      </Stack>\n    </>\n  );\n}\n\nTableToolBar.propTypes = {\n  filterName: PropTypes.object,\n  handleFilterName: PropTypes.func,\n  userIsAdmin: PropTypes.bool,\n};\n"
  },
  {
    "path": "web/berry/src/views/Log/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport { showError } from 'utils/common';\n\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableContainer from '@mui/material/TableContainer';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport TablePagination from '@mui/material/TablePagination';\nimport LinearProgress from '@mui/material/LinearProgress';\nimport ButtonGroup from '@mui/material/ButtonGroup';\nimport Toolbar from '@mui/material/Toolbar';\n\nimport { Button, Card, Stack, Container, Typography, Box } from '@mui/material';\nimport LogTableRow from './component/TableRow';\nimport LogTableHead from './component/TableHead';\nimport TableToolBar from './component/TableToolBar';\nimport { API } from 'utils/api';\nimport { isAdmin } from 'utils/common';\nimport { ITEMS_PER_PAGE } from 'constants';\nimport { IconRefresh, IconSearch } from '@tabler/icons-react';\n\nexport default function Log() {\n  const originalKeyword = {\n    p: 0,\n    username: '',\n    token_name: '',\n    model_name: '',\n    start_timestamp: 0,\n    end_timestamp: new Date().getTime() / 1000 + 3600,\n    type: 0,\n    channel: ''\n  };\n  const [logs, setLogs] = useState([]);\n  const [activePage, setActivePage] = useState(0);\n  const [searching, setSearching] = useState(false);\n  const [searchKeyword, setSearchKeyword] = useState(originalKeyword);\n  const [initPage, setInitPage] = useState(true);\n  const userIsAdmin = isAdmin();\n\n  const loadLogs = async (startIdx) => {\n    setSearching(true);\n    const url = userIsAdmin ? '/api/log/' : '/api/log/self/';\n    const query = searchKeyword;\n\n    query.p = startIdx;\n    if (!userIsAdmin) {\n      delete query.username;\n      delete query.channel;\n    }\n    const res = await API.get(url, { params: query });\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setLogs(data);\n      } else {\n        let newLogs = [...logs];\n        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setLogs(newLogs);\n      }\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const onPaginationChange = (event, activePage) => {\n    (async () => {\n      if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE)) {\n        // In this case we have to load more data and then append them.\n        await loadLogs(activePage);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const searchLogs = async (event) => {\n    event.preventDefault();\n    await loadLogs(0);\n    setActivePage(0);\n    return;\n  };\n\n  const handleSearchKeyword = (event) => {\n    setSearchKeyword({ ...searchKeyword, [event.target.name]: event.target.value });\n  };\n\n  // 处理刷新\n  const handleRefresh = () => {\n    setInitPage(true);\n  };\n\n  useEffect(() => {\n    setSearchKeyword(originalKeyword);\n    setActivePage(0);\n    loadLogs(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n    setInitPage(false);\n  }, [initPage]);\n\n  return (\n    <>\n      <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"space-between\" mb={2.5}>\n        <Typography variant=\"h4\">日志</Typography>\n      </Stack>\n      <Card>\n        <Box component=\"form\" onSubmit={searchLogs} noValidate sx={{marginTop: 2}}>\n          <TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} userIsAdmin={userIsAdmin} />\n        </Box>\n        <Toolbar\n          sx={{\n            textAlign: 'right',\n            height: 50,\n            display: 'flex',\n            justifyContent: 'space-between',\n            p: (theme) => theme.spacing(0, 1, 0, 3)\n          }}\n        >\n          <Container>\n            <ButtonGroup variant=\"outlined\" aria-label=\"outlined small primary button group\" sx={{marginBottom: 2}}>\n              <Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>\n                刷新/清除搜索条件\n              </Button>\n\n              <Button onClick={searchLogs} startIcon={<IconSearch width={'18px'} />}>\n                搜索\n              </Button>\n            </ButtonGroup>\n          </Container>\n        </Toolbar>\n        {searching && <LinearProgress />}\n        <PerfectScrollbar component=\"div\">\n          <TableContainer sx={{ overflow: 'unset' }}>\n            <Table sx={{ minWidth: 800 }}>\n              <LogTableHead userIsAdmin={userIsAdmin} />\n              <TableBody>\n                {logs.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row, index) => (\n                  <LogTableRow item={row} key={`${row.id}_${index}`} userIsAdmin={userIsAdmin} />\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n        </PerfectScrollbar>\n        <TablePagination\n          page={activePage}\n          component=\"div\"\n          count={logs.length + (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}\n          rowsPerPage={ITEMS_PER_PAGE}\n          onPageChange={onPaginationChange}\n          rowsPerPageOptions={[ITEMS_PER_PAGE]}\n        />\n      </Card>\n    </>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/views/Log/type/LogType.js",
    "content": "const LOG_TYPE = {\n  0: { value: '0', text: '全部', color: '' },\n  1: { value: '1', text: '充值', color: 'primary' },\n  2: { value: '2', text: '消费', color: 'orange' },\n  3: { value: '3', text: '管理', color: 'default' },\n  4: { value: '4', text: '系统', color: 'secondary' },\n  5: { value: '5', text: '测试', color: 'secondary' },\n};\n\nexport default LOG_TYPE;\n"
  },
  {
    "path": "web/berry/src/views/Profile/component/EmailModal.js",
    "content": "import { useState, useEffect } from \"react\";\nimport PropTypes from \"prop-types\";\nimport React from \"react\";\nimport {\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  OutlinedInput,\n  Button,\n  InputLabel,\n  Grid,\n  InputAdornment,\n  FormControl,\n  FormHelperText,\n} from \"@mui/material\";\nimport { Formik } from \"formik\";\nimport { showError, showSuccess } from \"utils/common\";\nimport { useTheme } from \"@mui/material/styles\";\nimport * as Yup from \"yup\";\nimport useRegister from \"hooks/useRegister\";\nimport { API } from \"utils/api\";\n\nconst validationSchema = Yup.object().shape({\n  email: Yup.string().email(\"请输入正确的邮箱地址\").required(\"邮箱不能为空\"),\n  email_verification_code: Yup.string().required(\"验证码不能为空\"),\n});\n\nconst EmailModal = ({ open, handleClose, turnstileToken }) => {\n  const theme = useTheme();\n  const [countdown, setCountdown] = useState(30);\n  const [disableButton, setDisableButton] = useState(false);\n  const { sendVerificationCode } = useRegister();\n  const [loading, setLoading] = useState(false);\n\n  const submit = async (values, { setErrors, setStatus, setSubmitting }) => {\n    setLoading(true);\n    setSubmitting(true);\n    const res = await API.get(\n      `/api/oauth/email/bind?email=${values.email}&code=${values.email_verification_code}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(\"邮箱账户绑定成功！\");\n      setSubmitting(false);\n      setStatus({ success: true });\n      handleClose();\n    } else {\n      showError(message);\n      setErrors({ submit: message });\n    }\n    setLoading(false);\n  };\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval); // Clean up on unmount\n  }, [disableButton, countdown]);\n\n  const handleSendCode = async (email) => {\n    setDisableButton(true);\n    if (email === \"\") {\n      showError(\"请输入邮箱\");\n      return;\n    }\n    if (turnstileToken === \"\") {\n      showError(\"请稍后几秒重试，Turnstile 正在检查用户环境！\");\n      return;\n    }\n    setLoading(true);\n    const { success, message } = await sendVerificationCode(\n      email,\n      turnstileToken\n    );\n    setLoading(false);\n    if (!success) {\n      showError(message);\n      return;\n    }\n  };\n\n  return (\n    <Dialog open={open} onClose={handleClose}>\n      <DialogTitle>绑定邮箱</DialogTitle>\n      <DialogContent>\n        <Grid container direction=\"column\" alignItems=\"center\">\n          <Formik\n            initialValues={{\n              email: \"\",\n              email_verification_code: \"\",\n            }}\n            enableReinitialize\n            validationSchema={validationSchema}\n            onSubmit={submit}\n          >\n            {({\n              errors,\n              touched,\n              handleBlur,\n              handleChange,\n              handleSubmit,\n              values,\n            }) => (\n              <form noValidate onSubmit={handleSubmit}>\n                <FormControl\n                  fullWidth\n                  error={Boolean(touched.email && errors.email)}\n                  sx={{ ...theme.typography.customInput }}\n                >\n                  <InputLabel htmlFor=\"email\">Email</InputLabel>\n                  <OutlinedInput\n                    id=\"email\"\n                    type=\"text\"\n                    value={values.email}\n                    name=\"email\"\n                    onBlur={handleBlur}\n                    onChange={handleChange}\n                    endAdornment={\n                      <InputAdornment position=\"end\">\n                        <Button\n                          variant=\"contained\"\n                          color=\"primary\"\n                          onClick={() => handleSendCode(values.email)}\n                          disabled={disableButton || loading}\n                        >\n                          {disableButton\n                            ? `重新发送(${countdown})`\n                            : \"获取验证码\"}\n                        </Button>\n                      </InputAdornment>\n                    }\n                    inputProps={{}}\n                  />\n                  {touched.email && errors.email && (\n                    <FormHelperText error id=\"helper-email\">\n                      {errors.email}\n                    </FormHelperText>\n                  )}\n                </FormControl>\n                <FormControl\n                  fullWidth\n                  error={Boolean(\n                    touched.email_verification_code &&\n                      errors.email_verification_code\n                  )}\n                  sx={{ ...theme.typography.customInput }}\n                >\n                  <InputLabel htmlFor=\"email_verification_code\">\n                    验证码\n                  </InputLabel>\n                  <OutlinedInput\n                    id=\"email_verification_code\"\n                    type=\"text\"\n                    value={values.email_verification_code}\n                    name=\"email_verification_code\"\n                    onBlur={handleBlur}\n                    onChange={handleChange}\n                    inputProps={{}}\n                  />\n                  {touched.email_verification_code &&\n                    errors.email_verification_code && (\n                      <FormHelperText error id=\"helper-email_verification_code\">\n                        {errors.email_verification_code}\n                      </FormHelperText>\n                    )}\n                </FormControl>\n                <DialogActions>\n                  <Button onClick={handleClose}>取消</Button>\n                  <Button\n                    disableElevation\n                    disabled={loading}\n                    type=\"submit\"\n                    variant=\"contained\"\n                    color=\"primary\"\n                  >\n                    提交\n                  </Button>\n                </DialogActions>\n              </form>\n            )}\n          </Formik>\n        </Grid>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default EmailModal;\n\nEmailModal.propTypes = {\n  open: PropTypes.bool,\n  handleClose: PropTypes.func,\n};\n"
  },
  {
    "path": "web/berry/src/views/Profile/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport UserCard from 'ui-component/cards/UserCard';\nimport {\n  Card,\n  Button,\n  InputLabel,\n  FormControl,\n  OutlinedInput,\n  Stack,\n  Alert,\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  Divider,\n  SvgIcon\n} from '@mui/material';\nimport Grid from '@mui/material/Unstable_Grid2';\nimport SubCard from 'ui-component/cards/SubCard';\nimport { IconBrandWechat, IconBrandGithub, IconMail } from '@tabler/icons-react';\nimport Label from 'ui-component/Label';\nimport { API } from 'utils/api';\nimport { onOidcClicked, showError, showSuccess } from 'utils/common';\nimport { onGitHubOAuthClicked, onLarkOAuthClicked, copy } from 'utils/common';\nimport * as Yup from 'yup';\nimport WechatModal from 'views/Authentication/AuthForms/WechatModal';\nimport { useSelector } from 'react-redux';\nimport EmailModal from './component/EmailModal';\nimport Turnstile from 'react-turnstile';\nimport { ReactComponent as Lark } from 'assets/images/icons/lark.svg';\nimport { ReactComponent as OIDC } from 'assets/images/icons/oidc.svg';\n\nconst validationSchema = Yup.object().shape({\n  username: Yup.string().required('用户名 不能为空').min(3, '用户名 不能小于 3 个字符'),\n  display_name: Yup.string(),\n  password: Yup.string().test('password', '密码不能小于 8 个字符', (val) => {\n    return !val || val.length >= 8;\n  })\n});\n\nexport default function Profile() {\n  const [inputs, setInputs] = useState([]);\n  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [openWechat, setOpenWechat] = useState(false);\n  const [openEmail, setOpenEmail] = useState(false);\n  const status = useSelector((state) => state.siteInfo);\n\n  const handleWechatOpen = () => {\n    setOpenWechat(true);\n  };\n\n  const handleWechatClose = () => {\n    setOpenWechat(false);\n  };\n\n  const handleInputChange = (event) => {\n    let { name, value } = event.target;\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const loadUser = async () => {\n    let res = await API.get(`/api/user/self`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const bindWeChat = async (code) => {\n    if (code === '') return;\n    try {\n      const res = await API.get(`/api/oauth/wechat/bind?code=${code}`);\n      const { success, message } = res.data;\n      if (success) {\n        showSuccess('微信账户绑定成功！');\n      }\n      return { success, message };\n    } catch (err) {\n      // 请求失败，设置错误信息\n      return { success: false, message: '' };\n    }\n  };\n\n  const generateAccessToken = async () => {\n    const res = await API.get('/api/user/token');\n    const { success, message, data } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, access_token: data }));\n      copy(data, '访问令牌');\n    } else {\n      showError(message);\n    }\n\n    console.log(turnstileEnabled, turnstileSiteKey, status);\n  };\n\n  const submit = async () => {\n    try {\n      await validationSchema.validate(inputs);\n      const res = await API.put(`/api/user/self`, inputs);\n      const { success, message } = res.data;\n      if (success) {\n        showSuccess('用户信息更新成功！');\n      } else {\n        showError(message);\n      }\n    } catch (err) {\n      showError(err.message);\n    }\n  };\n\n  useEffect(() => {\n    if (status) {\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n    loadUser().then();\n  }, [status]);\n\n  function getOidcId(){\n    if (!inputs.oidc_id) return '';\n    let oidc_id = inputs.oidc_id;\n    if (inputs.oidc_id.length > 8) {\n      oidc_id = inputs.oidc_id.slice(0, 6) + '...' + inputs.oidc_id.slice(-6);\n    }\n    return oidc_id;\n  }\n\n  return (\n    <>\n      <UserCard>\n        <Card sx={{ paddingTop: '20px' }}>\n          <Stack spacing={2}>\n            <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"center\" spacing={2} sx={{ paddingBottom: '20px' }}>\n              <Label variant=\"ghost\" color={inputs.wechat_id ? 'primary' : 'default'}>\n                <IconBrandWechat /> {inputs.wechat_id || '未绑定'}\n              </Label>\n              <Label variant=\"ghost\" color={inputs.github_id ? 'primary' : 'default'}>\n                <IconBrandGithub /> {inputs.github_id || '未绑定'}\n              </Label>\n              <Label variant=\"ghost\" color={inputs.email ? 'primary' : 'default'}>\n                <IconMail /> {inputs.email || '未绑定'}\n              </Label>\n              <Label variant=\"ghost\" color={inputs.lark_id ? 'primary' : 'default'}>\n                <SvgIcon component={Lark} inheritViewBox=\"0 0 24 24\" /> {inputs.lark_id || '未绑定'}\n              </Label>\n              <Label variant=\"ghost\" color={inputs.oidc_id ? 'primary' : 'default'}>\n                <SvgIcon component={OIDC} inheritViewBox=\"0 0 24 24\" /> {getOidcId() || '未绑定'}\n              </Label>\n            </Stack>\n            <SubCard title=\"个人信息\">\n              <Grid container spacing={2}>\n                <Grid xs={12}>\n                  <FormControl fullWidth variant=\"outlined\">\n                    <InputLabel htmlFor=\"username\">用户名</InputLabel>\n                    <OutlinedInput\n                      id=\"username\"\n                      label=\"用户名\"\n                      type=\"text\"\n                      value={inputs.username || ''}\n                      onChange={handleInputChange}\n                      name=\"username\"\n                      placeholder=\"请输入用户名\"\n                    />\n                  </FormControl>\n                </Grid>\n                <Grid xs={12}>\n                  <FormControl fullWidth variant=\"outlined\">\n                    <InputLabel htmlFor=\"password\">密码</InputLabel>\n                    <OutlinedInput\n                      id=\"password\"\n                      label=\"密码\"\n                      type=\"password\"\n                      value={inputs.password || ''}\n                      onChange={handleInputChange}\n                      name=\"password\"\n                      placeholder=\"请输入密码\"\n                    />\n                  </FormControl>\n                </Grid>\n                <Grid xs={12}>\n                  <FormControl fullWidth variant=\"outlined\">\n                    <InputLabel htmlFor=\"display_name\">显示名称</InputLabel>\n                    <OutlinedInput\n                      id=\"display_name\"\n                      label=\"显示名称\"\n                      type=\"text\"\n                      value={inputs.display_name || ''}\n                      onChange={handleInputChange}\n                      name=\"display_name\"\n                      placeholder=\"请输入显示名称\"\n                    />\n                  </FormControl>\n                </Grid>\n                <Grid xs={12}>\n                  <Button variant=\"contained\" color=\"primary\" onClick={submit}>\n                    提交\n                  </Button>\n                </Grid>\n              </Grid>\n            </SubCard>\n            <SubCard title=\"账号绑定\">\n              <Grid container spacing={2}>\n                {status.wechat_login && !inputs.wechat_id && (\n                  <Grid xs={12} md={4}>\n                    <Button variant=\"contained\" onClick={handleWechatOpen}>\n                      绑定微信账号\n                    </Button>\n                  </Grid>\n                )}\n                {status.github_oauth && !inputs.github_id && (\n                  <Grid xs={12} md={4}>\n                    <Button variant=\"contained\" onClick={() => onGitHubOAuthClicked(status.github_client_id, true)}>\n                      绑定 GitHub 账号\n                    </Button>\n                  </Grid>\n                )}\n                {status.lark_client_id && !inputs.lark_id && (\n                  <Grid xs={12} md={4}>\n                    <Button variant=\"contained\" onClick={() => onLarkOAuthClicked(status.lark_client_id)}>\n                      绑定 飞书 账号\n                    </Button>\n                  </Grid>\n                )}\n                {status.oidc && !inputs.oidc_id && (\n                  <Grid xs={12} md={4}>\n                    <Button variant=\"contained\" onClick={() => onOidcClicked(status.oidc_authorization_endpoint,status.oidc_client_id,true)}>\n                      绑定 OIDC 账号\n                    </Button>\n                  </Grid>\n                )}\n                <Grid xs={12} md={4}>\n                  <Button\n                    variant=\"contained\"\n                    onClick={() => {\n                      setOpenEmail(true);\n                    }}\n                  >\n                    {inputs.email ? '更换邮箱' : '绑定邮箱'}\n                  </Button>\n                  {turnstileEnabled ? (\n                    <Turnstile\n                      sitekey={turnstileSiteKey}\n                      onVerify={(token) => {\n                        setTurnstileToken(token);\n                      }}\n                    />\n                  ) : (\n                    <></>\n                  )}\n                </Grid>\n              </Grid>\n            </SubCard>\n            <SubCard title=\"其他\">\n              <Grid container spacing={2}>\n                <Grid xs={12}>\n                  <Alert severity=\"info\">注意，此处生成的令牌用于系统管理，而非用于请求 OpenAI 相关的服务，请知悉。</Alert>\n                </Grid>\n                {inputs.access_token && (\n                  <Grid xs={12}>\n                    <Alert severity=\"error\">\n                      你的访问令牌是: <b>{inputs.access_token}</b> <br />\n                      请妥善保管。如有泄漏，请立即重置。\n                    </Alert>\n                  </Grid>\n                )}\n                <Grid xs={12}>\n                  <Button variant=\"contained\" onClick={generateAccessToken}>\n                    {inputs.access_token ? '重置访问令牌' : '生成访问令牌'}\n                  </Button>\n                </Grid>\n\n                <Grid xs={12}>\n                  <Button\n                    variant=\"contained\"\n                    color=\"error\"\n                    onClick={() => {\n                      setShowAccountDeleteModal(true);\n                    }}\n                  >\n                    删除帐号\n                  </Button>\n                </Grid>\n              </Grid>\n            </SubCard>\n          </Stack>\n        </Card>\n      </UserCard>\n      <Dialog open={showAccountDeleteModal} onClose={() => setShowAccountDeleteModal(false)} maxWidth={'md'}>\n        <DialogTitle sx={{ margin: '0px', fontWeight: 500, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>\n          危险操作\n        </DialogTitle>\n        <Divider />\n        <DialogContent>您正在删除自己的帐户，将清空所有数据且不可恢复</DialogContent>\n        <DialogActions>\n          <Button onClick={() => setShowAccountDeleteModal(false)}>取消</Button>\n          <Button\n            sx={{ color: 'error.main' }}\n            onClick={async () => {\n              setShowAccountDeleteModal(false);\n            }}\n          >\n            确定\n          </Button>\n        </DialogActions>\n      </Dialog>\n      <WechatModal open={openWechat} handleClose={handleWechatClose} wechatLogin={bindWeChat} qrCode={status.wechat_qrcode} />\n      <EmailModal\n        open={openEmail}\n        turnstileToken={turnstileToken}\n        handleClose={() => {\n          setOpenEmail(false);\n        }}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/views/Redemption/component/EditModal.js",
    "content": "import PropTypes from 'prop-types';\nimport * as Yup from 'yup';\nimport { Formik } from 'formik';\nimport { useTheme } from '@mui/material/styles';\nimport { useState, useEffect } from 'react';\nimport {\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  Button,\n  Divider,\n  FormControl,\n  InputLabel,\n  OutlinedInput,\n  InputAdornment,\n  FormHelperText\n} from '@mui/material';\n\nimport { renderQuotaWithPrompt, showSuccess, showError, downloadTextAsFile } from 'utils/common';\nimport { API } from 'utils/api';\n\nconst validationSchema = Yup.object().shape({\n  is_edit: Yup.boolean(),\n  name: Yup.string().required('名称 不能为空'),\n  quota: Yup.number().min(0, '必须大于等于0'),\n  count: Yup.number().when('is_edit', {\n    is: false,\n    then: Yup.number().min(1, '必须大于等于1'),\n    otherwise: Yup.number()\n  })\n});\n\nconst originInputs = {\n  is_edit: false,\n  name: '',\n  quota: 100000,\n  count: 1\n};\n\nconst EditModal = ({ open, redemptiondId, onCancel, onOk }) => {\n  const theme = useTheme();\n  const [inputs, setInputs] = useState(originInputs);\n\n  const submit = async (values, { setErrors, setStatus, setSubmitting }) => {\n    setSubmitting(true);\n\n    let res;\n    if (values.is_edit) {\n      res = await API.put(`/api/redemption/`, { ...values, id: parseInt(redemptiondId) });\n    } else {\n      res = await API.post(`/api/redemption/`, values);\n    }\n    const { success, message, data } = res.data;\n    if (success) {\n      if (values.is_edit) {\n        showSuccess('兑换码更新成功！');\n      } else {\n        showSuccess('兑换码创建成功！');\n        if (data.length > 1) {\n          let text = '';\n          for (let i = 0; i < data.length; i++) {\n            text += data[i] + '\\n';\n          }\n          downloadTextAsFile(text, `${values.name}.txt`);\n        }\n      }\n      setSubmitting(false);\n      setStatus({ success: true });\n      onOk(true);\n    } else {\n      showError(message);\n      setErrors({ submit: message });\n    }\n  };\n\n  const loadRedemptiond = async () => {\n    let res = await API.get(`/api/redemption/${redemptiondId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      data.is_edit = true;\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    if (redemptiondId) {\n      loadRedemptiond().then();\n    } else {\n      setInputs(originInputs);\n    }\n  }, [redemptiondId]);\n\n  return (\n    <Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>\n      <DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>\n        {redemptiondId ? '编辑兑换码' : '新建兑换码'}\n      </DialogTitle>\n      <Divider />\n      <DialogContent>\n        <Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>\n          {({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (\n            <form noValidate onSubmit={handleSubmit}>\n              <FormControl fullWidth error={Boolean(touched.name && errors.name)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-name-label\">名称</InputLabel>\n                <OutlinedInput\n                  id=\"channel-name-label\"\n                  label=\"名称\"\n                  type=\"text\"\n                  value={values.name}\n                  name=\"name\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'name' }}\n                  aria-describedby=\"helper-text-channel-name-label\"\n                />\n                {touched.name && errors.name && (\n                  <FormHelperText error id=\"helper-tex-channel-name-label\">\n                    {errors.name}\n                  </FormHelperText>\n                )}\n              </FormControl>\n\n              <FormControl fullWidth error={Boolean(touched.quota && errors.quota)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-quota-label\">额度</InputLabel>\n                <OutlinedInput\n                  id=\"channel-quota-label\"\n                  label=\"额度\"\n                  type=\"number\"\n                  value={values.quota}\n                  name=\"quota\"\n                  endAdornment={<InputAdornment position=\"end\">{renderQuotaWithPrompt(values.quota)}</InputAdornment>}\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  aria-describedby=\"helper-text-channel-quota-label\"\n                  disabled={values.unlimited_quota}\n                />\n\n                {touched.quota && errors.quota && (\n                  <FormHelperText error id=\"helper-tex-channel-quota-label\">\n                    {errors.quota}\n                  </FormHelperText>\n                )}\n              </FormControl>\n\n              {!values.is_edit && (\n                <FormControl fullWidth error={Boolean(touched.count && errors.count)} sx={{ ...theme.typography.otherInput }}>\n                  <InputLabel htmlFor=\"channel-count-label\">数量</InputLabel>\n                  <OutlinedInput\n                    id=\"channel-count-label\"\n                    label=\"数量\"\n                    type=\"number\"\n                    value={values.count}\n                    name=\"count\"\n                    onBlur={handleBlur}\n                    onChange={handleChange}\n                    aria-describedby=\"helper-text-channel-count-label\"\n                  />\n\n                  {touched.count && errors.count && (\n                    <FormHelperText error id=\"helper-tex-channel-count-label\">\n                      {errors.count}\n                    </FormHelperText>\n                  )}\n                </FormControl>\n              )}\n              <DialogActions>\n                <Button onClick={onCancel}>取消</Button>\n                <Button disableElevation disabled={isSubmitting} type=\"submit\" variant=\"contained\" color=\"primary\">\n                  提交\n                </Button>\n              </DialogActions>\n            </form>\n          )}\n        </Formik>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default EditModal;\n\nEditModal.propTypes = {\n  open: PropTypes.bool,\n  redemptiondId: PropTypes.number,\n  onCancel: PropTypes.func,\n  onOk: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/Redemption/component/TableHead.js",
    "content": "import { TableCell, TableHead, TableRow } from '@mui/material';\n\nconst RedemptionTableHead = () => {\n  return (\n    <TableHead>\n      <TableRow>\n        <TableCell>ID</TableCell>\n        <TableCell>名称</TableCell>\n        <TableCell>状态</TableCell>\n        <TableCell>额度</TableCell>\n        <TableCell>创建时间</TableCell>\n        <TableCell>兑换时间</TableCell>\n        <TableCell>操作</TableCell>\n      </TableRow>\n    </TableHead>\n  );\n};\n\nexport default RedemptionTableHead;\n"
  },
  {
    "path": "web/berry/src/views/Redemption/component/TableRow.js",
    "content": "import PropTypes from 'prop-types';\nimport { useState } from 'react';\n\nimport {\n  Popover,\n  TableRow,\n  MenuItem,\n  TableCell,\n  IconButton,\n  Dialog,\n  DialogActions,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Button,\n  Stack\n} from '@mui/material';\n\nimport Label from 'ui-component/Label';\nimport TableSwitch from 'ui-component/Switch';\nimport { timestamp2string, renderQuota, copy } from 'utils/common';\n\nimport { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';\n\nexport default function RedemptionTableRow({ item, manageRedemption, handleOpenModal, setModalRedemptionId }) {\n  const [open, setOpen] = useState(null);\n  const [openDelete, setOpenDelete] = useState(false);\n  const [statusSwitch, setStatusSwitch] = useState(item.status);\n\n  const handleDeleteOpen = () => {\n    handleCloseMenu();\n    setOpenDelete(true);\n  };\n\n  const handleDeleteClose = () => {\n    setOpenDelete(false);\n  };\n\n  const handleOpenMenu = (event) => {\n    setOpen(event.currentTarget);\n  };\n\n  const handleCloseMenu = () => {\n    setOpen(null);\n  };\n\n  const handleStatus = async () => {\n    const switchVlue = statusSwitch === 1 ? 2 : 1;\n    const { success } = await manageRedemption(item.id, 'status', switchVlue);\n    if (success) {\n      setStatusSwitch(switchVlue);\n    }\n  };\n\n  const handleDelete = async () => {\n    handleCloseMenu();\n    await manageRedemption(item.id, 'delete', '');\n  };\n\n  return (\n    <>\n      <TableRow tabIndex={item.id}>\n        <TableCell>{item.id}</TableCell>\n\n        <TableCell>{item.name}</TableCell>\n\n        <TableCell>\n          {item.status !== 1 && item.status !== 2 ? (\n            <Label variant=\"filled\" color={item.status === 3 ? 'success' : 'orange'}>\n              {item.status === 3 ? '已使用' : '未知'}\n            </Label>\n          ) : (\n            <TableSwitch id={`switch-${item.id}`} checked={statusSwitch === 1} onChange={handleStatus} />\n          )}\n        </TableCell>\n\n        <TableCell>{renderQuota(item.quota)}</TableCell>\n        <TableCell>{timestamp2string(item.created_time)}</TableCell>\n        <TableCell>{item.redeemed_time ? timestamp2string(item.redeemed_time) : '尚未兑换'}</TableCell>\n        <TableCell>\n          <Stack direction=\"row\" spacing={1}>\n            <Button\n              variant=\"contained\"\n              color=\"primary\"\n              onClick={() => {\n                copy(item.key, '兑换码');\n              }}\n            >\n              复制\n            </Button>\n            <IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>\n              <IconDotsVertical />\n            </IconButton>\n          </Stack>\n        </TableCell>\n      </TableRow>\n\n      <Popover\n        open={!!open}\n        anchorEl={open}\n        onClose={handleCloseMenu}\n        anchorOrigin={{ vertical: 'top', horizontal: 'left' }}\n        transformOrigin={{ vertical: 'top', horizontal: 'right' }}\n        PaperProps={{\n          sx: { width: 140 }\n        }}\n      >\n        <MenuItem\n          disabled={item.status !== 1 && item.status !== 2}\n          onClick={() => {\n            handleCloseMenu();\n            handleOpenModal();\n            setModalRedemptionId(item.id);\n          }}\n        >\n          <IconEdit style={{ marginRight: '16px' }} />\n          编辑\n        </MenuItem>\n        <MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>\n          <IconTrash style={{ marginRight: '16px' }} />\n          删除\n        </MenuItem>\n      </Popover>\n\n      <Dialog open={openDelete} onClose={handleDeleteClose}>\n        <DialogTitle>删除兑换码</DialogTitle>\n        <DialogContent>\n          <DialogContentText>是否删除兑换码 {item.name}？</DialogContentText>\n        </DialogContent>\n        <DialogActions>\n          <Button onClick={handleDeleteClose}>关闭</Button>\n          <Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>\n            删除\n          </Button>\n        </DialogActions>\n      </Dialog>\n    </>\n  );\n}\n\nRedemptionTableRow.propTypes = {\n  item: PropTypes.object,\n  manageRedemption: PropTypes.func,\n  handleOpenModal: PropTypes.func,\n  setModalRedemptionId: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/Redemption/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport { showError, showSuccess } from 'utils/common';\n\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableContainer from '@mui/material/TableContainer';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport TablePagination from '@mui/material/TablePagination';\nimport LinearProgress from '@mui/material/LinearProgress';\nimport ButtonGroup from '@mui/material/ButtonGroup';\nimport Toolbar from '@mui/material/Toolbar';\n\nimport { Button, Card, Box, Stack, Container, Typography } from '@mui/material';\nimport RedemptionTableRow from './component/TableRow';\nimport RedemptionTableHead from './component/TableHead';\nimport TableToolBar from 'ui-component/TableToolBar';\nimport { API } from 'utils/api';\nimport { ITEMS_PER_PAGE } from 'constants';\nimport { IconRefresh, IconPlus } from '@tabler/icons-react';\nimport EditeModal from './component/EditModal';\n\n// ----------------------------------------------------------------------\nexport default function Redemption() {\n  const [redemptions, setRedemptions] = useState([]);\n  const [activePage, setActivePage] = useState(0);\n  const [searching, setSearching] = useState(false);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [openModal, setOpenModal] = useState(false);\n  const [editRedemptionId, setEditRedemptionId] = useState(0);\n\n  const loadRedemptions = async (startIdx) => {\n    setSearching(true);\n    const res = await API.get(`/api/redemption/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setRedemptions(data);\n      } else {\n        let newRedemptions = [...redemptions];\n        newRedemptions.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setRedemptions(newRedemptions);\n      }\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const onPaginationChange = (event, activePage) => {\n    (async () => {\n      if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE)) {\n        // In this case we have to load more data and then append them.\n        await loadRedemptions(activePage);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const searchRedemptions = async (event) => {\n    event.preventDefault();\n    if (searchKeyword === '') {\n      await loadRedemptions(0);\n      setActivePage(0);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setRedemptions(data);\n      setActivePage(0);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleSearchKeyword = (event) => {\n    setSearchKeyword(event.target.value);\n  };\n\n  const manageRedemptions = async (id, action, value) => {\n    const url = '/api/redemption/';\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(url + id);\n        break;\n      case 'status':\n        res = await API.put(url + '?status_only=true', {\n          ...data,\n          status: value\n        });\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      if (action === 'delete') {\n        await loadRedemptions(0);\n      }\n    } else {\n      showError(message);\n    }\n\n    return res.data;\n  };\n\n  // 处理刷新\n  const handleRefresh = async () => {\n    await loadRedemptions(0);\n    setActivePage(0);\n    setSearchKeyword('');\n  };\n\n  const handleOpenModal = (redemptionId) => {\n    setEditRedemptionId(redemptionId);\n    setOpenModal(true);\n  };\n\n  const handleCloseModal = () => {\n    setOpenModal(false);\n    setEditRedemptionId(0);\n  };\n\n  const handleOkModal = (status) => {\n    if (status === true) {\n      handleCloseModal();\n      handleRefresh();\n    }\n  };\n\n  useEffect(() => {\n    loadRedemptions(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, []);\n\n  return (\n    <>\n      <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"space-between\" mb={2.5}>\n        <Typography variant=\"h4\">兑换</Typography>\n\n        <Button variant=\"contained\" color=\"primary\" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>\n          新建兑换码\n        </Button>\n      </Stack>\n      <Card>\n        <Box component=\"form\" onSubmit={searchRedemptions} noValidate sx={{marginTop: 2}}>\n          <TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} placeholder={'搜索兑换码的ID和名称...'} />\n        </Box>\n        <Toolbar\n          sx={{\n            textAlign: 'right',\n            height: 50,\n            display: 'flex',\n            justifyContent: 'space-between',\n            p: (theme) => theme.spacing(0, 1, 0, 3)\n          }}\n        >\n          <Container>\n            <ButtonGroup variant=\"outlined\" aria-label=\"outlined small primary button group\" sx={{marginBottom: 2}}>\n              <Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>\n                刷新\n              </Button>\n            </ButtonGroup>\n          </Container>\n        </Toolbar>\n        {searching && <LinearProgress />}\n        <PerfectScrollbar component=\"div\">\n          <TableContainer sx={{ overflow: 'unset' }}>\n            <Table sx={{ minWidth: 800 }}>\n              <RedemptionTableHead />\n              <TableBody>\n                {redemptions.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (\n                  <RedemptionTableRow\n                    item={row}\n                    manageRedemption={manageRedemptions}\n                    key={row.id}\n                    handleOpenModal={handleOpenModal}\n                    setModalRedemptionId={setEditRedemptionId}\n                  />\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n        </PerfectScrollbar>\n        <TablePagination\n          page={activePage}\n          component=\"div\"\n          count={redemptions.length + (redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}\n          rowsPerPage={ITEMS_PER_PAGE}\n          onPageChange={onPaginationChange}\n          rowsPerPageOptions={[ITEMS_PER_PAGE]}\n        />\n      </Card>\n      <EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} redemptiondId={editRedemptionId} />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/views/Setting/component/OperationSetting.js",
    "content": "import { useState, useEffect } from \"react\";\nimport SubCard from \"ui-component/cards/SubCard\";\nimport {\n  Stack,\n  FormControl,\n  InputLabel,\n  OutlinedInput,\n  Checkbox,\n  Button,\n  FormControlLabel,\n  TextField,\n} from \"@mui/material\";\nimport { showSuccess, showError, verifyJSON } from \"utils/common\";\nimport { API } from \"utils/api\";\nimport { AdapterDayjs } from \"@mui/x-date-pickers/AdapterDayjs\";\nimport { LocalizationProvider } from \"@mui/x-date-pickers/LocalizationProvider\";\nimport { DateTimePicker } from \"@mui/x-date-pickers/DateTimePicker\";\nimport dayjs from \"dayjs\";\nrequire(\"dayjs/locale/zh-cn\");\n\nconst OperationSetting = () => {\n  let now = new Date();\n  let [inputs, setInputs] = useState({\n    QuotaForNewUser: 0,\n    QuotaForInviter: 0,\n    QuotaForInvitee: 0,\n    QuotaRemindThreshold: 0,\n    PreConsumedQuota: 0,\n    ModelRatio: \"\",\n    CompletionRatio: \"\",\n    GroupRatio: \"\",\n    TopUpLink: \"\",\n    ChatLink: \"\",\n    QuotaPerUnit: 0,\n    AutomaticDisableChannelEnabled: \"\",\n    AutomaticEnableChannelEnabled: \"\",\n    ChannelDisableThreshold: 0,\n    LogConsumeEnabled: \"\",\n    DisplayInCurrencyEnabled: \"\",\n    DisplayTokenStatEnabled: \"\",\n    ApproximateTokenEnabled: \"\",\n    RetryTimes: 0,\n  });\n  const [originInputs, setOriginInputs] = useState({});\n  let [loading, setLoading] = useState(false);\n  let [historyTimestamp, setHistoryTimestamp] = useState(\n    now.getTime() / 1000 - 30 * 24 * 3600\n  ); // a month ago new Date().getTime() / 1000 + 3600\n\n  const getOptions = async () => {\n    const res = await API.get(\"/api/option/\");\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        if (item.key === \"ModelRatio\" || item.key === \"GroupRatio\" || item.key === \"CompletionRatio\") {\n          item.value = JSON.stringify(JSON.parse(item.value), null, 2);\n        }\n        if (item.value === '{}') {\n          item.value = '';\n        }\n        newInputs[item.key] = item.value;\n      });\n      setInputs(newInputs);\n      setOriginInputs(newInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    if (key.endsWith(\"Enabled\")) {\n      value = inputs[key] === \"true\" ? \"false\" : \"true\";\n    }\n    const res = await API.put(\"/api/option/\", {\n      key,\n      value,\n    });\n    const { success, message } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, [key]: value }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (event) => {\n    let { name, value } = event.target;\n\n    if (name.endsWith(\"Enabled\")) {\n      await updateOption(name, value);\n      showSuccess(\"设置成功！\");\n    } else {\n      setInputs((inputs) => ({ ...inputs, [name]: value }));\n    }\n  };\n\n  const submitConfig = async (group) => {\n    switch (group) {\n      case \"monitor\":\n        if (\n          originInputs[\"ChannelDisableThreshold\"] !==\n          inputs.ChannelDisableThreshold\n        ) {\n          await updateOption(\n            \"ChannelDisableThreshold\",\n            inputs.ChannelDisableThreshold\n          );\n        }\n        if (\n          originInputs[\"QuotaRemindThreshold\"] !== inputs.QuotaRemindThreshold\n        ) {\n          await updateOption(\n            \"QuotaRemindThreshold\",\n            inputs.QuotaRemindThreshold\n          );\n        }\n        break;\n      case \"ratio\":\n        if (originInputs[\"ModelRatio\"] !== inputs.ModelRatio) {\n          if (!verifyJSON(inputs.ModelRatio)) {\n            showError(\"模型倍率不是合法的 JSON 字符串\");\n            return;\n          }\n          await updateOption(\"ModelRatio\", inputs.ModelRatio);\n        }\n        if (originInputs[\"GroupRatio\"] !== inputs.GroupRatio) {\n          if (!verifyJSON(inputs.GroupRatio)) {\n            showError(\"分组倍率不是合法的 JSON 字符串\");\n            return;\n          }\n          await updateOption(\"GroupRatio\", inputs.GroupRatio);\n        }\n        if (originInputs['CompletionRatio'] !== inputs.CompletionRatio) {\n          if (!verifyJSON(inputs.CompletionRatio)) {\n            showError('补全倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('CompletionRatio', inputs.CompletionRatio);\n        }\n        break;\n      case \"quota\":\n        if (originInputs[\"QuotaForNewUser\"] !== inputs.QuotaForNewUser) {\n          await updateOption(\"QuotaForNewUser\", inputs.QuotaForNewUser);\n        }\n        if (originInputs[\"QuotaForInvitee\"] !== inputs.QuotaForInvitee) {\n          await updateOption(\"QuotaForInvitee\", inputs.QuotaForInvitee);\n        }\n        if (originInputs[\"QuotaForInviter\"] !== inputs.QuotaForInviter) {\n          await updateOption(\"QuotaForInviter\", inputs.QuotaForInviter);\n        }\n        if (originInputs[\"PreConsumedQuota\"] !== inputs.PreConsumedQuota) {\n          await updateOption(\"PreConsumedQuota\", inputs.PreConsumedQuota);\n        }\n        break;\n      case \"general\":\n        if (originInputs[\"TopUpLink\"] !== inputs.TopUpLink) {\n          await updateOption(\"TopUpLink\", inputs.TopUpLink);\n        }\n        if (originInputs[\"ChatLink\"] !== inputs.ChatLink) {\n          await updateOption(\"ChatLink\", inputs.ChatLink);\n        }\n        if (originInputs[\"QuotaPerUnit\"] !== inputs.QuotaPerUnit) {\n          await updateOption(\"QuotaPerUnit\", inputs.QuotaPerUnit);\n        }\n        if (originInputs[\"RetryTimes\"] !== inputs.RetryTimes) {\n          await updateOption(\"RetryTimes\", inputs.RetryTimes);\n        }\n        break;\n    }\n\n    showSuccess(\"保存成功！\");\n  };\n\n  const deleteHistoryLogs = async () => {\n    const res = await API.delete(\n      `/api/log/?target_timestamp=${Math.floor(historyTimestamp)}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`${data} 条日志已清理！`);\n      return;\n    }\n    showError(\"日志清理失败：\" + message);\n  };\n\n  return (\n    <Stack spacing={2}>\n      <SubCard title=\"通用设置\">\n        <Stack justifyContent=\"flex-start\" alignItems=\"flex-start\" spacing={2}>\n          <Stack\n            direction={{ sm: \"column\", md: \"row\" }}\n            spacing={{ xs: 3, sm: 2, md: 4 }}\n          >\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"TopUpLink\">充值链接</InputLabel>\n              <OutlinedInput\n                id=\"TopUpLink\"\n                name=\"TopUpLink\"\n                value={inputs.TopUpLink}\n                onChange={handleInputChange}\n                label=\"充值链接\"\n                placeholder=\"例如发卡网站的购买链接\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"ChatLink\">聊天链接</InputLabel>\n              <OutlinedInput\n                id=\"ChatLink\"\n                name=\"ChatLink\"\n                value={inputs.ChatLink}\n                onChange={handleInputChange}\n                label=\"聊天链接\"\n                placeholder=\"例如 ChatGPT Next Web 的部署地址\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"QuotaPerUnit\">单位额度</InputLabel>\n              <OutlinedInput\n                id=\"QuotaPerUnit\"\n                name=\"QuotaPerUnit\"\n                value={inputs.QuotaPerUnit}\n                onChange={handleInputChange}\n                label=\"单位额度\"\n                placeholder=\"一单位货币能兑换的额度\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"RetryTimes\">重试次数</InputLabel>\n              <OutlinedInput\n                id=\"RetryTimes\"\n                name=\"RetryTimes\"\n                value={inputs.RetryTimes}\n                onChange={handleInputChange}\n                label=\"重试次数\"\n                placeholder=\"重试次数\"\n                disabled={loading}\n              />\n            </FormControl>\n          </Stack>\n          <Stack\n            direction={{ sm: \"column\", md: \"row\" }}\n            spacing={{ xs: 3, sm: 2, md: 4 }}\n            justifyContent=\"flex-start\"\n            alignItems=\"flex-start\"\n          >\n            <FormControlLabel\n              sx={{ marginLeft: \"0px\" }}\n              label=\"以货币形式显示额度\"\n              control={\n                <Checkbox\n                  checked={inputs.DisplayInCurrencyEnabled === \"true\"}\n                  onChange={handleInputChange}\n                  name=\"DisplayInCurrencyEnabled\"\n                />\n              }\n            />\n\n            <FormControlLabel\n              label=\"Billing 相关 API 显示令牌额度而非用户额度\"\n              control={\n                <Checkbox\n                  checked={inputs.DisplayTokenStatEnabled === \"true\"}\n                  onChange={handleInputChange}\n                  name=\"DisplayTokenStatEnabled\"\n                />\n              }\n            />\n\n            <FormControlLabel\n              label=\"使用近似的方式估算 token 数以减少计算量\"\n              control={\n                <Checkbox\n                  checked={inputs.ApproximateTokenEnabled === \"true\"}\n                  onChange={handleInputChange}\n                  name=\"ApproximateTokenEnabled\"\n                />\n              }\n            />\n          </Stack>\n          <Button\n            variant=\"contained\"\n            onClick={() => {\n              submitConfig(\"general\").then();\n            }}\n          >\n            保存通用设置\n          </Button>\n        </Stack>\n      </SubCard>\n      <SubCard title=\"日志设置\">\n        <Stack\n          direction=\"column\"\n          justifyContent=\"flex-start\"\n          alignItems=\"flex-start\"\n          spacing={2}\n        >\n          <FormControlLabel\n            label=\"启用日志消费\"\n            control={\n              <Checkbox\n                checked={inputs.LogConsumeEnabled === \"true\"}\n                onChange={handleInputChange}\n                name=\"LogConsumeEnabled\"\n              />\n            }\n          />\n\n          <FormControl>\n            <LocalizationProvider\n              dateAdapter={AdapterDayjs}\n              adapterLocale={\"zh-cn\"}\n            >\n              <DateTimePicker\n                label=\"日志清理时间\"\n                placeholder=\"日志清理时间\"\n                ampm={false}\n                name=\"historyTimestamp\"\n                value={\n                  historyTimestamp === null\n                    ? null\n                    : dayjs.unix(historyTimestamp)\n                }\n                disabled={loading}\n                onChange={(newValue) => {\n                  setHistoryTimestamp(\n                    newValue === null ? null : newValue.unix()\n                  );\n                }}\n                slotProps={{\n                  actionBar: {\n                    actions: [\"today\", \"clear\", \"accept\"],\n                  },\n                }}\n              />\n            </LocalizationProvider>\n          </FormControl>\n          <Button\n            variant=\"contained\"\n            onClick={() => {\n              deleteHistoryLogs().then();\n            }}\n          >\n            清理历史日志\n          </Button>\n        </Stack>\n      </SubCard>\n      <SubCard title=\"监控设置\">\n        <Stack justifyContent=\"flex-start\" alignItems=\"flex-start\" spacing={2}>\n          <Stack\n            direction={{ sm: \"column\", md: \"row\" }}\n            spacing={{ xs: 3, sm: 2, md: 4 }}\n          >\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"ChannelDisableThreshold\">\n                最长响应时间\n              </InputLabel>\n              <OutlinedInput\n                id=\"ChannelDisableThreshold\"\n                name=\"ChannelDisableThreshold\"\n                type=\"number\"\n                value={inputs.ChannelDisableThreshold}\n                onChange={handleInputChange}\n                label=\"最长响应时间\"\n                placeholder=\"单位秒，当运行渠道全部测试时，超过此时间将自动禁用渠道\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"QuotaRemindThreshold\">\n                额度提醒阈值\n              </InputLabel>\n              <OutlinedInput\n                id=\"QuotaRemindThreshold\"\n                name=\"QuotaRemindThreshold\"\n                type=\"number\"\n                value={inputs.QuotaRemindThreshold}\n                onChange={handleInputChange}\n                label=\"额度提醒阈值\"\n                placeholder=\"低于此额度时将发送邮件提醒用户\"\n                disabled={loading}\n              />\n            </FormControl>\n          </Stack>\n          <FormControlLabel\n            label=\"失败时自动禁用渠道\"\n            control={\n              <Checkbox\n                checked={inputs.AutomaticDisableChannelEnabled === \"true\"}\n                onChange={handleInputChange}\n                name=\"AutomaticDisableChannelEnabled\"\n              />\n            }\n          />\n          <FormControlLabel\n            label=\"成功时自动启用渠道\"\n            control={\n              <Checkbox\n                checked={inputs.AutomaticEnableChannelEnabled === \"true\"}\n                onChange={handleInputChange}\n                name=\"AutomaticEnableChannelEnabled\"\n              />\n            }\n          />\n          <Button\n            variant=\"contained\"\n            onClick={() => {\n              submitConfig(\"monitor\").then();\n            }}\n          >\n            保存监控设置\n          </Button>\n        </Stack>\n      </SubCard>\n      <SubCard title=\"额度设置\">\n        <Stack justifyContent=\"flex-start\" alignItems=\"flex-start\" spacing={2}>\n          <Stack\n            direction={{ sm: \"column\", md: \"row\" }}\n            spacing={{ xs: 3, sm: 2, md: 4 }}\n          >\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"QuotaForNewUser\">新用户初始额度</InputLabel>\n              <OutlinedInput\n                id=\"QuotaForNewUser\"\n                name=\"QuotaForNewUser\"\n                type=\"number\"\n                value={inputs.QuotaForNewUser}\n                onChange={handleInputChange}\n                label=\"新用户初始额度\"\n                placeholder=\"例如：100\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"PreConsumedQuota\">请求预扣费额度</InputLabel>\n              <OutlinedInput\n                id=\"PreConsumedQuota\"\n                name=\"PreConsumedQuota\"\n                type=\"number\"\n                value={inputs.PreConsumedQuota}\n                onChange={handleInputChange}\n                label=\"请求预扣费额度\"\n                placeholder=\"请求结束后多退少补\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"QuotaForInviter\">\n                邀请新用户奖励额度\n              </InputLabel>\n              <OutlinedInput\n                id=\"QuotaForInviter\"\n                name=\"QuotaForInviter\"\n                type=\"number\"\n                label=\"邀请新用户奖励额度\"\n                value={inputs.QuotaForInviter}\n                onChange={handleInputChange}\n                placeholder=\"例如：2000\"\n                disabled={loading}\n              />\n            </FormControl>\n            <FormControl fullWidth>\n              <InputLabel htmlFor=\"QuotaForInvitee\">\n                新用户使用邀请码奖励额度\n              </InputLabel>\n              <OutlinedInput\n                id=\"QuotaForInvitee\"\n                name=\"QuotaForInvitee\"\n                type=\"number\"\n                label=\"新用户使用邀请码奖励额度\"\n                value={inputs.QuotaForInvitee}\n                onChange={handleInputChange}\n                autoComplete=\"new-password\"\n                placeholder=\"例如：1000\"\n                disabled={loading}\n              />\n            </FormControl>\n          </Stack>\n          <Button\n            variant=\"contained\"\n            onClick={() => {\n              submitConfig(\"quota\").then();\n            }}\n          >\n            保存额度设置\n          </Button>\n        </Stack>\n      </SubCard>\n      <SubCard title=\"倍率设置\">\n        <Stack justifyContent=\"flex-start\" alignItems=\"flex-start\" spacing={2}>\n          <FormControl fullWidth>\n            <TextField\n              multiline\n              maxRows={15}\n              id=\"channel-ModelRatio-label\"\n              label=\"模型倍率\"\n              value={inputs.ModelRatio}\n              name=\"ModelRatio\"\n              onChange={handleInputChange}\n              aria-describedby=\"helper-text-channel-ModelRatio-label\"\n              minRows={5}\n              placeholder=\"为一个 JSON 文本，键为模型名称，值为倍率\"\n            />\n          </FormControl>\n          <FormControl fullWidth>\n            <TextField\n              multiline\n              maxRows={15}\n              id=\"channel-CompletionRatio-label\"\n              label=\"补全倍率\"\n              value={inputs.CompletionRatio}\n              name=\"CompletionRatio\"\n              onChange={handleInputChange}\n              aria-describedby=\"helper-text-channel-CompletionRatio-label\"\n              minRows={5}\n              placeholder=\"为一个 JSON 文本，键为模型名称，值为倍率，此处的倍率设置是模型补全倍率相较于提示倍率的比例，使用该设置可强制覆盖 One API 的内部比例\"\n            />\n          </FormControl>\n          <FormControl fullWidth>\n            <TextField\n              multiline\n              maxRows={15}\n              id=\"channel-GroupRatio-label\"\n              label=\"分组倍率\"\n              value={inputs.GroupRatio}\n              name=\"GroupRatio\"\n              onChange={handleInputChange}\n              aria-describedby=\"helper-text-channel-GroupRatio-label\"\n              minRows={5}\n              placeholder=\"为一个 JSON 文本，键为分组名称，值为倍率\"\n            />\n          </FormControl>\n          <Button\n            variant=\"contained\"\n            onClick={() => {\n              submitConfig(\"ratio\").then();\n            }}\n          >\n            保存倍率设置\n          </Button>\n        </Stack>\n      </SubCard>\n    </Stack>\n  );\n};\n\nexport default OperationSetting;\n"
  },
  {
    "path": "web/berry/src/views/Setting/component/OtherSetting.js",
    "content": "import { useState, useEffect } from 'react';\nimport SubCard from 'ui-component/cards/SubCard';\nimport {\n    Stack,\n    FormControl,\n    InputLabel,\n    OutlinedInput,\n    Button,\n    Alert,\n    TextField,\n    Dialog,\n    DialogTitle,\n    DialogActions,\n    DialogContent,\n    Divider, Link\n} from '@mui/material';\nimport Grid from '@mui/material/Unstable_Grid2';\nimport { showError, showSuccess } from 'utils/common'; //,\nimport { API } from 'utils/api';\nimport { marked } from 'marked';\n\nconst OtherSetting = () => {\n  let [inputs, setInputs] = useState({\n    Footer: '',\n    Notice: '',\n    About: '',\n    SystemName: '',\n    Logo: '',\n    HomePageContent: '',\n    Theme: '',\n  });\n  let [loading, setLoading] = useState(false);\n  const [showUpdateModal, setShowUpdateModal] = useState(false);\n  const [updateData, setUpdateData] = useState({\n    tag_name: '',\n    content: ''\n  });\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        if (item.key in inputs) {\n          newInputs[item.key] = item.value;\n        }\n      });\n      setInputs(newInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    const res = await API.put('/api/option/', {\n      key,\n      value\n    });\n    const { success, message } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, [key]: value }));\n      showSuccess('保存成功');\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (event) => {\n    let { name, value } = event.target;\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const submitNotice = async () => {\n    await updateOption('Notice', inputs.Notice);\n  };\n\n  const submitFooter = async () => {\n    await updateOption('Footer', inputs.Footer);\n  };\n\n  const submitSystemName = async () => {\n    await updateOption('SystemName', inputs.SystemName);\n  };\n\n  const submitTheme = async () => {\n    await updateOption('Theme', inputs.Theme);\n  };\n\n  const submitLogo = async () => {\n    await updateOption('Logo', inputs.Logo);\n  };\n\n  const submitAbout = async () => {\n    await updateOption('About', inputs.About);\n  };\n\n  const submitOption = async (key) => {\n    await updateOption(key, inputs[key]);\n  };\n\n  const openGitHubRelease = () => {\n    window.location = 'https://github.com/songquanpeng/one-api/releases/latest';\n  };\n\n  const checkUpdate = async () => {\n    const res = await API.get('https://api.github.com/repos/songquanpeng/one-api/releases/latest');\n    const { tag_name, body } = res.data;\n    if (tag_name === process.env.REACT_APP_VERSION) {\n      showSuccess(`已是最新版本：${tag_name}`);\n    } else {\n      setUpdateData({\n        tag_name: tag_name,\n        content: marked.parse(body)\n      });\n      setShowUpdateModal(true);\n    }\n  };\n\n  return (\n    <>\n      <Stack spacing={2}>\n        <SubCard title=\"通用设置\">\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={checkUpdate}>\n                检查更新\n              </Button>\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <TextField\n                  multiline\n                  maxRows={15}\n                  id=\"Notice\"\n                  label=\"公告\"\n                  value={inputs.Notice}\n                  name=\"Notice\"\n                  onChange={handleInputChange}\n                  minRows={10}\n                  placeholder=\"在此输入新的公告内容，支持 Markdown & HTML 代码\"\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitNotice}>\n                保存公告\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard title=\"个性化设置\">\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"SystemName\">系统名称</InputLabel>\n                <OutlinedInput\n                  id=\"SystemName\"\n                  name=\"SystemName\"\n                  value={inputs.SystemName || ''}\n                  onChange={handleInputChange}\n                  label=\"系统名称\"\n                  placeholder=\"在此输入系统名称\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitSystemName}>\n                设置系统名称\n              </Button>\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"Theme\">主题名称</InputLabel>\n                <OutlinedInput\n                    id=\"Theme\"\n                    name=\"Theme\"\n                    value={inputs.Theme || ''}\n                    onChange={handleInputChange}\n                    label=\"主题名称\"\n                    placeholder=\"请输入主题名称\"\n                    disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitTheme}>\n                设置主题（重启生效）\n              </Button>\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"Logo\">Logo 图片地址</InputLabel>\n                <OutlinedInput\n                  id=\"Logo\"\n                  name=\"Logo\"\n                  value={inputs.Logo || ''}\n                  onChange={handleInputChange}\n                  label=\"Logo 图片地址\"\n                  placeholder=\"在此输入Logo 图片地址\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitLogo}>\n                设置 Logo\n              </Button>\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <TextField\n                  multiline\n                  maxRows={15}\n                  id=\"HomePageContent\"\n                  label=\"首页内容\"\n                  value={inputs.HomePageContent}\n                  name=\"HomePageContent\"\n                  onChange={handleInputChange}\n                  minRows={10}\n                  placeholder=\"在此输入首页内容，支持 Markdown & HTML 代码，设置后首页的状态信息将不再显示。如果输入的是一个链接，则会使用该链接作为 iframe 的 src 属性，这允许你设置任意网页作为首页。\"\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={() => submitOption('HomePageContent')}>\n                保存首页内容\n              </Button>\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <TextField\n                  multiline\n                  maxRows={15}\n                  id=\"About\"\n                  label=\"关于\"\n                  value={inputs.About}\n                  name=\"About\"\n                  onChange={handleInputChange}\n                  minRows={10}\n                  placeholder=\"在此输入新的关于内容，支持 Markdown & HTML 代码。如果输入的是一个链接，则会使用该链接作为 iframe 的 src 属性，这允许你设置任意网页作为关于页面。\"\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitAbout}>\n                保存关于\n              </Button>\n            </Grid>\n            <Grid xs={12}>\n              <Alert severity=\"warning\">\n                移除 One API 的版权标识必须首先获得授权，项目维护需要花费大量精力，如果本项目对你有意义，请主动支持本项目。\n              </Alert>\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <TextField\n                  multiline\n                  maxRows={15}\n                  id=\"Footer\"\n                  label=\"页脚\"\n                  value={inputs.Footer}\n                  name=\"Footer\"\n                  onChange={handleInputChange}\n                  minRows={10}\n                  placeholder=\"在此输入新的页脚，留空则使用默认页脚，支持 HTML 代码\"\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitFooter}>\n                设置页脚\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n      </Stack>\n      <Dialog open={showUpdateModal} onClose={() => setShowUpdateModal(false)} fullWidth maxWidth={'md'}>\n        <DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>\n          新版本：{updateData.tag_name}\n        </DialogTitle>\n        <Divider />\n        <DialogContent>\n          {' '}\n          <div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>\n        </DialogContent>\n        <DialogActions>\n          <Button onClick={() => setShowUpdateModal(false)}>关闭</Button>\n          <Button\n            onClick={async () => {\n              setShowUpdateModal(false);\n              openGitHubRelease();\n            }}\n          >\n            去GitHub查看\n          </Button>\n        </DialogActions>\n      </Dialog>\n    </>\n  );\n};\n\nexport default OtherSetting;\n"
  },
  {
    "path": "web/berry/src/views/Setting/component/SystemSetting.js",
    "content": "import { useState, useEffect } from 'react';\nimport SubCard from 'ui-component/cards/SubCard';\nimport {\n  Stack,\n  FormControl,\n  InputLabel,\n  OutlinedInput,\n  Checkbox,\n  Button,\n  FormControlLabel,\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  Divider,\n  Alert,\n  Autocomplete,\n  TextField\n} from '@mui/material';\nimport Grid from '@mui/material/Unstable_Grid2';\nimport { showError, showSuccess, removeTrailingSlash } from 'utils/common'; //,\nimport { API } from 'utils/api';\nimport { createFilterOptions } from '@mui/material/Autocomplete';\n\nconst filter = createFilterOptions();\nconst SystemSetting = () => {\n  let [inputs, setInputs] = useState({\n    PasswordLoginEnabled: '',\n    PasswordRegisterEnabled: '',\n    EmailVerificationEnabled: '',\n    GitHubOAuthEnabled: '',\n    GitHubClientId: '',\n    GitHubClientSecret: '',\n    LarkClientId: '',\n    LarkClientSecret: '',\n    OidcEnabled: '',\n    OidcWellKnown: '',\n    OidcClientId: '',\n    OidcClientSecret: '',\n    OidcAuthorizationEndpoint: '',\n    OidcTokenEndpoint: '',\n    OidcUserinfoEndpoint: '',\n    Notice: '',\n    SMTPServer: '',\n    SMTPPort: '',\n    SMTPAccount: '',\n    SMTPFrom: '',\n    SMTPToken: '',\n    ServerAddress: '',\n    Footer: '',\n    WeChatAuthEnabled: '',\n    WeChatServerAddress: '',\n    WeChatServerToken: '',\n    WeChatAccountQRCodeImageURL: '',\n    TurnstileCheckEnabled: '',\n    TurnstileSiteKey: '',\n    TurnstileSecretKey: '',\n    RegisterEnabled: '',\n    EmailDomainRestrictionEnabled: '',\n    EmailDomainWhitelist: [],\n    MessagePusherAddress: '',\n    MessagePusherToken: ''\n  });\n  const [originInputs, setOriginInputs] = useState({});\n  let [loading, setLoading] = useState(false);\n  const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);\n  const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false);\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        newInputs[item.key] = item.value;\n      });\n      setInputs({\n        ...newInputs,\n        EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',')\n      });\n      setOriginInputs(newInputs);\n\n      setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(','));\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    switch (key) {\n      case 'PasswordLoginEnabled':\n      case 'PasswordRegisterEnabled':\n      case 'EmailVerificationEnabled':\n      case 'GitHubOAuthEnabled':\n      case 'WeChatAuthEnabled':\n      case 'TurnstileCheckEnabled':\n      case 'EmailDomainRestrictionEnabled':\n      case 'RegisterEnabled':\n      case 'OidcEnabled':\n        value = inputs[key] === 'true' ? 'false' : 'true';\n        break;\n      default:\n        break;\n    }\n    const res = await API.put('/api/option/', {\n      key,\n      value\n    });\n    const { success, message } = res.data;\n    if (success) {\n      if (key === 'EmailDomainWhitelist') {\n        value = value.split(',');\n      }\n      setInputs((inputs) => ({\n        ...inputs,\n        [key]: value\n      }));\n      showSuccess('设置成功！');\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (event) => {\n    let { name, value } = event.target;\n\n    if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {\n      // block disabling password login\n      setShowPasswordWarningModal(true);\n      return;\n    }\n    if (\n      name === 'Notice' ||\n      name.startsWith('SMTP') ||\n      name === 'ServerAddress' ||\n      name === 'GitHubClientId' ||\n      name === 'GitHubClientSecret' ||\n      name === 'WeChatServerAddress' ||\n      name === 'WeChatServerToken' ||\n      name === 'WeChatAccountQRCodeImageURL' ||\n      name === 'TurnstileSiteKey' ||\n      name === 'TurnstileSecretKey' ||\n      name === 'EmailDomainWhitelist' ||\n      name === 'MessagePusherAddress' ||\n      name === 'MessagePusherToken' ||\n      name === 'LarkClientId' ||\n      name === 'LarkClientSecret' ||\n      name === 'OidcClientId' ||\n      name === 'OidcClientSecret' ||\n      name === 'OidcWellKnown' ||\n      name === 'OidcAuthorizationEndpoint' ||\n      name === 'OidcTokenEndpoint' ||\n      name === 'OidcUserinfoEndpoint'\n    )\n    {\n      setInputs((inputs) => ({ ...inputs, [name]: value }));\n    } else {\n      await updateOption(name, value);\n    }\n  };\n\n  const submitServerAddress = async () => {\n    let ServerAddress = removeTrailingSlash(inputs.ServerAddress);\n    await updateOption('ServerAddress', ServerAddress);\n  };\n\n  const submitSMTP = async () => {\n    if (originInputs['SMTPServer'] !== inputs.SMTPServer) {\n      await updateOption('SMTPServer', inputs.SMTPServer);\n    }\n    if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {\n      await updateOption('SMTPAccount', inputs.SMTPAccount);\n    }\n    if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) {\n      await updateOption('SMTPFrom', inputs.SMTPFrom);\n    }\n    if (originInputs['SMTPPort'] !== inputs.SMTPPort && inputs.SMTPPort !== '') {\n      await updateOption('SMTPPort', inputs.SMTPPort);\n    }\n    if (originInputs['SMTPToken'] !== inputs.SMTPToken && inputs.SMTPToken !== '') {\n      await updateOption('SMTPToken', inputs.SMTPToken);\n    }\n  };\n\n  const submitEmailDomainWhitelist = async () => {\n    await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(','));\n  };\n\n  const submitWeChat = async () => {\n    if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {\n      await updateOption('WeChatServerAddress', removeTrailingSlash(inputs.WeChatServerAddress));\n    }\n    if (originInputs['WeChatAccountQRCodeImageURL'] !== inputs.WeChatAccountQRCodeImageURL) {\n      await updateOption('WeChatAccountQRCodeImageURL', inputs.WeChatAccountQRCodeImageURL);\n    }\n    if (originInputs['WeChatServerToken'] !== inputs.WeChatServerToken && inputs.WeChatServerToken !== '') {\n      await updateOption('WeChatServerToken', inputs.WeChatServerToken);\n    }\n  };\n\n  const submitGitHubOAuth = async () => {\n    if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {\n      await updateOption('GitHubClientId', inputs.GitHubClientId);\n    }\n    if (originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret && inputs.GitHubClientSecret !== '') {\n      await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);\n    }\n  };\n\n  const submitTurnstile = async () => {\n    if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {\n      await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);\n    }\n    if (originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey && inputs.TurnstileSecretKey !== '') {\n      await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);\n    }\n  };\n\n  const submitMessagePusher = async () => {\n    if (originInputs['MessagePusherAddress'] !== inputs.MessagePusherAddress) {\n      await updateOption('MessagePusherAddress', removeTrailingSlash(inputs.MessagePusherAddress));\n    }\n    if (originInputs['MessagePusherToken'] !== inputs.MessagePusherToken && inputs.MessagePusherToken !== '') {\n      await updateOption('MessagePusherToken', inputs.MessagePusherToken);\n    }\n  };\n\n  const submitLarkOAuth = async () => {\n    if (originInputs['LarkClientId'] !== inputs.LarkClientId) {\n      await updateOption('LarkClientId', inputs.LarkClientId);\n    }\n    if (originInputs['LarkClientSecret'] !== inputs.LarkClientSecret && inputs.LarkClientSecret !== '') {\n      await updateOption('LarkClientSecret', inputs.LarkClientSecret);\n    }\n  };\n\n  const submitOidc = async () => {\n    if (inputs.OidcWellKnown !== '') {\n      if (!inputs.OidcWellKnown.startsWith('http://') && !inputs.OidcWellKnown.startsWith('https://')) {\n        showError('Well-Known URL 必须以 http:// 或 https:// 开头');\n        return;\n      }\n      try {\n        const res = await API.get(inputs.OidcWellKnown);\n        inputs.OidcAuthorizationEndpoint = res.data['authorization_endpoint'];\n        inputs.OidcTokenEndpoint = res.data['token_endpoint'];\n        inputs.OidcUserinfoEndpoint = res.data['userinfo_endpoint'];\n        showSuccess('获取 OIDC 配置成功！');\n      } catch (err) {\n        showError(\"获取 OIDC 配置失败，请检查网络状况和 Well-Known URL 是否正确\");\n      }\n    }\n\n    if (originInputs['OidcWellKnown'] !== inputs.OidcWellKnown) {\n      await updateOption('OidcWellKnown', inputs.OidcWellKnown);\n    }\n    if (originInputs['OidcClientId'] !== inputs.OidcClientId) {\n      await updateOption('OidcClientId', inputs.OidcClientId);\n    }\n    if (originInputs['OidcClientSecret'] !== inputs.OidcClientSecret && inputs.OidcClientSecret !== '') {\n      await updateOption('OidcClientSecret', inputs.OidcClientSecret);\n    }\n    if (originInputs['OidcAuthorizationEndpoint'] !== inputs.OidcAuthorizationEndpoint) {\n      await updateOption('OidcAuthorizationEndpoint', inputs.OidcAuthorizationEndpoint);\n    }\n    if (originInputs['OidcTokenEndpoint'] !== inputs.OidcTokenEndpoint) {\n      await updateOption('OidcTokenEndpoint', inputs.OidcTokenEndpoint);\n    }\n    if (originInputs['OidcUserinfoEndpoint'] !== inputs.OidcUserinfoEndpoint) {\n      await updateOption('OidcUserinfoEndpoint', inputs.OidcUserinfoEndpoint);\n    }\n  };\n\n  return (\n    <>\n      <Stack spacing={2}>\n        <SubCard title=\"通用设置\">\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"ServerAddress\">服务器地址</InputLabel>\n                <OutlinedInput\n                  id=\"ServerAddress\"\n                  name=\"ServerAddress\"\n                  value={inputs.ServerAddress || ''}\n                  onChange={handleInputChange}\n                  label=\"服务器地址\"\n                  placeholder=\"例如：https://yourdomain.com\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitServerAddress}>\n                更新服务器地址\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard title=\"配置登录注册\">\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"允许通过密码进行登录\"\n                control={\n                  <Checkbox checked={inputs.PasswordLoginEnabled === 'true'} onChange={handleInputChange} name=\"PasswordLoginEnabled\" />\n                }\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"允许通过密码进行注册\"\n                control={\n                  <Checkbox\n                    checked={inputs.PasswordRegisterEnabled === 'true'}\n                    onChange={handleInputChange}\n                    name=\"PasswordRegisterEnabled\"\n                  />\n                }\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"通过密码注册时需要进行邮箱验证\"\n                control={\n                  <Checkbox\n                    checked={inputs.EmailVerificationEnabled === 'true'}\n                    onChange={handleInputChange}\n                    name=\"EmailVerificationEnabled\"\n                  />\n                }\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"允许通过 GitHub 账户登录 & 注册\"\n                control={<Checkbox checked={inputs.GitHubOAuthEnabled === 'true'} onChange={handleInputChange} name=\"GitHubOAuthEnabled\" />}\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"允许通过 OIDC 登录 & 注册\"\n                control={<Checkbox checked={inputs.OidcEnabled === 'true'} onChange={handleInputChange} name=\"OidcEnabled\" />}\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"允许通过微信登录 & 注册\"\n                control={<Checkbox checked={inputs.WeChatAuthEnabled === 'true'} onChange={handleInputChange} name=\"WeChatAuthEnabled\" />}\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"允许新用户注册（此项为否时，新用户将无法以任何方式进行注册）\"\n                control={<Checkbox checked={inputs.RegisterEnabled === 'true'} onChange={handleInputChange} name=\"RegisterEnabled\" />}\n              />\n            </Grid>\n            <Grid xs={12} md={3}>\n              <FormControlLabel\n                label=\"启用 Turnstile 用户校验\"\n                control={\n                  <Checkbox checked={inputs.TurnstileCheckEnabled === 'true'} onChange={handleInputChange} name=\"TurnstileCheckEnabled\" />\n                }\n              />\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard title=\"配置邮箱域名白名单\" subTitle=\"用以防止恶意用户利用临时邮箱批量注册\">\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12}>\n              <FormControlLabel\n                label=\"启用邮箱域名白名单\"\n                control={\n                  <Checkbox\n                    checked={inputs.EmailDomainRestrictionEnabled === 'true'}\n                    onChange={handleInputChange}\n                    name=\"EmailDomainRestrictionEnabled\"\n                  />\n                }\n              />\n            </Grid>\n            <Grid xs={12}>\n              <FormControl fullWidth>\n                <Autocomplete\n                  multiple\n                  freeSolo\n                  id=\"EmailDomainWhitelist\"\n                  options={EmailDomainWhitelist}\n                  value={inputs.EmailDomainWhitelist}\n                  onChange={(e, value) => {\n                    const event = {\n                      target: {\n                        name: 'EmailDomainWhitelist',\n                        value: value\n                      }\n                    };\n                    handleInputChange(event);\n                  }}\n                  filterSelectedOptions\n                  renderInput={(params) => <TextField {...params} name=\"EmailDomainWhitelist\" label=\"允许的邮箱域名\" />}\n                  filterOptions={(options, params) => {\n                    const filtered = filter(options, params);\n                    const { inputValue } = params;\n                    const isExisting = options.some((option) => inputValue === option);\n                    if (inputValue !== '' && !isExisting) {\n                      filtered.push(inputValue);\n                    }\n                    return filtered;\n                  }}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitEmailDomainWhitelist}>\n                保存邮箱域名白名单设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard title=\"配置 SMTP\" subTitle=\"用以支持系统的邮件发送\">\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"SMTPServer\">SMTP 服务器地址</InputLabel>\n                <OutlinedInput\n                  id=\"SMTPServer\"\n                  name=\"SMTPServer\"\n                  value={inputs.SMTPServer || ''}\n                  onChange={handleInputChange}\n                  label=\"SMTP 服务器地址\"\n                  placeholder=\"例如：smtp.qq.com\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"SMTPPort\">SMTP 端口</InputLabel>\n                <OutlinedInput\n                  id=\"SMTPPort\"\n                  name=\"SMTPPort\"\n                  value={inputs.SMTPPort || ''}\n                  onChange={handleInputChange}\n                  label=\"SMTP 端口\"\n                  placeholder=\"默认: 587\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"SMTPAccount\">SMTP 账户</InputLabel>\n                <OutlinedInput\n                  id=\"SMTPAccount\"\n                  name=\"SMTPAccount\"\n                  value={inputs.SMTPAccount || ''}\n                  onChange={handleInputChange}\n                  label=\"SMTP 账户\"\n                  placeholder=\"通常是邮箱地址\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"SMTPFrom\">SMTP 发送者邮箱</InputLabel>\n                <OutlinedInput\n                  id=\"SMTPFrom\"\n                  name=\"SMTPFrom\"\n                  value={inputs.SMTPFrom || ''}\n                  onChange={handleInputChange}\n                  label=\"SMTP 发送者邮箱\"\n                  placeholder=\"通常和邮箱地址保持一致\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"SMTPToken\">SMTP 访问凭证</InputLabel>\n                <OutlinedInput\n                  id=\"SMTPToken\"\n                  name=\"SMTPToken\"\n                  value={inputs.SMTPToken || ''}\n                  onChange={handleInputChange}\n                  label=\"SMTP 访问凭证\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitSMTP}>\n                保存 SMTP 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard\n          title=\"配置 GitHub OAuth App\"\n          subTitle={\n            <span>\n              {' '}\n              用以支持通过 GitHub 进行登录注册，\n              <a href=\"https://github.com/settings/developers\" target=\"_blank\" rel=\"noopener noreferrer\">\n                点击此处\n              </a>\n              管理你的 GitHub OAuth App\n            </span>\n          }\n        >\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12}>\n              <Alert severity=\"info\" sx={{ wordWrap: 'break-word' }}>\n                Homepage URL 填 <b>{inputs.ServerAddress}</b>\n                ，Authorization callback URL 填 <b>{`${inputs.ServerAddress}/oauth/github`}</b>\n              </Alert>\n            </Grid>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"GitHubClientId\">GitHub Client ID</InputLabel>\n                <OutlinedInput\n                  id=\"GitHubClientId\"\n                  name=\"GitHubClientId\"\n                  value={inputs.GitHubClientId || ''}\n                  onChange={handleInputChange}\n                  label=\"GitHub Client ID\"\n                  placeholder=\"输入你注册的 GitHub OAuth APP 的 ID\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"GitHubClientSecret\">GitHub Client Secret</InputLabel>\n                <OutlinedInput\n                  id=\"GitHubClientSecret\"\n                  name=\"GitHubClientSecret\"\n                  value={inputs.GitHubClientSecret || ''}\n                  onChange={handleInputChange}\n                  label=\"GitHub Client Secret\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitGitHubOAuth}>\n                保存 GitHub OAuth 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard\n          title=\"配置飞书授权登录\"\n          subTitle={\n            <span>\n              {' '}\n              用以支持通过飞书进行登录注册，\n              <a href=\"https://open.feishu.cn/app\" target=\"_blank\" rel=\"noreferrer\">\n                点击此处\n              </a>\n              管理你的飞书应用\n            </span>\n          }\n        >\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12}>\n              <Alert severity=\"info\" sx={{ wordWrap: 'break-word' }}>\n                主页链接填 <code>{inputs.ServerAddress}</code>\n                ，重定向 URL 填 <code>{`${inputs.ServerAddress}/oauth/lark`}</code>\n              </Alert>\n            </Grid>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"LarkClientId\">App ID</InputLabel>\n                <OutlinedInput\n                  id=\"LarkClientId\"\n                  name=\"LarkClientId\"\n                  value={inputs.LarkClientId || ''}\n                  onChange={handleInputChange}\n                  label=\"App ID\"\n                  placeholder=\"输入 App ID\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"LarkClientSecret\">App Secret</InputLabel>\n                <OutlinedInput\n                  id=\"LarkClientSecret\"\n                  name=\"LarkClientSecret\"\n                  value={inputs.LarkClientSecret || ''}\n                  onChange={handleInputChange}\n                  label=\"App Secret\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitLarkOAuth}>\n                保存飞书 OAuth 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard\n          title=\"配置 WeChat Server\"\n          subTitle={\n            <span>\n              用以支持通过微信进行登录注册，\n              <a href=\"https://github.com/songquanpeng/wechat-server\" target=\"_blank\" rel=\"noopener noreferrer\">\n                点击此处\n              </a>\n              了解 WeChat Server\n            </span>\n          }\n        >\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"WeChatServerAddress\">WeChat Server 服务器地址</InputLabel>\n                <OutlinedInput\n                  id=\"WeChatServerAddress\"\n                  name=\"WeChatServerAddress\"\n                  value={inputs.WeChatServerAddress || ''}\n                  onChange={handleInputChange}\n                  label=\"WeChat Server 服务器地址\"\n                  placeholder=\"例如：https://yourdomain.com\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"WeChatServerToken\">WeChat Server 访问凭证</InputLabel>\n                <OutlinedInput\n                  id=\"WeChatServerToken\"\n                  name=\"WeChatServerToken\"\n                  value={inputs.WeChatServerToken || ''}\n                  onChange={handleInputChange}\n                  label=\"WeChat Server 访问凭证\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={4}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"WeChatAccountQRCodeImageURL\">微信公众号二维码图片链接</InputLabel>\n                <OutlinedInput\n                  id=\"WeChatAccountQRCodeImageURL\"\n                  name=\"WeChatAccountQRCodeImageURL\"\n                  value={inputs.WeChatAccountQRCodeImageURL || ''}\n                  onChange={handleInputChange}\n                  label=\"微信公众号二维码图片链接\"\n                  placeholder=\"输入一个图片链接\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitWeChat}>\n                保存 WeChat Server 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n\n        <SubCard\n          title=\"配置 OIDC\"\n          subTitle={\n            <span>\n              用以支持通过 OIDC 登录，例如 Okta、Auth0 等兼容 OIDC 协议的 IdP\n            </span>\n          }\n        >\n          <Grid container spacing={ { xs: 3, sm: 2, md: 4 } }>\n            <Grid xs={ 12 } md={ 12 }>\n              <Alert severity=\"info\" sx={ { wordWrap: 'break-word' } }>\n                主页链接填 <code>{ inputs.ServerAddress }</code>\n                ，重定向 URL 填 <code>{ `${ inputs.ServerAddress }/oauth/oidc` }</code>\n              </Alert> <br />\n              <Alert severity=\"info\" sx={ { wordWrap: 'break-word' } }>\n                若你的 OIDC Provider 支持 Discovery Endpoint，你可以仅填写 OIDC Well-Known URL，系统会自动获取 OIDC 配置\n              </Alert>\n            </Grid>\n            <Grid xs={ 12 } md={ 6 }>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"OidcClientId\">Client ID</InputLabel>\n                <OutlinedInput\n                  id=\"OidcClientId\"\n                  name=\"OidcClientId\"\n                  value={ inputs.OidcClientId || '' }\n                  onChange={ handleInputChange }\n                  label=\"Client ID\"\n                  placeholder=\"输入 OIDC 的 Client ID\"\n                  disabled={ loading }\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={ 12 } md={ 6 }>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"OidcClientSecret\">Client Secret</InputLabel>\n                <OutlinedInput\n                  id=\"OidcClientSecret\"\n                  name=\"OidcClientSecret\"\n                  value={ inputs.OidcClientSecret || '' }\n                  onChange={ handleInputChange }\n                  label=\"Client Secret\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={ loading }\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={ 12 } md={ 6 }>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"OidcWellKnown\">Well-Known URL</InputLabel>\n                <OutlinedInput\n                  id=\"OidcWellKnown\"\n                  name=\"OidcWellKnown\"\n                  value={ inputs.OidcWellKnown || '' }\n                  onChange={ handleInputChange }\n                  label=\"Well-Known URL\"\n                  placeholder=\"请输入 OIDC 的 Well-Known URL\"\n                  disabled={ loading }\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={ 12 } md={ 6 }>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"OidcAuthorizationEndpoint\">Authorization Endpoint</InputLabel>\n                <OutlinedInput\n                  id=\"OidcAuthorizationEndpoint\"\n                  name=\"OidcAuthorizationEndpoint\"\n                  value={ inputs.OidcAuthorizationEndpoint || '' }\n                  onChange={ handleInputChange }\n                  label=\"Authorization Endpoint\"\n                  placeholder=\"输入 OIDC 的 Authorization Endpoint\"\n                  disabled={ loading }\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={ 12 } md={ 6 }>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"OidcTokenEndpoint\">Token Endpoint</InputLabel>\n                <OutlinedInput\n                  id=\"OidcTokenEndpoint\"\n                  name=\"OidcTokenEndpoint\"\n                  value={ inputs.OidcTokenEndpoint || '' }\n                  onChange={ handleInputChange }\n                  label=\"Token Endpoint\"\n                  placeholder=\"输入 OIDC 的 Token Endpoint\"\n                  disabled={ loading }\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={ 12 } md={ 6 }>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"OidcUserinfoEndpoint\">Userinfo Endpoint</InputLabel>\n                <OutlinedInput\n                  id=\"OidcUserinfoEndpoint\"\n                  name=\"OidcUserinfoEndpoint\"\n                  value={ inputs.OidcUserinfoEndpoint || '' }\n                  onChange={ handleInputChange }\n                  label=\"Userinfo Endpoint\"\n                  placeholder=\"输入 OIDC 的 Userinfo Endpoint\"\n                  disabled={ loading }\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={ 12 }>\n              <Button variant=\"contained\" onClick={ submitOidc }>\n                保存 OIDC 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n\n        <SubCard\n          title=\"配置 Message Pusher\"\n          subTitle={\n            <span>\n              用以推送报警信息，\n              <a href=\"https://github.com/songquanpeng/message-pusher\" target=\"_blank\" rel=\"noreferrer\">\n                点击此处\n              </a>\n              了解 Message Pusher\n            </span>\n          }\n        >\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"MessagePusherAddress\">Message Pusher 推送地址</InputLabel>\n                <OutlinedInput\n                  id=\"MessagePusherAddress\"\n                  name=\"MessagePusherAddress\"\n                  value={inputs.MessagePusherAddress || ''}\n                  onChange={handleInputChange}\n                  label=\"Message Pusher 推送地址\"\n                  placeholder=\"例如：https://msgpusher.com/push/your_username\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"MessagePusherToken\">Message Pusher 访问凭证</InputLabel>\n                <OutlinedInput\n                  id=\"MessagePusherToken\"\n                  name=\"MessagePusherToken\"\n                  type=\"password\"\n                  value={inputs.MessagePusherToken || ''}\n                  onChange={handleInputChange}\n                  label=\"Message Pusher 访问凭证\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitMessagePusher}>\n                保存 Message Pusher 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n        <SubCard\n          title=\"配置 Turnstile\"\n          subTitle={\n            <span>\n              用以支持用户校验，\n              <a href=\"https://dash.cloudflare.com/\" target=\"_blank\" rel=\"noopener noreferrer\">\n                点击此处\n              </a>\n              管理你的 Turnstile Sites，推荐选择 Invisible Widget Type\n            </span>\n          }\n        >\n          <Grid container spacing={{ xs: 3, sm: 2, md: 4 }}>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"TurnstileSiteKey\">Turnstile Site Key</InputLabel>\n                <OutlinedInput\n                  id=\"TurnstileSiteKey\"\n                  name=\"TurnstileSiteKey\"\n                  value={inputs.TurnstileSiteKey || ''}\n                  onChange={handleInputChange}\n                  label=\"Turnstile Site Key\"\n                  placeholder=\"输入你注册的 Turnstile Site Key\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12} md={6}>\n              <FormControl fullWidth>\n                <InputLabel htmlFor=\"TurnstileSecretKey\">Turnstile Secret Key</InputLabel>\n                <OutlinedInput\n                  id=\"TurnstileSecretKey\"\n                  name=\"TurnstileSecretKey\"\n                  type=\"password\"\n                  value={inputs.TurnstileSecretKey || ''}\n                  onChange={handleInputChange}\n                  label=\"Turnstile Secret Key\"\n                  placeholder=\"敏感信息不会发送到前端显示\"\n                  disabled={loading}\n                />\n              </FormControl>\n            </Grid>\n            <Grid xs={12}>\n              <Button variant=\"contained\" onClick={submitTurnstile}>\n                保存 Turnstile 设置\n              </Button>\n            </Grid>\n          </Grid>\n        </SubCard>\n      </Stack>\n      <Dialog open={showPasswordWarningModal} onClose={() => setShowPasswordWarningModal(false)} maxWidth={'md'}>\n        <DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>\n          警告\n        </DialogTitle>\n        <Divider />\n        <DialogContent>取消密码登录将导致所有未绑定其他登录方式的用户（包括管理员）无法通过密码登录，确认取消？</DialogContent>\n        <DialogActions>\n          <Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button>\n          <Button\n            sx={{ color: 'error.main' }}\n            onClick={async () => {\n              setShowPasswordWarningModal(false);\n              await updateOption('PasswordLoginEnabled', 'false');\n            }}\n          >\n            确定\n          </Button>\n        </DialogActions>\n      </Dialog>\n    </>\n  );\n};\n\nexport default SystemSetting;\n"
  },
  {
    "path": "web/berry/src/views/Setting/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport PropTypes from 'prop-types';\nimport { Tabs, Tab, Box, Card } from '@mui/material';\nimport { IconSettings2, IconActivity, IconSettings } from '@tabler/icons-react';\nimport OperationSetting from './component/OperationSetting';\nimport SystemSetting from './component/SystemSetting';\nimport OtherSetting from './component/OtherSetting';\nimport AdminContainer from 'ui-component/AdminContainer';\nimport { useLocation, useNavigate } from 'react-router-dom';\n\nfunction CustomTabPanel(props) {\n  const { children, value, index, ...other } = props;\n\n  return (\n    <div role=\"tabpanel\" hidden={value !== index} id={`setting-tabpanel-${index}`} aria-labelledby={`setting-tab-${index}`} {...other}>\n      {value === index && <Box sx={{ p: 3 }}>{children}</Box>}\n    </div>\n  );\n}\n\nCustomTabPanel.propTypes = {\n  children: PropTypes.node,\n  index: PropTypes.number.isRequired,\n  value: PropTypes.number.isRequired\n};\n\nfunction a11yProps(index) {\n  return {\n    id: `setting-tab-${index}`,\n    'aria-controls': `setting-tabpanel-${index}`\n  };\n}\n\nconst Setting = () => {\n  const location = useLocation();\n  const navigate = useNavigate();\n  const hash = location.hash.replace('#', '');\n  const tabMap = {\n    operation: 0,\n    system: 1,\n    other: 2\n  };\n  const [value, setValue] = useState(tabMap[hash] || 0);\n\n  const handleChange = (event, newValue) => {\n    setValue(newValue);\n    const hashArray = Object.keys(tabMap);\n    navigate(`#${hashArray[newValue]}`);\n  };\n\n  useEffect(() => {\n    const handleHashChange = () => {\n      const hash = location.hash.replace('#', '');\n      setValue(tabMap[hash] || 0);\n    };\n    window.addEventListener('hashchange', handleHashChange);\n    return () => {\n      window.removeEventListener('hashchange', handleHashChange);\n    };\n  }, [location, tabMap]);\n\n  return (\n    <>\n      <Card>\n        <AdminContainer>\n          <Box sx={{ width: '100%' }}>\n            <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>\n              <Tabs value={value} onChange={handleChange} variant=\"scrollable\" scrollButtons=\"auto\">\n                <Tab label=\"运营设置\" {...a11yProps(0)} icon={<IconActivity />} iconPosition=\"start\" />\n                <Tab label=\"系统设置\" {...a11yProps(1)} icon={<IconSettings />} iconPosition=\"start\" />\n                <Tab label=\"其他设置\" {...a11yProps(2)} icon={<IconSettings2 />} iconPosition=\"start\" />\n              </Tabs>\n            </Box>\n            <CustomTabPanel value={value} index={0}>\n              <OperationSetting />\n            </CustomTabPanel>\n            <CustomTabPanel value={value} index={1}>\n              <SystemSetting />\n            </CustomTabPanel>\n            <CustomTabPanel value={value} index={2}>\n              <OtherSetting />\n            </CustomTabPanel>\n          </Box>\n        </AdminContainer>\n      </Card>\n    </>\n  );\n};\n\nexport default Setting;\n"
  },
  {
    "path": "web/berry/src/views/Token/component/EditModal.js",
    "content": "import PropTypes from 'prop-types';\nimport * as Yup from 'yup';\nimport { Formik } from 'formik';\nimport { useTheme } from '@mui/material/styles';\nimport { useState, useEffect } from 'react';\nimport dayjs from 'dayjs';\nimport {\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  Button,\n  Divider,\n  Alert,\n  FormControl,\n  InputLabel,\n  OutlinedInput,\n  InputAdornment,\n  Autocomplete,\n  Checkbox,\n  TextField,\n  Switch,\n  FormHelperText\n} from '@mui/material';\n\nimport { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';\nimport { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';\nimport { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';\nimport { renderQuotaWithPrompt, showSuccess, showError } from 'utils/common';\nimport { API } from 'utils/api';\nimport CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';\nimport CheckBoxIcon from '@mui/icons-material/CheckBox';\nimport { createFilterOptions } from '@mui/material/Autocomplete';\nrequire('dayjs/locale/zh-cn');\nconst icon = <CheckBoxOutlineBlankIcon fontSize=\"small\" />;\nconst checkedIcon = <CheckBoxIcon fontSize=\"small\" />;\nconst filter = createFilterOptions();\n\nconst validationSchema = Yup.object().shape({\n  is_edit: Yup.boolean(),\n  name: Yup.string().required('名称 不能为空'),\n  remain_quota: Yup.number().min(0, '必须大于等于0'),\n  expired_time: Yup.number(),\n  unlimited_quota: Yup.boolean()\n});\n\nconst originInputs = {\n  is_edit: false,\n  name: '',\n  remain_quota: 0,\n  expired_time: -1,\n  unlimited_quota: false,\n  subnet: '',\n  models: []\n};\n\nconst EditModal = ({ open, tokenId, onCancel, onOk }) => {\n  const theme = useTheme();\n  const [inputs, setInputs] = useState(originInputs);\n  const [modelOptions, setModelOptions] = useState([]);\n\n  const submit = async (values, { setErrors, setStatus, setSubmitting }) => {\n    setSubmitting(true);\n\n    values.remain_quota = parseInt(values.remain_quota);\n    let res;\n    let models = values.models.join(',');\n    if (values.is_edit) {\n      res = await API.put(`/api/token/`, { ...values, id: parseInt(tokenId), models: models });\n    } else {\n      res = await API.post(`/api/token/`, { ...values, models: models });\n    }\n    const { success, message } = res.data;\n    if (success) {\n      if (values.is_edit) {\n        showSuccess('令牌更新成功！');\n      } else {\n        showSuccess('令牌创建成功，请在列表页面点击复制获取令牌！');\n      }\n      setSubmitting(false);\n      setStatus({ success: true });\n      onOk(true);\n    } else {\n      showError(message);\n      setErrors({ submit: message });\n    }\n  };\n\n  const loadToken = async () => {\n    let res = await API.get(`/api/token/${tokenId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      data.is_edit = true;\n      if (data.models === '') {\n        data.models = [];\n      } else {\n        data.models = data.models.split(',');\n      }\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n  };\n  const loadAvailableModels = async () => {\n    let res = await API.get(`/api/user/available_models`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setModelOptions(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    if (tokenId) {\n      loadToken().then();\n    } else {\n      setInputs({ ...originInputs });\n    }\n    loadAvailableModels().then();\n  }, [tokenId]);\n\n  return (\n    <Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>\n      <DialogTitle\n        sx={{\n          margin: '0px',\n          fontWeight: 700,\n          lineHeight: '1.55556',\n          padding: '24px',\n          fontSize: '1.125rem'\n        }}\n      >\n        {tokenId ? '编辑令牌' : '新建令牌'}\n      </DialogTitle>\n      <Divider />\n      <DialogContent>\n        <Alert severity=\"info\">注意，令牌的额度仅用于限制令牌本身的最大额度使用量，实际的使用受到账户的剩余额度限制。</Alert>\n        <Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>\n          {({ errors, handleBlur, handleChange, handleSubmit, touched, values, setFieldError, setFieldValue, isSubmitting }) => (\n            <form noValidate onSubmit={handleSubmit}>\n              <FormControl fullWidth error={Boolean(touched.name && errors.name)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-name-label\">名称</InputLabel>\n                <OutlinedInput\n                  id=\"channel-name-label\"\n                  label=\"名称\"\n                  type=\"text\"\n                  value={values.name}\n                  name=\"name\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'name' }}\n                  aria-describedby=\"helper-text-channel-name-label\"\n                />\n                {touched.name && errors.name && (\n                  <FormHelperText error id=\"helper-tex-channel-name-label\">\n                    {errors.name}\n                  </FormHelperText>\n                )}\n              </FormControl>\n              <FormControl fullWidth sx={{ ...theme.typography.otherInput }}>\n                <Autocomplete\n                  multiple\n                  freeSolo\n                  id=\"channel-models-label\"\n                  options={modelOptions}\n                  value={values.models}\n                  onChange={(e, value) => {\n                    const event = {\n                      target: {\n                        name: 'models',\n                        value: value\n                      }\n                    };\n                    handleChange(event);\n                  }}\n                  onBlur={handleBlur}\n                  // filterSelectedOptions\n                  disableCloseOnSelect\n                  renderInput={(params) => <TextField {...params} name=\"models\" error={Boolean(errors.models)} label=\"模型范围\" />}\n                  filterOptions={(options, params) => {\n                    const filtered = filter(options, params);\n                    const { inputValue } = params;\n                    const isExisting = options.some((option) => inputValue === option);\n                    if (inputValue !== '' && !isExisting) {\n                      filtered.push(inputValue);\n                    }\n                    return filtered;\n                  }}\n                  renderOption={(props, option, { selected }) => (\n                    <li {...props}>\n                      <Checkbox icon={icon} checkedIcon={checkedIcon} style={{ marginRight: 8 }} checked={selected} />\n                      {option}\n                    </li>\n                  )}\n                />\n                {errors.models ? (\n                  <FormHelperText error id=\"helper-tex-channel-models-label\">\n                    {errors.models}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-models-label\">请选择允许使用的模型，留空则不进行限制</FormHelperText>\n                )}\n              </FormControl>\n              <FormControl fullWidth error={Boolean(touched.subnet && errors.subnet)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-subnet-label\">IP 限制</InputLabel>\n                <OutlinedInput\n                  id=\"channel-subnet-label\"\n                  label=\"IP 限制\"\n                  type=\"text\"\n                  value={values.subnet}\n                  name=\"subnet\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'subnet' }}\n                  aria-describedby=\"helper-text-channel-subnet-label\"\n                />\n                {touched.subnet && errors.subnet ? (\n                  <FormHelperText error id=\"helper-tex-channel-subnet-label\">\n                    {errors.subnet}\n                  </FormHelperText>\n                ) : (\n                  <FormHelperText id=\"helper-tex-channel-subnet-label\">\n                    请输入允许访问的网段，例如：192.168.0.0/24，请使用英文逗号分隔多个网段\n                  </FormHelperText>\n                )}\n              </FormControl>\n              {values.expired_time !== -1 && (\n                <FormControl fullWidth error={Boolean(touched.expired_time && errors.expired_time)} sx={{ ...theme.typography.otherInput }}>\n                  <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={'zh-cn'}>\n                    <DateTimePicker\n                      label=\"过期时间\"\n                      ampm={false}\n                      value={dayjs.unix(values.expired_time)}\n                      onError={(newError) => {\n                        if (newError === null) {\n                          setFieldError('expired_time', null);\n                        } else {\n                          setFieldError('expired_time', '无效的日期');\n                        }\n                      }}\n                      onChange={(newValue) => {\n                        setFieldValue('expired_time', newValue.unix());\n                      }}\n                      slotProps={{\n                        actionBar: {\n                          actions: ['today', 'accept']\n                        }\n                      }}\n                    />\n                  </LocalizationProvider>\n                  {errors.expired_time && (\n                    <FormHelperText error id=\"helper-tex-channel-expired_time-label\">\n                      {errors.expired_time}\n                    </FormHelperText>\n                  )}\n                </FormControl>\n              )}\n              <Switch\n                checked={values.expired_time === -1}\n                onClick={() => {\n                  if (values.expired_time === -1) {\n                    setFieldValue('expired_time', Math.floor(Date.now() / 1000));\n                  } else {\n                    setFieldValue('expired_time', -1);\n                  }\n                }}\n              />{' '}\n              永不过期\n              <FormControl fullWidth error={Boolean(touched.remain_quota && errors.remain_quota)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-remain_quota-label\">额度</InputLabel>\n                <OutlinedInput\n                  id=\"channel-remain_quota-label\"\n                  label=\"额度\"\n                  type=\"number\"\n                  value={values.remain_quota}\n                  name=\"remain_quota\"\n                  endAdornment={<InputAdornment position=\"end\">{renderQuotaWithPrompt(values.remain_quota)}</InputAdornment>}\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  aria-describedby=\"helper-text-channel-remain_quota-label\"\n                  disabled={values.unlimited_quota}\n                />\n\n                {touched.remain_quota && errors.remain_quota && (\n                  <FormHelperText error id=\"helper-tex-channel-remain_quota-label\">\n                    {errors.remain_quota}\n                  </FormHelperText>\n                )}\n              </FormControl>\n              <Switch\n                checked={values.unlimited_quota === true}\n                onClick={() => {\n                  setFieldValue('unlimited_quota', !values.unlimited_quota);\n                }}\n              />{' '}\n              无限额度\n              <DialogActions>\n                <Button onClick={onCancel}>取消</Button>\n                <Button disableElevation disabled={isSubmitting} type=\"submit\" variant=\"contained\" color=\"primary\">\n                  提交\n                </Button>\n              </DialogActions>\n            </form>\n          )}\n        </Formik>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default EditModal;\n\nEditModal.propTypes = {\n  open: PropTypes.bool,\n  tokenId: PropTypes.number,\n  onCancel: PropTypes.func,\n  onOk: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/Token/component/TableHead.js",
    "content": "import { TableCell, TableHead, TableRow } from '@mui/material';\n\nconst TokenTableHead = () => {\n  return (\n    <TableHead>\n      <TableRow>\n        <TableCell>名称</TableCell>\n        <TableCell>状态</TableCell>\n        <TableCell>已用额度</TableCell>\n        <TableCell>剩余额度</TableCell>\n        <TableCell>创建时间</TableCell>\n        <TableCell>过期时间</TableCell>\n        <TableCell>操作</TableCell>\n      </TableRow>\n    </TableHead>\n  );\n};\n\nexport default TokenTableHead;\n"
  },
  {
    "path": "web/berry/src/views/Token/component/TableRow.js",
    "content": "import PropTypes from 'prop-types';\nimport { useState } from 'react';\nimport { useSelector } from 'react-redux';\n\nimport {\n  Popover,\n  TableRow,\n  MenuItem,\n  TableCell,\n  IconButton,\n  Dialog,\n  DialogActions,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Button,\n  Tooltip,\n  Stack,\n  ButtonGroup\n} from '@mui/material';\n\nimport TableSwitch from 'ui-component/Switch';\nimport { renderQuota, timestamp2string, copy } from 'utils/common';\n\nimport { IconDotsVertical, IconEdit, IconTrash, IconCaretDownFilled } from '@tabler/icons-react';\n\nconst COPY_OPTIONS = [\n  {\n    key: 'next',\n    text: 'ChatGPT Next',\n    url: 'https://app.nextchat.dev/#/?settings={\"key\":\"sk-{key}\",\"url\":\"{serverAddress}\"}',\n    encode: false\n  },\n  { key: 'ama', text: 'BotGem', url: 'ama://set-api-key?server={serverAddress}&key=sk-{key}', encode: true },\n  { key: 'opencat', text: 'OpenCat', url: 'opencat://team/join?domain={serverAddress}&token=sk-{key}', encode: true },\n  { key: 'lobechat', text: 'LobeChat', url: 'https://lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"sk-{key}\",\"baseURL\":\"{serverAddress}\"}}}', encode: true }\n];\n\nfunction replacePlaceholders(text, key, serverAddress) {\n  return text.replace('{key}', key).replace('{serverAddress}', serverAddress);\n}\n\nfunction createMenu(menuItems) {\n  return (\n    <>\n      {menuItems.map((menuItem, index) => (\n        <MenuItem key={index} onClick={menuItem.onClick} sx={{ color: menuItem.color }}>\n          {menuItem.icon}\n          {menuItem.text}\n        </MenuItem>\n      ))}\n    </>\n  );\n}\n\nexport default function TokensTableRow({ item, manageToken, handleOpenModal, setModalTokenId }) {\n  const [open, setOpen] = useState(null);\n  const [menuItems, setMenuItems] = useState(null);\n  const [openDelete, setOpenDelete] = useState(false);\n  const [statusSwitch, setStatusSwitch] = useState(item.status);\n  const siteInfo = useSelector((state) => state.siteInfo);\n\n  const handleDeleteOpen = () => {\n    handleCloseMenu();\n    setOpenDelete(true);\n  };\n\n  const handleDeleteClose = () => {\n    setOpenDelete(false);\n  };\n\n  const handleOpenMenu = (event, type) => {\n    switch (type) {\n      case 'copy':\n        setMenuItems(copyItems);\n        break;\n      case 'link':\n        setMenuItems(linkItems);\n        break;\n      default:\n        setMenuItems(actionItems);\n    }\n    setOpen(event.currentTarget);\n  };\n\n  const handleCloseMenu = () => {\n    setOpen(null);\n  };\n\n  const handleStatus = async () => {\n    const switchVlue = statusSwitch === 1 ? 2 : 1;\n    const { success } = await manageToken(item.id, 'status', switchVlue);\n    if (success) {\n      setStatusSwitch(switchVlue);\n    }\n  };\n\n  const handleDelete = async () => {\n    handleCloseMenu();\n    await manageToken(item.id, 'delete', '');\n  };\n\n  const actionItems = createMenu([\n    {\n      text: '编辑',\n      icon: <IconEdit style={{ marginRight: '16px' }} />,\n      onClick: () => {\n        handleCloseMenu();\n        handleOpenModal();\n        setModalTokenId(item.id);\n      },\n      color: undefined\n    },\n    {\n      text: '删除',\n      icon: <IconTrash style={{ marginRight: '16px' }} />,\n      onClick: handleDeleteOpen,\n      color: 'error.main'\n    }\n  ]);\n\n  const handleCopy = (option, type) => {\n    let serverAddress = '';\n    if (siteInfo?.server_address) {\n      serverAddress = siteInfo.server_address;\n    } else {\n      serverAddress = window.location.host;\n    }\n\n    if (option.encode) {\n      serverAddress = encodeURIComponent(serverAddress);\n    }\n\n    let url = option.url;\n\n    if (option.key === 'next' && siteInfo?.chat_link) {\n      url = siteInfo.chat_link + `/#/?settings={\"key\":\"sk-{key}\",\"url\":\"{serverAddress}\"}`;\n    }\n\n    const key = item.key;\n    const text = replacePlaceholders(url, key, serverAddress);\n    if (type === 'link') {\n      window.open(text);\n    } else {\n      copy(text);\n    }\n    handleCloseMenu();\n  };\n\n  const copyItems = createMenu(\n    COPY_OPTIONS.map((option) => ({\n      text: option.text,\n      icon: undefined,\n      onClick: () => handleCopy(option, 'copy'),\n      color: undefined\n    }))\n  );\n\n  const linkItems = createMenu(\n    COPY_OPTIONS.map((option) => ({\n      text: option.text,\n      icon: undefined,\n      onClick: () => handleCopy(option, 'link'),\n      color: undefined\n    }))\n  );\n\n  return (\n    <>\n      <TableRow tabIndex={item.id}>\n        <TableCell>{item.name}</TableCell>\n\n        <TableCell>\n          <Tooltip\n            title={(() => {\n              switch (statusSwitch) {\n                case 1:\n                  return '已启用';\n                case 2:\n                  return '已禁用';\n                case 3:\n                  return '已过期';\n                case 4:\n                  return '已耗尽';\n                default:\n                  return '未知';\n              }\n            })()}\n            placement=\"top\"\n          >\n            <TableSwitch\n              id={`switch-${item.id}`}\n              checked={statusSwitch === 1}\n              onChange={handleStatus}\n              // disabled={statusSwitch !== 1 && statusSwitch !== 2}\n            />\n          </Tooltip>\n        </TableCell>\n\n        <TableCell>{renderQuota(item.used_quota)}</TableCell>\n\n        <TableCell>{item.unlimited_quota ? '无限制' : renderQuota(item.remain_quota, 2)}</TableCell>\n\n        <TableCell>{timestamp2string(item.created_time)}</TableCell>\n\n        <TableCell>{item.expired_time === -1 ? '永不过期' : timestamp2string(item.expired_time)}</TableCell>\n\n        <TableCell>\n          <Stack direction=\"row\" spacing={1}>\n            <ButtonGroup size=\"small\" aria-label=\"split button\">\n              <Button\n                color=\"primary\"\n                onClick={() => {\n                  copy(`sk-${item.key}`);\n                }}\n              >\n                复制\n              </Button>\n              <Button size=\"small\" onClick={(e) => handleOpenMenu(e, 'copy')}>\n                <IconCaretDownFilled size={'16px'} />\n              </Button>\n            </ButtonGroup>\n            <ButtonGroup size=\"small\" aria-label=\"split button\">\n              <Button color=\"primary\" onClick={(e) => handleCopy(COPY_OPTIONS[0], 'link')}>\n                聊天\n              </Button>\n              <Button size=\"small\" onClick={(e) => handleOpenMenu(e, 'link')}>\n                <IconCaretDownFilled size={'16px'} />\n              </Button>\n            </ButtonGroup>\n            <IconButton onClick={(e) => handleOpenMenu(e, 'action')} sx={{ color: 'rgb(99, 115, 129)' }}>\n              <IconDotsVertical />\n            </IconButton>\n          </Stack>\n        </TableCell>\n      </TableRow>\n      <Popover\n        open={!!open}\n        anchorEl={open}\n        onClose={handleCloseMenu}\n        anchorOrigin={{ vertical: 'top', horizontal: 'left' }}\n        transformOrigin={{ vertical: 'top', horizontal: 'right' }}\n        PaperProps={{\n          sx: { width: 140 }\n        }}\n      >\n        {menuItems}\n      </Popover>\n\n      <Dialog open={openDelete} onClose={handleDeleteClose}>\n        <DialogTitle>删除Token</DialogTitle>\n        <DialogContent>\n          <DialogContentText>是否删除Token {item.name}？</DialogContentText>\n        </DialogContent>\n        <DialogActions>\n          <Button onClick={handleDeleteClose}>关闭</Button>\n          <Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>\n            删除\n          </Button>\n        </DialogActions>\n      </Dialog>\n    </>\n  );\n}\n\nTokensTableRow.propTypes = {\n  item: PropTypes.object,\n  manageToken: PropTypes.func,\n  handleOpenModal: PropTypes.func,\n  setModalTokenId: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/Token/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport { showError, showSuccess } from 'utils/common';\n\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableContainer from '@mui/material/TableContainer';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport TablePagination from '@mui/material/TablePagination';\nimport LinearProgress from '@mui/material/LinearProgress';\nimport Alert from '@mui/material/Alert';\nimport ButtonGroup from '@mui/material/ButtonGroup';\nimport Toolbar from '@mui/material/Toolbar';\n\nimport { Button, Card, Box, Stack, Container, Typography } from '@mui/material';\nimport TokensTableRow from './component/TableRow';\nimport TokenTableHead from './component/TableHead';\nimport TableToolBar from 'ui-component/TableToolBar';\nimport { API } from 'utils/api';\nimport { ITEMS_PER_PAGE } from 'constants';\nimport { IconRefresh, IconPlus } from '@tabler/icons-react';\nimport EditeModal from './component/EditModal';\nimport { useSelector } from 'react-redux';\n\nexport default function Token() {\n  const [tokens, setTokens] = useState([]);\n  const [activePage, setActivePage] = useState(0);\n  const [searching, setSearching] = useState(false);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [openModal, setOpenModal] = useState(false);\n  const [editTokenId, setEditTokenId] = useState(0);\n  const siteInfo = useSelector((state) => state.siteInfo);\n\n  const loadTokens = async (startIdx) => {\n    setSearching(true);\n    const res = await API.get(`/api/token/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setTokens(data);\n      } else {\n        let newTokens = [...tokens];\n        newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setTokens(newTokens);\n      }\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  useEffect(() => {\n    loadTokens(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, []);\n\n  const onPaginationChange = (event, activePage) => {\n    (async () => {\n      if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE)) {\n        // In this case we have to load more data and then append them.\n        await loadTokens(activePage);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const searchTokens = async (event) => {\n    event.preventDefault();\n    if (searchKeyword === '') {\n      await loadTokens(0);\n      setActivePage(0);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/token/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setTokens(data);\n      setActivePage(0);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleSearchKeyword = (event) => {\n    setSearchKeyword(event.target.value);\n  };\n\n  const manageToken = async (id, action, value) => {\n    const url = '/api/token/';\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(url + id);\n        break;\n      case 'status':\n        res = await API.put(url + `?status_only=true`, {\n          ...data,\n          status: value\n        });\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      if (action === 'delete') {\n        await handleRefresh();\n      }\n    } else {\n      showError(message);\n    }\n\n    return res.data;\n  };\n\n  // 处理刷新\n  const handleRefresh = async () => {\n    await loadTokens(activePage);\n  };\n\n  const handleOpenModal = (tokenId) => {\n    setEditTokenId(tokenId);\n    setOpenModal(true);\n  };\n\n  const handleCloseModal = () => {\n    setOpenModal(false);\n    setEditTokenId(0);\n  };\n\n  const handleOkModal = (status) => {\n    if (status === true) {\n      handleCloseModal();\n      handleRefresh();\n    }\n  };\n\n  return (\n    <>\n      <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"space-between\" mb={2.5}>\n        <Typography variant=\"h4\">令牌</Typography>\n        <Button\n          variant=\"contained\"\n          color=\"primary\"\n          onClick={() => {\n            handleOpenModal(0);\n          }}\n          startIcon={<IconPlus />}\n        >\n          新建令牌\n        </Button>\n      </Stack>\n      <Stack mb={2}>\n        <Alert severity=\"info\">\n          将 OpenAI API 基础地址 https://api.openai.com 替换为 <b>{siteInfo.server_address}</b>，复制下面的密钥即可使用\n        </Alert>\n      </Stack>\n      <Card>\n        <Box component=\"form\" onSubmit={searchTokens} noValidate sx={{marginTop: 2}}>\n          <TableToolBar filterName={searchKeyword} handleFilterName={handleSearchKeyword} placeholder={'搜索令牌的名称...'} />\n        </Box>\n        <Toolbar\n          sx={{\n            textAlign: 'right',\n            height: 50,\n            display: 'flex',\n            justifyContent: 'space-between',\n            p: (theme) => theme.spacing(0, 1, 0, 3)\n          }}\n        >\n          <Container>\n            <ButtonGroup variant=\"outlined\" aria-label=\"outlined small primary button group\" sx={{marginBottom: 2}}>\n              <Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>\n                刷新\n              </Button>\n            </ButtonGroup>\n          </Container>\n        </Toolbar>\n        {searching && <LinearProgress />}\n        <PerfectScrollbar component=\"div\">\n          <TableContainer sx={{ overflow: 'unset' }}>\n            <Table sx={{ minWidth: 800 }}>\n              <TokenTableHead />\n              <TableBody>\n                {tokens.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (\n                  <TokensTableRow\n                    item={row}\n                    manageToken={manageToken}\n                    key={row.id}\n                    handleOpenModal={handleOpenModal}\n                    setModalTokenId={setEditTokenId}\n                  />\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n        </PerfectScrollbar>\n        <TablePagination\n          page={activePage}\n          component=\"div\"\n          count={tokens.length + (tokens.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}\n          rowsPerPage={ITEMS_PER_PAGE}\n          onPageChange={onPaginationChange}\n          rowsPerPageOptions={[ITEMS_PER_PAGE]}\n        />\n      </Card>\n      <EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} tokenId={editTokenId} />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/berry/src/views/Topup/component/InviteCard.js",
    "content": "import { Stack, Typography, Container, Box, OutlinedInput, InputAdornment, Button } from '@mui/material';\nimport { useTheme } from '@mui/material/styles';\nimport SubCard from 'ui-component/cards/SubCard';\nimport inviteImage from 'assets/images/invite/cwok_casual_19.webp';\nimport { useState } from 'react';\nimport { API } from 'utils/api';\nimport { showError, copy } from 'utils/common';\n\nconst InviteCard = () => {\n  const theme = useTheme();\n  const [inviteUl, setInviteUrl] = useState('');\n\n  const handleInviteUrl = async () => {\n    if (inviteUl) {\n      copy(inviteUl, '邀请链接');\n      return;\n    }\n    const res = await API.get('/api/user/aff');\n    const { success, message, data } = res.data;\n    if (success) {\n      let link = `${window.location.origin}/register?aff=${data}`;\n      setInviteUrl(link);\n      copy(link, '邀请链接');\n    } else {\n      showError(message);\n    }\n  };\n\n  return (\n    <Box component=\"div\">\n      <SubCard\n        sx={{\n          background: theme.palette.primary.dark\n        }}\n      >\n        <Stack justifyContent=\"center\" alignItems={'flex-start'} padding={'40px 24px 0px'} spacing={3}>\n          <Container sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>\n            <img src={inviteImage} alt=\"invite\" width={'250px'} />\n          </Container>\n        </Stack>\n      </SubCard>\n      <SubCard\n        sx={{\n          marginTop: '-20px'\n        }}\n      >\n        <Stack justifyContent=\"center\" alignItems={'center'} spacing={3}>\n          <Typography variant=\"h3\" sx={{ color: theme.palette.primary.dark }}>\n            邀请奖励\n          </Typography>\n          <Typography variant=\"body\" sx={{ color: theme.palette.primary.dark }}>\n            分享您的邀请链接，邀请好友注册，即可获得奖励！\n          </Typography>\n\n          <OutlinedInput\n            id=\"invite-url\"\n            label=\"邀请链接\"\n            type=\"text\"\n            value={inviteUl}\n            name=\"invite-url\"\n            placeholder=\"点击生成邀请链接\"\n            endAdornment={\n              <InputAdornment position=\"end\">\n                <Button variant=\"contained\" onClick={handleInviteUrl}>\n                  {inviteUl ? '复制' : '生成'}\n                </Button>\n              </InputAdornment>\n            }\n            aria-describedby=\"helper-text-channel-quota-label\"\n            disabled={true}\n          />\n        </Stack>\n      </SubCard>\n    </Box>\n  );\n};\n\nexport default InviteCard;\n"
  },
  {
    "path": "web/berry/src/views/Topup/component/TopupCard.js",
    "content": "import { Typography, Stack, OutlinedInput, InputAdornment, Button, InputLabel, FormControl } from '@mui/material';\nimport { IconWallet } from '@tabler/icons-react';\nimport { useTheme } from '@mui/material/styles';\nimport SubCard from 'ui-component/cards/SubCard';\nimport UserCard from 'ui-component/cards/UserCard';\n\nimport { API } from 'utils/api';\nimport React, { useEffect, useState } from 'react';\nimport { showError, showInfo, showSuccess, renderQuota } from 'utils/common';\n\nconst TopupCard = () => {\n  const theme = useTheme();\n  const [redemptionCode, setRedemptionCode] = useState('');\n  const [topUpLink, setTopUpLink] = useState('');\n  const [userQuota, setUserQuota] = useState(0);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const topUp = async () => {\n    if (redemptionCode === '') {\n      showInfo('请输入充值码！');\n      return;\n    }\n    setIsSubmitting(true);\n    try {\n      const res = await API.post('/api/user/topup', {\n        key: redemptionCode\n      });\n      const { success, message, data } = res.data;\n      if (success) {\n        showSuccess('充值成功！');\n        setUserQuota((quota) => {\n          return quota + data;\n        });\n        setRedemptionCode('');\n      } else {\n        showError(message);\n      }\n    } catch (err) {\n      showError('请求失败');\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const openTopUpLink = () => {\n    if (!topUpLink) {\n      showError('超级管理员未设置充值链接！');\n      return;\n    }\n    window.open(topUpLink, '_blank');\n  };\n\n  const getUserQuota = async () => {\n    let res = await API.get(`/api/user/self`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setUserQuota(data.quota);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    let status = localStorage.getItem('siteInfo');\n    if (status) {\n      status = JSON.parse(status);\n      if (status.top_up_link) {\n        setTopUpLink(status.top_up_link);\n      }\n    }\n    getUserQuota().then();\n  }, []);\n\n  return (\n    <UserCard>\n      <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"center\" spacing={2} paddingTop={'20px'}>\n        <IconWallet color={theme.palette.primary.main} />\n        <Typography variant=\"h4\">当前额度:</Typography>\n        <Typography variant=\"h4\">{renderQuota(userQuota)}</Typography>\n      </Stack>\n      <SubCard\n        sx={{\n          marginTop: '40px'\n        }}\n      >\n        <FormControl fullWidth variant=\"outlined\">\n          <InputLabel htmlFor=\"key\">兑换码</InputLabel>\n          <OutlinedInput\n            id=\"key\"\n            label=\"兑换码\"\n            type=\"text\"\n            value={redemptionCode}\n            onChange={(e) => {\n              setRedemptionCode(e.target.value);\n            }}\n            name=\"key\"\n            placeholder=\"请输入兑换码\"\n            endAdornment={\n              <InputAdornment position=\"end\">\n                <Button variant=\"contained\" onClick={topUp} disabled={isSubmitting}>\n                  {isSubmitting ? '兑换中...' : '兑换'}\n                </Button>\n              </InputAdornment>\n            }\n            aria-describedby=\"helper-text-channel-quota-label\"\n          />\n        </FormControl>\n\n        <Stack justifyContent=\"center\" alignItems={'center'} spacing={3} paddingTop={'20px'}>\n          <Typography variant={'h4'} color={theme.palette.grey[700]}>\n            还没有兑换码？ 点击获取兑换码：\n          </Typography>\n          <Button variant=\"contained\" onClick={openTopUpLink}>\n            获取兑换码\n          </Button>\n        </Stack>\n      </SubCard>\n    </UserCard>\n  );\n};\n\nexport default TopupCard;\n"
  },
  {
    "path": "web/berry/src/views/Topup/index.js",
    "content": "import { Stack, Alert } from '@mui/material';\nimport Grid from '@mui/material/Unstable_Grid2';\nimport TopupCard from './component/TopupCard';\nimport InviteCard from './component/InviteCard';\n\nconst Topup = () => {\n  return (\n    <Grid container spacing={2}>\n      <Grid xs={12}>\n        <Alert severity=\"warning\">\n          充值记录以及邀请记录请在日志中查询。充值记录请在日志中选择类型【充值】查询；邀请记录请在日志中选择【系统】查询{' '}\n        </Alert>\n      </Grid>\n      <Grid xs={12} md={6} lg={8}>\n        <Stack spacing={2}>\n          <TopupCard />\n        </Stack>\n      </Grid>\n      <Grid xs={12} md={6} lg={4}>\n        <InviteCard />\n      </Grid>\n    </Grid>\n  );\n};\n\nexport default Topup;\n"
  },
  {
    "path": "web/berry/src/views/User/component/EditModal.js",
    "content": "import PropTypes from 'prop-types';\nimport * as Yup from 'yup';\nimport { Formik } from 'formik';\nimport { useTheme } from '@mui/material/styles';\nimport { useState, useEffect } from 'react';\nimport {\n  Dialog,\n  DialogTitle,\n  DialogContent,\n  DialogActions,\n  Button,\n  Divider,\n  FormControl,\n  InputLabel,\n  OutlinedInput,\n  InputAdornment,\n  Select,\n  MenuItem,\n  IconButton,\n  FormHelperText\n} from '@mui/material';\n\nimport Visibility from '@mui/icons-material/Visibility';\nimport VisibilityOff from '@mui/icons-material/VisibilityOff';\n\nimport { renderQuotaWithPrompt, showSuccess, showError } from 'utils/common';\nimport { API } from 'utils/api';\n\nconst validationSchema = Yup.object().shape({\n  is_edit: Yup.boolean(),\n  username: Yup.string().required('用户名 不能为空'),\n  display_name: Yup.string(),\n  password: Yup.string().when('is_edit', {\n    is: false,\n    then: Yup.string().required('密码 不能为空'),\n    otherwise: Yup.string()\n  }),\n  group: Yup.string().when('is_edit', {\n    is: false,\n    then: Yup.string().required('用户组 不能为空'),\n    otherwise: Yup.string()\n  }),\n  quota: Yup.number().when('is_edit', {\n    is: false,\n    then: Yup.number().min(0, '额度 不能小于 0'),\n    otherwise: Yup.number()\n  })\n});\n\nconst originInputs = {\n  is_edit: false,\n  username: '',\n  display_name: '',\n  password: '',\n  group: 'default',\n  quota: 0\n};\n\nconst EditModal = ({ open, userId, onCancel, onOk }) => {\n  const theme = useTheme();\n  const [inputs, setInputs] = useState(originInputs);\n  const [groupOptions, setGroupOptions] = useState([]);\n  const [showPassword, setShowPassword] = useState(false);\n\n  const submit = async (values, { setErrors, setStatus, setSubmitting }) => {\n    setSubmitting(true);\n\n    let res;\n    if (values.is_edit) {\n      res = await API.put(`/api/user/`, { ...values, id: parseInt(userId) });\n    } else {\n      res = await API.post(`/api/user/`, values);\n    }\n    const { success, message } = res.data;\n    if (success) {\n      if (values.is_edit) {\n        showSuccess('用户更新成功！');\n      } else {\n        showSuccess('用户创建成功！');\n      }\n      setSubmitting(false);\n      setStatus({ success: true });\n      onOk(true);\n    } else {\n      showError(message);\n      setErrors({ submit: message });\n    }\n  };\n\n  const handleClickShowPassword = () => {\n    setShowPassword(!showPassword);\n  };\n\n  const handleMouseDownPassword = (event) => {\n    event.preventDefault();\n  };\n\n  const loadUser = async () => {\n    let res = await API.get(`/api/user/${userId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      data.is_edit = true;\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const fetchGroups = async () => {\n    try {\n      let res = await API.get(`/api/group/`);\n      setGroupOptions(res.data.data);\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n\n  useEffect(() => {\n    fetchGroups().then();\n    if (userId) {\n      loadUser().then();\n    } else {\n      setInputs(originInputs);\n    }\n  }, [userId]);\n\n  return (\n    <Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>\n      <DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>\n        {userId ? '编辑用户' : '新建用户'}\n      </DialogTitle>\n      <Divider />\n      <DialogContent>\n        <Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>\n          {({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (\n            <form noValidate onSubmit={handleSubmit}>\n              <FormControl fullWidth error={Boolean(touched.username && errors.username)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-username-label\">用户名</InputLabel>\n                <OutlinedInput\n                  id=\"channel-username-label\"\n                  label=\"用户名\"\n                  type=\"text\"\n                  value={values.username}\n                  name=\"username\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'username' }}\n                  aria-describedby=\"helper-text-channel-username-label\"\n                />\n                {touched.username && errors.username && (\n                  <FormHelperText error id=\"helper-tex-channel-username-label\">\n                    {errors.username}\n                  </FormHelperText>\n                )}\n              </FormControl>\n\n              <FormControl fullWidth error={Boolean(touched.display_name && errors.display_name)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-display_name-label\">显示名称</InputLabel>\n                <OutlinedInput\n                  id=\"channel-display_name-label\"\n                  label=\"显示名称\"\n                  type=\"text\"\n                  value={values.display_name}\n                  name=\"display_name\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'display_name' }}\n                  aria-describedby=\"helper-text-channel-display_name-label\"\n                />\n                {touched.display_name && errors.display_name && (\n                  <FormHelperText error id=\"helper-tex-channel-display_name-label\">\n                    {errors.display_name}\n                  </FormHelperText>\n                )}\n              </FormControl>\n\n              <FormControl fullWidth error={Boolean(touched.password && errors.password)} sx={{ ...theme.typography.otherInput }}>\n                <InputLabel htmlFor=\"channel-password-label\">密码</InputLabel>\n                <OutlinedInput\n                  id=\"channel-password-label\"\n                  label=\"密码\"\n                  type={showPassword ? 'text' : 'password'}\n                  value={values.password}\n                  name=\"password\"\n                  onBlur={handleBlur}\n                  onChange={handleChange}\n                  inputProps={{ autoComplete: 'password' }}\n                  endAdornment={\n                    <InputAdornment position=\"end\">\n                      <IconButton\n                        aria-label=\"toggle password visibility\"\n                        onClick={handleClickShowPassword}\n                        onMouseDown={handleMouseDownPassword}\n                        edge=\"end\"\n                        size=\"large\"\n                      >\n                        {showPassword ? <Visibility /> : <VisibilityOff />}\n                      </IconButton>\n                    </InputAdornment>\n                  }\n                  aria-describedby=\"helper-text-channel-password-label\"\n                />\n                {touched.password && errors.password && (\n                  <FormHelperText error id=\"helper-tex-channel-password-label\">\n                    {errors.password}\n                  </FormHelperText>\n                )}\n              </FormControl>\n\n              {values.is_edit && (\n                <>\n                  <FormControl fullWidth error={Boolean(touched.quota && errors.quota)} sx={{ ...theme.typography.otherInput }}>\n                    <InputLabel htmlFor=\"channel-quota-label\">额度</InputLabel>\n                    <OutlinedInput\n                      id=\"channel-quota-label\"\n                      label=\"额度\"\n                      type=\"number\"\n                      value={values.quota}\n                      name=\"quota\"\n                      endAdornment={<InputAdornment position=\"end\">{renderQuotaWithPrompt(values.quota)}</InputAdornment>}\n                      onBlur={handleBlur}\n                      onChange={handleChange}\n                      aria-describedby=\"helper-text-channel-quota-label\"\n                      disabled={values.unlimited_quota}\n                    />\n\n                    {touched.quota && errors.quota && (\n                      <FormHelperText error id=\"helper-tex-channel-quota-label\">\n                        {errors.quota}\n                      </FormHelperText>\n                    )}\n                  </FormControl>\n\n                  <FormControl fullWidth error={Boolean(touched.group && errors.group)} sx={{ ...theme.typography.otherInput }}>\n                    <InputLabel htmlFor=\"channel-group-label\">分组</InputLabel>\n                    <Select\n                      id=\"channel-group-label\"\n                      label=\"分组\"\n                      value={values.group}\n                      name=\"group\"\n                      onBlur={handleBlur}\n                      onChange={handleChange}\n                      MenuProps={{\n                        PaperProps: {\n                          style: {\n                            maxHeight: 200\n                          }\n                        }\n                      }}\n                    >\n                      {groupOptions.map((option) => {\n                        return (\n                          <MenuItem key={option} value={option}>\n                            {option}\n                          </MenuItem>\n                        );\n                      })}\n                    </Select>\n                    {touched.group && errors.group && (\n                      <FormHelperText error id=\"helper-tex-channel-group-label\">\n                        {errors.group}\n                      </FormHelperText>\n                    )}\n                  </FormControl>\n                </>\n              )}\n              <DialogActions>\n                <Button onClick={onCancel}>取消</Button>\n                <Button disableElevation disabled={isSubmitting} type=\"submit\" variant=\"contained\" color=\"primary\">\n                  提交\n                </Button>\n              </DialogActions>\n            </form>\n          )}\n        </Formik>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport default EditModal;\n\nEditModal.propTypes = {\n  open: PropTypes.bool,\n  userId: PropTypes.number,\n  onCancel: PropTypes.func,\n  onOk: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/User/component/TableHead.js",
    "content": "import { TableCell, TableHead, TableRow } from '@mui/material';\n\nconst UsersTableHead = () => {\n  return (\n    <TableHead>\n      <TableRow>\n        <TableCell>ID</TableCell>\n        <TableCell>用户名</TableCell>\n        <TableCell>分组</TableCell>\n        <TableCell>统计信息</TableCell>\n        <TableCell>用户角色</TableCell>\n        <TableCell>绑定</TableCell>\n        <TableCell>状态</TableCell>\n        <TableCell>操作</TableCell>\n      </TableRow>\n    </TableHead>\n  );\n};\n\nexport default UsersTableHead;\n"
  },
  {
    "path": "web/berry/src/views/User/component/TableRow.js",
    "content": "import PropTypes from 'prop-types';\nimport { useState } from 'react';\n\nimport {\n  Popover,\n  TableRow,\n  MenuItem,\n  TableCell,\n  IconButton,\n  Dialog,\n  DialogActions,\n  DialogContent,\n  DialogContentText,\n  DialogTitle,\n  Button,\n  Tooltip,\n  Stack\n} from '@mui/material';\n\nimport Label from 'ui-component/Label';\nimport TableSwitch from 'ui-component/Switch';\nimport { renderQuota, renderNumber } from 'utils/common';\nimport { IconDotsVertical, IconEdit, IconTrash, IconUser, IconBrandWechat, IconBrandGithub, IconMail } from '@tabler/icons-react';\nimport { useTheme } from '@mui/material/styles';\n\nfunction renderRole(role) {\n  switch (role) {\n    case 1:\n      return <Label color=\"default\">普通用户</Label>;\n    case 10:\n      return <Label color=\"orange\">管理员</Label>;\n    case 100:\n      return <Label color=\"success\">超级管理员</Label>;\n    default:\n      return <Label color=\"error\">未知身份</Label>;\n  }\n}\n\nexport default function UsersTableRow({ item, manageUser, handleOpenModal, setModalUserId }) {\n  const theme = useTheme();\n  const [open, setOpen] = useState(null);\n  const [openDelete, setOpenDelete] = useState(false);\n  const [statusSwitch, setStatusSwitch] = useState(item.status);\n\n  const handleDeleteOpen = () => {\n    handleCloseMenu();\n    setOpenDelete(true);\n  };\n\n  const handleDeleteClose = () => {\n    setOpenDelete(false);\n  };\n\n  const handleOpenMenu = (event) => {\n    setOpen(event.currentTarget);\n  };\n\n  const handleCloseMenu = () => {\n    setOpen(null);\n  };\n\n  const handleStatus = async () => {\n    const switchVlue = statusSwitch === 1 ? 2 : 1;\n    const { success } = await manageUser(item.username, 'status', switchVlue);\n    if (success) {\n      setStatusSwitch(switchVlue);\n    }\n  };\n\n  const handleDelete = async () => {\n    handleCloseMenu();\n    await manageUser(item.username, 'delete', '');\n  };\n\n  return (\n    <>\n      <TableRow tabIndex={item.id}>\n        <TableCell>{item.id}</TableCell>\n\n        <TableCell>{item.username}</TableCell>\n\n        <TableCell>\n          <Label>{item.group}</Label>\n        </TableCell>\n\n        <TableCell>\n          <Stack direction=\"row\" spacing={0.5} alignItems=\"center\" justifyContent=\"center\">\n            <Tooltip title={'剩余额度'} placement=\"top\">\n              <Label color={'primary'} variant=\"outlined\">\n                {' '}\n                {renderQuota(item.quota)}{' '}\n              </Label>\n            </Tooltip>\n            <Tooltip title={'已用额度'} placement=\"top\">\n              <Label color={'primary'} variant=\"outlined\">\n                {' '}\n                {renderQuota(item.used_quota)}{' '}\n              </Label>\n            </Tooltip>\n            <Tooltip title={'请求次数'} placement=\"top\">\n              <Label color={'primary'} variant=\"outlined\">\n                {' '}\n                {renderNumber(item.request_count)}{' '}\n              </Label>\n            </Tooltip>\n          </Stack>\n        </TableCell>\n        <TableCell>{renderRole(item.role)}</TableCell>\n        <TableCell>\n          <Stack direction=\"row\" spacing={0.5} alignItems=\"center\" justifyContent=\"center\">\n            <Tooltip title={item.wechat_id ? item.wechat_id : '未绑定'} placement=\"top\">\n              <IconBrandWechat color={item.wechat_id ? theme.palette.success.dark : theme.palette.grey[400]} />\n            </Tooltip>\n            <Tooltip title={item.github_id ? item.github_id : '未绑定'} placement=\"top\">\n              <IconBrandGithub color={item.github_id ? theme.palette.grey[900] : theme.palette.grey[400]} />\n            </Tooltip>\n            <Tooltip title={item.email ? item.email : '未绑定'} placement=\"top\">\n              <IconMail color={item.email ? theme.palette.grey[900] : theme.palette.grey[400]} />\n            </Tooltip>\n          </Stack>\n        </TableCell>\n\n        <TableCell>\n          {' '}\n          <TableSwitch id={`switch-${item.id}`} checked={statusSwitch === 1} onChange={handleStatus} />\n        </TableCell>\n        <TableCell>\n          <IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>\n            <IconDotsVertical />\n          </IconButton>\n        </TableCell>\n      </TableRow>\n\n      <Popover\n        open={!!open}\n        anchorEl={open}\n        onClose={handleCloseMenu}\n        anchorOrigin={{ vertical: 'top', horizontal: 'left' }}\n        transformOrigin={{ vertical: 'top', horizontal: 'right' }}\n        PaperProps={{\n          sx: { width: 140 }\n        }}\n      >\n        {item.role !== 100 && (\n          <MenuItem\n            onClick={() => {\n              handleCloseMenu();\n              manageUser(item.username, 'role', item.role === 1 ? true : false);\n            }}\n          >\n            <IconUser style={{ marginRight: '16px' }} />\n            {item.role === 1 ? '设为管理员' : '取消管理员'}\n          </MenuItem>\n        )}\n\n        <MenuItem\n          onClick={() => {\n            handleCloseMenu();\n            handleOpenModal();\n            setModalUserId(item.id);\n          }}\n        >\n          <IconEdit style={{ marginRight: '16px' }} />\n          编辑\n        </MenuItem>\n        <MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>\n          <IconTrash style={{ marginRight: '16px' }} />\n          删除\n        </MenuItem>\n      </Popover>\n\n      <Dialog open={openDelete} onClose={handleDeleteClose}>\n        <DialogTitle>删除用户</DialogTitle>\n        <DialogContent>\n          <DialogContentText>是否删除用户 {item.name}？</DialogContentText>\n        </DialogContent>\n        <DialogActions>\n          <Button onClick={handleDeleteClose}>关闭</Button>\n          <Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>\n            删除\n          </Button>\n        </DialogActions>\n      </Dialog>\n    </>\n  );\n}\n\nUsersTableRow.propTypes = {\n  item: PropTypes.object,\n  manageUser: PropTypes.func,\n  handleOpenModal: PropTypes.func,\n  setModalUserId: PropTypes.func\n};\n"
  },
  {
    "path": "web/berry/src/views/User/index.js",
    "content": "import { useState, useEffect } from 'react';\nimport { showError, showSuccess } from 'utils/common';\n\nimport Table from '@mui/material/Table';\nimport TableBody from '@mui/material/TableBody';\nimport TableContainer from '@mui/material/TableContainer';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport TablePagination from '@mui/material/TablePagination';\nimport LinearProgress from '@mui/material/LinearProgress';\nimport ButtonGroup from '@mui/material/ButtonGroup';\nimport Toolbar from '@mui/material/Toolbar';\n\nimport { Button, Card, Box, Stack, Container, Typography } from '@mui/material';\nimport UsersTableRow from './component/TableRow';\nimport UsersTableHead from './component/TableHead';\nimport TableToolBar from 'ui-component/TableToolBar';\nimport { API } from 'utils/api';\nimport { ITEMS_PER_PAGE } from 'constants';\nimport { IconRefresh, IconPlus } from '@tabler/icons-react';\nimport EditeModal from './component/EditModal';\n\n// ----------------------------------------------------------------------\nexport default function Users() {\n  const [users, setUsers] = useState([]);\n  const [activePage, setActivePage] = useState(0);\n  const [searching, setSearching] = useState(false);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [openModal, setOpenModal] = useState(false);\n  const [editUserId, setEditUserId] = useState(0);\n\n  const loadUsers = async (startIdx) => {\n    setSearching(true);\n    const res = await API.get(`/api/user/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setUsers(data);\n      } else {\n        let newUsers = [...users];\n        newUsers.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setUsers(newUsers);\n      }\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const onPaginationChange = (event, activePage) => {\n    (async () => {\n      if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE)) {\n        // In this case we have to load more data and then append them.\n        await loadUsers(activePage);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const searchUsers = async (event) => {\n    event.preventDefault();\n    if (searchKeyword === '') {\n      await loadUsers(0);\n      setActivePage(0);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setUsers(data);\n      setActivePage(0);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleSearchKeyword = (event) => {\n    setSearchKeyword(event.target.value);\n  };\n\n  const manageUser = async (username, action, value) => {\n    const url = '/api/user/manage';\n    let data = { username: username, action: '' };\n    let res;\n    switch (action) {\n      case 'delete':\n        data.action = 'delete';\n        break;\n      case 'status':\n        data.action = value === 1 ? 'enable' : 'disable';\n        break;\n      case 'role':\n        data.action = value === true ? 'promote' : 'demote';\n        break;\n    }\n\n    res = await API.post(url, data);\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('操作成功完成！');\n      await loadUsers(activePage);\n    } else {\n      showError(message);\n    }\n\n    return res.data;\n  };\n\n  // 处理刷新\n  const handleRefresh = async () => {\n    await loadUsers(activePage);\n  };\n\n  const handleOpenModal = (userId) => {\n    setEditUserId(userId);\n    setOpenModal(true);\n  };\n\n  const handleCloseModal = () => {\n    setOpenModal(false);\n    setEditUserId(0);\n  };\n\n  const handleOkModal = (status) => {\n    if (status === true) {\n      handleCloseModal();\n      handleRefresh();\n    }\n  };\n\n  useEffect(() => {\n    loadUsers(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, []);\n\n  return (\n    <>\n      <Stack direction=\"row\" alignItems=\"center\" justifyContent=\"space-between\" mb={2.5}>\n        <Typography variant=\"h4\">用户</Typography>\n\n        <Button variant=\"contained\" color=\"primary\" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>\n          新建用户\n        </Button>\n      </Stack>\n      <Card>\n        <Box component=\"form\" onSubmit={searchUsers} noValidate sx={{marginTop: 2}}>\n          <TableToolBar\n            filterName={searchKeyword}\n            handleFilterName={handleSearchKeyword}\n            placeholder={'搜索用户的ID，用户名，显示名称，以及邮箱地址...'}\n          />\n        </Box>\n        <Toolbar\n          sx={{\n            textAlign: 'right',\n            height: 50,\n            display: 'flex',\n            justifyContent: 'space-between',\n            p: (theme) => theme.spacing(0, 1, 0, 3)\n          }}\n        >\n          <Container>\n            <ButtonGroup variant=\"outlined\" aria-label=\"outlined small primary button group\" sx={{marginBottom: 2}}>\n              <Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>\n                刷新\n              </Button>\n            </ButtonGroup>\n          </Container>\n        </Toolbar>\n        {searching && <LinearProgress />}\n        <PerfectScrollbar component=\"div\">\n          <TableContainer sx={{ overflow: 'unset' }}>\n            <Table sx={{ minWidth: 800 }}>\n              <UsersTableHead />\n              <TableBody>\n                {users.slice(activePage * ITEMS_PER_PAGE, (activePage + 1) * ITEMS_PER_PAGE).map((row) => (\n                  <UsersTableRow\n                    item={row}\n                    manageUser={manageUser}\n                    key={row.id}\n                    handleOpenModal={handleOpenModal}\n                    setModalUserId={setEditUserId}\n                  />\n                ))}\n              </TableBody>\n            </Table>\n          </TableContainer>\n        </PerfectScrollbar>\n        <TablePagination\n          page={activePage}\n          component=\"div\"\n          count={users.length + (users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)}\n          rowsPerPage={ITEMS_PER_PAGE}\n          onPageChange={onPaginationChange}\n          rowsPerPageOptions={[ITEMS_PER_PAGE]}\n        />\n      </Card>\n      <EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} userId={editUserId} />\n    </>\n  );\n}\n"
  },
  {
    "path": "web/build.sh",
    "content": "#!/bin/sh\n\nversion=$(cat VERSION)\npwd\n\nwhile IFS= read -r theme; do\n    echo \"Building theme: $theme\"\n    rm -r build/$theme\n    cd \"$theme\"\n    npm install\n    DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$version npm run build\n    cd ..\ndone < THEMES\n"
  },
  {
    "path": "web/default/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.idea\npackage-lock.json\nyarn.lock"
  },
  {
    "path": "web/default/README.md",
    "content": "# React Template\n\n## Basic Usages\n\n```shell\n# Runs the app in the development mode\nnpm start\n\n# Builds the app for production to the `build` folder\nnpm run build\n```\n\nIf you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,\nfor example: `REACT_APP_SERVER=http://your.domain.com`.\n\nBefore you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.\n\n## Reference\n\n1. https://github.com/OIerDb-ng/OIerDb\n2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example"
  },
  {
    "path": "web/default/package.json",
    "content": "{\n  \"name\": \"react-template\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"axios\": \"^0.27.2\",\n    \"history\": \"^5.3.0\",\n    \"i18next\": \"^24.2.2\",\n    \"i18next-browser-languagedetector\": \"^8.0.2\",\n    \"marked\": \"^4.1.1\",\n    \"moment\": \"^2.30.1\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dropzone\": \"^14.2.3\",\n    \"react-i18next\": \"^15.4.0\",\n    \"react-router-dom\": \"^6.3.0\",\n    \"react-scripts\": \"5.0.1\",\n    \"react-toastify\": \"^9.0.8\",\n    \"react-turnstile\": \"^1.0.5\",\n    \"recharts\": \"^2.15.1\",\n    \"semantic-ui-css\": \"^2.5.0\",\n    \"semantic-ui-react\": \"^2.1.3\"\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"build\": \"react-scripts build && rm -rf ../build/default && mv -f build ../build/default\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\",\n      \"react-app/jest\"\n    ]\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^2.7.1\"\n  },\n  \"prettier\": {\n    \"singleQuote\": true,\n    \"jsxSingleQuote\": true\n  },\n  \"proxy\": \"http://localhost:3000\"\n}\n"
  },
  {
    "path": "web/default/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"logo.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n    <meta\n      name=\"description\"\n      content=\"OpenAI 接口聚合管理，支持多种渠道包括 Azure，可用于二次分发管理 key，仅单可执行文件，已打包好 Docker 镜像，一键部署，开箱即用\"\n    />\n    <title>One API</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "web/default/public/robots.txt",
    "content": "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "web/default/src/App.js",
    "content": "import React, { lazy, Suspense, useContext, useEffect } from 'react';\nimport { Route, Routes } from 'react-router-dom';\nimport Loading from './components/Loading';\nimport User from './pages/User';\nimport { PrivateRoute } from './components/PrivateRoute';\nimport RegisterForm from './components/RegisterForm';\nimport LoginForm from './components/LoginForm';\nimport NotFound from './pages/NotFound';\nimport Setting from './pages/Setting';\nimport EditUser from './pages/User/EditUser';\nimport AddUser from './pages/User/AddUser';\nimport { API, getLogo, getSystemName, showError, showNotice } from './helpers';\nimport PasswordResetForm from './components/PasswordResetForm';\nimport GitHubOAuth from './components/GitHubOAuth';\nimport PasswordResetConfirm from './components/PasswordResetConfirm';\nimport { UserContext } from './context/User';\nimport { StatusContext } from './context/Status';\nimport Channel from './pages/Channel';\nimport Token from './pages/Token';\nimport EditToken from './pages/Token/EditToken';\nimport EditChannel from './pages/Channel/EditChannel';\nimport Redemption from './pages/Redemption';\nimport EditRedemption from './pages/Redemption/EditRedemption';\nimport TopUp from './pages/TopUp';\nimport Log from './pages/Log';\nimport Chat from './pages/Chat';\nimport LarkOAuth from './components/LarkOAuth';\nimport Dashboard from './pages/Dashboard';\n\nconst Home = lazy(() => import('./pages/Home'));\nconst About = lazy(() => import('./pages/About'));\n\nfunction App() {\n  const [userState, userDispatch] = useContext(UserContext);\n  const [statusState, statusDispatch] = useContext(StatusContext);\n\n  const loadUser = () => {\n    let user = localStorage.getItem('user');\n    if (user) {\n      let data = JSON.parse(user);\n      userDispatch({ type: 'login', payload: data });\n    }\n  };\n  const loadStatus = async () => {\n    try {\n      const res = await API.get('/api/status');\n      const { success, message, data } = res.data || {}; // Add default empty object\n      if (success && data) {\n        // Check data exists\n        localStorage.setItem('status', JSON.stringify(data));\n        statusDispatch({ type: 'set', payload: data });\n        localStorage.setItem('system_name', data.system_name);\n        localStorage.setItem('logo', data.logo);\n        localStorage.setItem('footer_html', data.footer_html);\n        localStorage.setItem('quota_per_unit', data.quota_per_unit);\n        localStorage.setItem('display_in_currency', data.display_in_currency);\n        if (data.chat_link) {\n          localStorage.setItem('chat_link', data.chat_link);\n        } else {\n          localStorage.removeItem('chat_link');\n        }\n        if (\n          data.version !== process.env.REACT_APP_VERSION &&\n          data.version !== 'v0.0.0' &&\n          process.env.REACT_APP_VERSION !== ''\n        ) {\n          showNotice(\n            `新版本可用：${data.version}，请使用快捷键 Shift + F5 刷新页面`\n          );\n        }\n      } else {\n        showError(message || '无法正常连接至服务器！');\n      }\n    } catch (error) {\n      showError(error.message || '无法正常连接至服务器！');\n    }\n  };\n\n  useEffect(() => {\n    loadUser();\n    loadStatus().then();\n    let systemName = getSystemName();\n    if (systemName) {\n      document.title = systemName;\n    }\n    let logo = getLogo();\n    if (logo) {\n      let linkElement = document.querySelector(\"link[rel~='icon']\");\n      if (linkElement) {\n        linkElement.href = logo;\n      }\n    }\n  }, []);\n\n  return (\n    <Routes>\n      <Route\n        path='/'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <Home />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/channel'\n        element={\n          <PrivateRoute>\n            <Channel />\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/channel/edit/:id'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditChannel />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/channel/add'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditChannel />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/token'\n        element={\n          <PrivateRoute>\n            <Token />\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/token/edit/:id'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditToken />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/token/add'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditToken />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/redemption'\n        element={\n          <PrivateRoute>\n            <Redemption />\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/redemption/edit/:id'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditRedemption />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/redemption/add'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditRedemption />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/user'\n        element={\n          <PrivateRoute>\n            <User />\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/user/edit/:id'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditUser />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/user/edit'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <EditUser />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/user/add'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <AddUser />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/user/reset'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <PasswordResetConfirm />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/login'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <LoginForm />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/register'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <RegisterForm />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/reset'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <PasswordResetForm />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/oauth/github'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <GitHubOAuth />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/oauth/lark'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <LarkOAuth />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/setting'\n        element={\n          <PrivateRoute>\n            <Suspense fallback={<Loading></Loading>}>\n              <Setting />\n            </Suspense>\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/topup'\n        element={\n          <PrivateRoute>\n            <Suspense fallback={<Loading></Loading>}>\n              <TopUp />\n            </Suspense>\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/log'\n        element={\n          <PrivateRoute>\n            <Log />\n          </PrivateRoute>\n        }\n      />\n      <Route\n        path='/about'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <About />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/chat'\n        element={\n          <Suspense fallback={<Loading></Loading>}>\n            <Chat />\n          </Suspense>\n        }\n      />\n      <Route\n        path='/dashboard'\n        element={\n          <PrivateRoute>\n            <Dashboard />\n          </PrivateRoute>\n        }\n      />\n      <Route path='*' element={<NotFound />} />\n    </Routes>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "web/default/src/components/ChannelsTable.js",
    "content": "import React, {useEffect, useState} from 'react';\nimport {useTranslation} from 'react-i18next';\nimport {Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table,} from 'semantic-ui-react';\nimport {Link} from 'react-router-dom';\nimport {\n  API,\n  loadChannelModels,\n  setPromptShown,\n  shouldShowPrompt,\n  showError,\n  showInfo,\n  showSuccess,\n  timestamp2string,\n} from '../helpers';\n\nimport {CHANNEL_OPTIONS, ITEMS_PER_PAGE} from '../constants';\nimport {renderGroup, renderNumber} from '../helpers/render';\n\nfunction renderTimestamp(timestamp) {\n  return <>{timestamp2string(timestamp)}</>;\n}\n\nlet type2label = undefined;\n\nfunction renderType(type, t) {\n  if (!type2label) {\n    type2label = new Map();\n    for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {\n      type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];\n    }\n    type2label[0] = {\n      value: 0,\n      text: t('channel.table.status_unknown'),\n      color: 'grey',\n    };\n  }\n  return (\n    <Label basic color={type2label[type]?.color}>\n      {type2label[type] ? type2label[type].text : type}\n    </Label>\n  );\n}\n\nfunction renderBalance(type, balance, t) {\n  switch (type) {\n    case 1: // OpenAI\n        if (balance === 0) {\n            return <span>{t('channel.table.balance_not_supported')}</span>;\n        }\n      return <span>${balance.toFixed(2)}</span>;\n    case 4: // CloseAI\n      return <span>¥{balance.toFixed(2)}</span>;\n    case 8: // 自定义\n      return <span>${balance.toFixed(2)}</span>;\n    case 5: // OpenAI-SB\n      return <span>¥{(balance / 10000).toFixed(2)}</span>;\n    case 10: // AI Proxy\n      return <span>{renderNumber(balance)}</span>;\n    case 12: // API2GPT\n      return <span>¥{balance.toFixed(2)}</span>;\n    case 13: // AIGC2D\n      return <span>{renderNumber(balance)}</span>;\n    case 20: // OpenRouter\n      return <span>${balance.toFixed(2)}</span>;\n    case 36: // DeepSeek\n      return <span>¥{balance.toFixed(2)}</span>;\n    case 44: // SiliconFlow\n      return <span>¥{balance.toFixed(2)}</span>;\n    default:\n      return <span>{t('channel.table.balance_not_supported')}</span>;\n  }\n}\n\nfunction isShowDetail() {\n  return localStorage.getItem('show_detail') === 'true';\n}\n\nconst promptID = 'detail';\n\nconst ChannelsTable = () => {\n  const { t } = useTranslation();\n  const [channels, setChannels] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [updatingBalance, setUpdatingBalance] = useState(false);\n  const [showPrompt, setShowPrompt] = useState(shouldShowPrompt(promptID));\n  const [showDetail, setShowDetail] = useState(isShowDetail());\n\n  const processChannelData = (channel) => {\n    if (channel.models === '') {\n      channel.models = [];\n      channel.test_model = '';\n    } else {\n      channel.models = channel.models.split(',');\n      if (channel.models.length > 0) {\n        channel.test_model = channel.models[0];\n      }\n      channel.model_options = channel.models.map((model) => {\n        return {\n          key: model,\n          text: model,\n          value: model,\n        };\n      });\n      console.log('channel', channel);\n    }\n    return channel;\n  };\n\n  const loadChannels = async (startIdx) => {\n    const res = await API.get(`/api/channel/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      let localChannels = data.map(processChannelData);\n      if (startIdx === 0) {\n        setChannels(localChannels);\n      } else {\n        let newChannels = [...channels];\n        newChannels.splice(\n          startIdx * ITEMS_PER_PAGE,\n          data.length,\n          ...localChannels\n        );\n        setChannels(newChannels);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadChannels(activePage - 1);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const refresh = async () => {\n    setLoading(true);\n    await loadChannels(activePage - 1);\n  };\n\n  const toggleShowDetail = () => {\n    setShowDetail(!showDetail);\n    localStorage.setItem('show_detail', (!showDetail).toString());\n  };\n\n  useEffect(() => {\n    loadChannels(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n    loadChannelModels().then();\n  }, []);\n\n  const manageChannel = async (id, action, idx, value) => {\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(`/api/channel/${id}/`);\n        break;\n      case 'enable':\n        data.status = 1;\n        res = await API.put('/api/channel/', data);\n        break;\n      case 'disable':\n        data.status = 2;\n        res = await API.put('/api/channel/', data);\n        break;\n      case 'priority':\n        if (value === '') {\n          return;\n        }\n        data.priority = parseInt(value);\n        res = await API.put('/api/channel/', data);\n        break;\n      case 'weight':\n        if (value === '') {\n          return;\n        }\n        data.weight = parseInt(value);\n        if (data.weight < 0) {\n          data.weight = 0;\n        }\n        res = await API.put('/api/channel/', data);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('channel.messages.operation_success'));\n      let channel = res.data.data;\n      let newChannels = [...channels];\n      let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      if (action === 'delete') {\n        newChannels[realIdx].deleted = true;\n      } else {\n        newChannels[realIdx].status = channel.status;\n      }\n      setChannels(newChannels);\n    } else {\n      showError(message);\n    }\n  };\n\n  const renderStatus = (status, t) => {\n    switch (status) {\n      case 1:\n        return (\n          <Label basic color='green'>\n            {t('channel.table.status_enabled')}\n          </Label>\n        );\n      case 2:\n        return (\n          <Popup\n            trigger={\n              <Label basic color='red'>\n                {t('channel.table.status_disabled')}\n              </Label>\n            }\n            content={t('channel.table.status_disabled_tip')}\n            basic\n          />\n        );\n      case 3:\n        return (\n          <Popup\n            trigger={\n              <Label basic color='yellow'>\n                {t('channel.table.status_auto_disabled')}\n              </Label>\n            }\n            content={t('channel.table.status_auto_disabled_tip')}\n            basic\n          />\n        );\n      default:\n        return (\n          <Label basic color='grey'>\n            {t('channel.table.status_unknown')}\n          </Label>\n        );\n    }\n  };\n\n  const renderResponseTime = (responseTime, t) => {\n    let time = responseTime / 1000;\n    time = time.toFixed(2) + 's';\n    if (responseTime === 0) {\n      return (\n        <Label basic color='grey'>\n          {t('channel.table.not_tested')}\n        </Label>\n      );\n    } else if (responseTime <= 1000) {\n      return (\n        <Label basic color='green'>\n          {time}\n        </Label>\n      );\n    } else if (responseTime <= 3000) {\n      return (\n        <Label basic color='olive'>\n          {time}\n        </Label>\n      );\n    } else if (responseTime <= 5000) {\n      return (\n        <Label basic color='yellow'>\n          {time}\n        </Label>\n      );\n    } else {\n      return (\n        <Label basic color='red'>\n          {time}\n        </Label>\n      );\n    }\n  };\n\n  const searchChannels = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadChannels(0);\n      setActivePage(1);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/channel/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      let localChannels = data.map(processChannelData);\n      setChannels(localChannels);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const switchTestModel = async (idx, model) => {\n    let newChannels = [...channels];\n    let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n    newChannels[realIdx].test_model = model;\n    setChannels(newChannels);\n  };\n\n  const testChannel = async (id, name, idx, m) => {\n    const res = await API.get(`/api/channel/test/${id}?model=${m}`);\n    const { success, message, time, model } = res.data;\n    if (success) {\n      let newChannels = [...channels];\n      let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      newChannels[realIdx].response_time = time * 1000;\n      newChannels[realIdx].test_time = Date.now() / 1000;\n      setChannels(newChannels);\n      showSuccess(\n        t('channel.messages.test_success', { name, model, time, message })\n      );\n    } else {\n      showError(message);\n    }\n    let newChannels = [...channels];\n    let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n    newChannels[realIdx].response_time = time * 1000;\n    newChannels[realIdx].test_time = Date.now() / 1000;\n    setChannels(newChannels);\n  };\n\n  const testChannels = async (scope) => {\n    const res = await API.get(`/api/channel/test?scope=${scope}`);\n    const { success, message } = res.data;\n    if (success) {\n      showInfo(t('channel.messages.test_all_started'));\n    } else {\n      showError(message);\n    }\n  };\n\n  const deleteAllDisabledChannels = async () => {\n    const res = await API.delete(`/api/channel/disabled`);\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(\n        t('channel.messages.delete_disabled_success', { count: data })\n      );\n      await refresh();\n    } else {\n      showError(message);\n    }\n  };\n\n  const updateChannelBalance = async (id, name, idx) => {\n    const res = await API.get(`/api/channel/update_balance/${id}/`);\n    const { success, message, balance } = res.data;\n    if (success) {\n      let newChannels = [...channels];\n      let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      newChannels[realIdx].balance = balance;\n      newChannels[realIdx].balance_updated_time = Date.now() / 1000;\n      setChannels(newChannels);\n      showSuccess(t('channel.messages.balance_update_success', { name }));\n    } else {\n      showError(message);\n    }\n  };\n\n  const updateAllChannelsBalance = async () => {\n    setUpdatingBalance(true);\n    const res = await API.get(`/api/channel/update_balance`);\n    const { success, message } = res.data;\n    if (success) {\n      showInfo(t('channel.messages.all_balance_updated'));\n    } else {\n      showError(message);\n    }\n    setUpdatingBalance(false);\n  };\n\n  const handleKeywordChange = async (e, { value }) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortChannel = (key) => {\n    if (channels.length === 0) return;\n    setLoading(true);\n    let sortedChannels = [...channels];\n    sortedChannels.sort((a, b) => {\n      if (!isNaN(a[key])) {\n        // If the value is numeric, subtract to sort\n        return a[key] - b[key];\n      } else {\n        // If the value is not numeric, sort as strings\n        return ('' + a[key]).localeCompare(b[key]);\n      }\n    });\n    if (sortedChannels[0].id === channels[0].id) {\n      sortedChannels.reverse();\n    }\n    setChannels(sortedChannels);\n    setLoading(false);\n  };\n\n  return (\n    <>\n      <Form onSubmit={searchChannels}>\n        <Form.Input\n          icon='search'\n          fluid\n          iconPosition='left'\n          placeholder={t('channel.search')}\n          value={searchKeyword}\n          loading={searching}\n          onChange={handleKeywordChange}\n        />\n      </Form>\n      {showPrompt && (\n        <Message\n          onDismiss={() => {\n            setShowPrompt(false);\n            setPromptShown(promptID);\n          }}\n        >\n          {t('channel.balance_notice')}\n          <br />\n          {t('channel.test_notice')}\n          <br />\n          {t('channel.detail_notice')}\n        </Message>\n      )}\n      <Table basic={'very'} compact size='small'>\n        <Table.Header>\n          <Table.Row>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('id');\n              }}\n            >\n              {t('channel.table.id')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('name');\n              }}\n            >\n              {t('channel.table.name')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('group');\n              }}\n            >\n              {t('channel.table.group')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('type');\n              }}\n            >\n              {t('channel.table.type')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('status');\n              }}\n            >\n              {t('channel.table.status')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('response_time');\n              }}\n            >\n              {t('channel.table.response_time')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('balance');\n              }}\n            >\n              {t('channel.table.balance')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortChannel('priority');\n              }}\n              hidden={!showDetail}\n            >\n              {t('channel.table.priority')}\n            </Table.HeaderCell>\n            <Table.HeaderCell hidden={!showDetail}>\n              {t('channel.table.test_model')}\n            </Table.HeaderCell>\n            <Table.HeaderCell>{t('channel.table.actions')}</Table.HeaderCell>\n          </Table.Row>\n        </Table.Header>\n\n        <Table.Body>\n          {channels\n            .slice(\n              (activePage - 1) * ITEMS_PER_PAGE,\n              activePage * ITEMS_PER_PAGE\n            )\n            .map((channel, idx) => {\n              if (channel.deleted) return <></>;\n              return (\n                <Table.Row key={channel.id}>\n                  <Table.Cell>{channel.id}</Table.Cell>\n                  <Table.Cell>\n                    {channel.name ? channel.name : t('channel.table.no_name')}\n                  </Table.Cell>\n                  <Table.Cell>{renderGroup(channel.group)}</Table.Cell>\n                  <Table.Cell>{renderType(channel.type, t)}</Table.Cell>\n                  <Table.Cell>{renderStatus(channel.status, t)}</Table.Cell>\n                  <Table.Cell>\n                    <Popup\n                      content={\n                        channel.test_time\n                          ? renderTimestamp(channel.test_time)\n                          : t('channel.table.not_tested')\n                      }\n                      key={channel.id}\n                      trigger={renderResponseTime(channel.response_time, t)}\n                      basic\n                    />\n                  </Table.Cell>\n                  <Table.Cell>\n                    <Popup\n                      trigger={\n                        <span\n                          onClick={() => {\n                            updateChannelBalance(channel.id, channel.name, idx);\n                          }}\n                          style={{ cursor: 'pointer' }}\n                        >\n                          {renderBalance(channel.type, channel.balance, t)}\n                        </span>\n                      }\n                      content={t('channel.table.click_to_update')}\n                      basic\n                    />\n                  </Table.Cell>\n                  <Table.Cell hidden={!showDetail}>\n                    <Popup\n                      trigger={\n                        <Input\n                          type='number'\n                          defaultValue={channel.priority}\n                          onBlur={(event) => {\n                            manageChannel(\n                              channel.id,\n                              'priority',\n                              idx,\n                              event.target.value\n                            );\n                          }}\n                        >\n                          <input style={{ maxWidth: '60px' }} />\n                        </Input>\n                      }\n                      content={t('channel.table.priority_tip')}\n                      basic\n                    />\n                  </Table.Cell>\n                  <Table.Cell hidden={!showDetail}>\n                    <Dropdown\n                      placeholder={t('channel.table.select_test_model')}\n                      selection\n                      options={channel.model_options}\n                      defaultValue={channel.test_model}\n                      onChange={(event, data) => {\n                        switchTestModel(idx, data.value);\n                      }}\n                    />\n                  </Table.Cell>\n                  <Table.Cell>\n                    <div\n                      style={{\n                        display: 'flex',\n                        alignItems: 'center',\n                        flexWrap: 'wrap',\n                        gap: '2px',\n                        rowGap: '6px',\n                      }}\n                    >\n                      <Button\n                        size={'tiny'}\n                        positive\n                        onClick={() => {\n                          testChannel(\n                            channel.id,\n                            channel.name,\n                            idx,\n                            channel.test_model\n                          );\n                        }}\n                      >\n                        {t('channel.buttons.test')}\n                      </Button>\n                      <Popup\n                        trigger={\n                          <Button size='tiny' negative>\n                            {t('channel.buttons.delete')}\n                          </Button>\n                        }\n                        on='click'\n                        flowing\n                        hoverable\n                      >\n                        <Button\n                          size={'tiny'}\n                          negative\n                          onClick={() => {\n                            manageChannel(channel.id, 'delete', idx);\n                          }}\n                        >\n                          {t('channel.buttons.confirm_delete')} {channel.name}\n                        </Button>\n                      </Popup>\n                      <Button\n                        size={'tiny'}\n                        onClick={() => {\n                          manageChannel(\n                            channel.id,\n                            channel.status === 1 ? 'disable' : 'enable',\n                            idx\n                          );\n                        }}\n                      >\n                        {channel.status === 1\n                          ? t('channel.buttons.disable')\n                          : t('channel.buttons.enable')}\n                      </Button>\n                      <Button\n                        size={'tiny'}\n                        as={Link}\n                        to={'/channel/edit/' + channel.id}\n                      >\n                        {t('channel.buttons.edit')}\n                      </Button>\n                    </div>\n                  </Table.Cell>\n                </Table.Row>\n              );\n            })}\n        </Table.Body>\n\n        <Table.Footer>\n          <Table.Row>\n            <Table.HeaderCell colSpan={showDetail ? '10' : '8'}>\n              <Button size='tiny' as={Link} to='/channel/add' loading={loading}>\n                {t('channel.buttons.add')}\n              </Button>\n              <Button\n                size='tiny'\n                loading={loading}\n                onClick={() => {\n                  testChannels('all');\n                }}\n              >\n                {t('channel.buttons.test_all')}\n              </Button>\n              <Button\n                size='tiny'\n                loading={loading}\n                onClick={() => {\n                  testChannels('disabled');\n                }}\n              >\n                {t('channel.buttons.test_disabled')}\n              </Button>\n              <Popup\n                trigger={\n                  <Button size='tiny' loading={loading}>\n                    {t('channel.buttons.delete_disabled')}\n                  </Button>\n                }\n                on='click'\n                flowing\n                hoverable\n              >\n                <Button\n                  size='tiny'\n                  loading={loading}\n                  negative\n                  onClick={deleteAllDisabledChannels}\n                >\n                  {t('channel.buttons.confirm_delete_disabled')}\n                </Button>\n              </Popup>\n              <Pagination\n                floated='right'\n                activePage={activePage}\n                onPageChange={onPaginationChange}\n                size='tiny'\n                siblingRange={1}\n                totalPages={\n                  Math.ceil(channels.length / ITEMS_PER_PAGE) +\n                  (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)\n                }\n              />\n              <Button size='tiny' onClick={refresh} loading={loading}>\n                {t('channel.buttons.refresh')}\n              </Button>\n              <Button size='tiny' onClick={toggleShowDetail}>\n                {showDetail\n                  ? t('channel.buttons.hide_detail')\n                  : t('channel.buttons.show_detail')}\n              </Button>\n            </Table.HeaderCell>\n          </Table.Row>\n        </Table.Footer>\n      </Table>\n    </>\n  );\n};\n\nexport default ChannelsTable;\n"
  },
  {
    "path": "web/default/src/components/Footer.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Container, Segment } from 'semantic-ui-react';\nimport { getFooterHTML, getSystemName } from '../helpers';\n\nconst Footer = () => {\n  const { t } = useTranslation();\n  const systemName = getSystemName();\n  const [footer, setFooter] = useState(getFooterHTML());\n  let remainCheckTimes = 5;\n\n  const loadFooter = () => {\n    let footer_html = localStorage.getItem('footer_html');\n    if (footer_html) {\n      setFooter(footer_html);\n    }\n  };\n\n  useEffect(() => {\n    const timer = setInterval(() => {\n      if (remainCheckTimes <= 0) {\n        clearInterval(timer);\n        return;\n      }\n      remainCheckTimes--;\n      loadFooter();\n    }, 200);\n    return () => clearTimeout(timer);\n  }, []);\n\n  return (\n    <Segment vertical>\n      <Container textAlign='center' style={{ color: '#666666' }}>\n        {footer ? (\n          <div\n            className='custom-footer'\n            dangerouslySetInnerHTML={{ __html: footer }}\n          ></div>\n        ) : (\n          <div className='custom-footer'>\n            <a href='https://github.com/songquanpeng/one-api' target='_blank'>\n              {systemName} {process.env.REACT_APP_VERSION}{' '}\n            </a>\n            {t('footer.built_by')}{' '}\n            <a href='https://github.com/songquanpeng' target='_blank'>\n              {t('footer.built_by_name')}\n            </a>{' '}\n            {t('footer.license')}{' '}\n            <a href='https://opensource.org/licenses/mit-license.php'>\n              {t('footer.mit')}\n            </a>\n          </div>\n        )}\n      </Container>\n    </Segment>\n  );\n};\n\nexport default Footer;\n"
  },
  {
    "path": "web/default/src/components/GitHubOAuth.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { Dimmer, Loader, Segment } from 'semantic-ui-react';\nimport { useNavigate, useSearchParams } from 'react-router-dom';\nimport { API, showError, showSuccess } from '../helpers';\nimport { UserContext } from '../context/User';\n\nconst GitHubOAuth = () => {\n  const [searchParams, setSearchParams] = useSearchParams();\n\n  const [userState, userDispatch] = useContext(UserContext);\n  const [prompt, setPrompt] = useState('处理中...');\n  const [processing, setProcessing] = useState(true);\n\n  let navigate = useNavigate();\n\n  const sendCode = async (code, state, count) => {\n    const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (message === 'bind') {\n        showSuccess('绑定成功！');\n        navigate('/setting');\n      } else {\n        userDispatch({ type: 'login', payload: data });\n        localStorage.setItem('user', JSON.stringify(data));\n        showSuccess('登录成功！');\n        navigate('/');\n      }\n    } else {\n      showError(message);\n      if (count === 0) {\n        setPrompt(`操作失败，重定向至登录界面中...`);\n        navigate('/setting'); // in case this is failed to bind GitHub\n        return;\n      }\n      count++;\n      setPrompt(`出现错误，第 ${count} 次重试中...`);\n      await new Promise((resolve) => setTimeout(resolve, count * 2000));\n      await sendCode(code, state, count);\n    }\n  };\n\n  useEffect(() => {\n    let code = searchParams.get('code');\n    let state = searchParams.get('state');\n    sendCode(code, state, 0).then();\n  }, []);\n\n  return (\n    <Segment style={{ minHeight: '300px' }}>\n      <Dimmer active inverted>\n        <Loader size='large'>{prompt}</Loader>\n      </Dimmer>\n    </Segment>\n  );\n};\n\nexport default GitHubOAuth;\n"
  },
  {
    "path": "web/default/src/components/Header.js",
    "content": "import React, { useContext, useState } from 'react';\nimport { Link, useNavigate } from 'react-router-dom';\nimport { UserContext } from '../context/User';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n  Button,\n  Container,\n  Dropdown,\n  Icon,\n  Menu,\n  Segment,\n} from 'semantic-ui-react';\nimport {\n  API,\n  getLogo,\n  getSystemName,\n  isAdmin,\n  isMobile,\n  showSuccess,\n} from '../helpers';\nimport '../index.css';\n\n// Header Buttons\nlet headerButtons = [\n  {\n    name: 'header.channel',\n    to: '/channel',\n    icon: 'sitemap',\n    admin: true,\n  },\n  {\n    name: 'header.token',\n    to: '/token',\n    icon: 'key',\n  },\n  {\n    name: 'header.redemption',\n    to: '/redemption',\n    icon: 'dollar sign',\n    admin: true,\n  },\n  {\n    name: 'header.topup',\n    to: '/topup',\n    icon: 'cart',\n  },\n  {\n    name: 'header.user',\n    to: '/user',\n    icon: 'user',\n    admin: true,\n  },\n  {\n    name: 'header.dashboard',\n    to: '/dashboard',\n    icon: 'chart bar',\n  },\n  {\n    name: 'header.log',\n    to: '/log',\n    icon: 'book',\n  },\n  {\n    name: 'header.setting',\n    to: '/setting',\n    icon: 'setting',\n  },\n  {\n    name: 'header.about',\n    to: '/about',\n    icon: 'info circle',\n  },\n];\n\nif (localStorage.getItem('chat_link')) {\n  headerButtons.splice(1, 0, {\n    name: 'header.chat',\n    to: '/chat',\n    icon: 'comments',\n  });\n}\n\nconst Header = () => {\n  const { t, i18n } = useTranslation();\n  const [userState, userDispatch] = useContext(UserContext);\n  let navigate = useNavigate();\n\n  const [showSidebar, setShowSidebar] = useState(false);\n  const systemName = getSystemName();\n  const logo = getLogo();\n\n  async function logout() {\n    setShowSidebar(false);\n    await API.get('/api/user/logout');\n    showSuccess('注销成功!');\n    userDispatch({ type: 'logout' });\n    localStorage.removeItem('user');\n    navigate('/login');\n  }\n\n  const toggleSidebar = () => {\n    setShowSidebar(!showSidebar);\n  };\n\n  const renderButtons = (isMobile) => {\n    return headerButtons.map((button) => {\n      if (button.admin && !isAdmin()) return <></>;\n      if (isMobile) {\n        return (\n          <Menu.Item\n            key={button.name}\n            onClick={() => {\n              navigate(button.to);\n              setShowSidebar(false);\n            }}\n            style={{ fontSize: '15px' }}\n          >\n            {t(button.name)}\n          </Menu.Item>\n        );\n      }\n      return (\n        <Menu.Item\n          key={button.name}\n          as={Link}\n          to={button.to}\n          style={{\n            fontSize: '15px',\n            fontWeight: '400',\n            color: '#666',\n          }}\n        >\n          <Icon name={button.icon} style={{ marginRight: '4px' }} />\n          {t(button.name)}\n        </Menu.Item>\n      );\n    });\n  };\n\n  // Add language switcher dropdown\n  const languageOptions = [\n    { key: 'zh', text: '中文', value: 'zh' },\n    { key: 'en', text: 'English', value: 'en' },\n  ];\n\n  const changeLanguage = (language) => {\n    i18n.changeLanguage(language);\n  };\n\n  if (isMobile()) {\n    return (\n      <>\n        <Menu\n          borderless\n          size='large'\n          style={\n            showSidebar\n              ? {\n                  borderBottom: 'none',\n                  marginBottom: '0',\n                  borderTop: 'none',\n                  height: '51px',\n                }\n              : { borderTop: 'none', height: '52px' }\n          }\n        >\n          <Container\n            style={{\n              width: '100%',\n              maxWidth: isMobile() ? '100%' : '1200px',\n              padding: isMobile() ? '0 10px' : '0 20px',\n            }}\n          >\n            <Menu.Item as={Link} to='/'>\n              <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />\n              <div style={{ fontSize: '20px' }}>\n                <b>{systemName}</b>\n              </div>\n            </Menu.Item>\n            <Menu.Menu position='right'>\n              <Menu.Item onClick={toggleSidebar}>\n                <Icon name={showSidebar ? 'close' : 'sidebar'} />\n              </Menu.Item>\n            </Menu.Menu>\n          </Container>\n        </Menu>\n        {showSidebar ? (\n          <Segment style={{ marginTop: 0, borderTop: '0' }}>\n            <Menu secondary vertical style={{ width: '100%', margin: 0 }}>\n              {renderButtons(true)}\n              <Menu.Item>\n                <Dropdown\n                  selection\n                  trigger={\n                    <Icon\n                      name='language'\n                      style={{ margin: 0, fontSize: '18px' }}\n                    />\n                  }\n                  options={languageOptions}\n                  value={i18n.language}\n                  onChange={(_, { value }) => changeLanguage(value)}\n                />\n              </Menu.Item>\n              <Menu.Item>\n                {userState.user ? (\n                  <Button onClick={logout} style={{ color: '#666666' }}>\n                    {t('header.logout')}\n                  </Button>\n                ) : (\n                  <>\n                    <Button\n                      onClick={() => {\n                        setShowSidebar(false);\n                        navigate('/login');\n                      }}\n                    >\n                      {t('header.login')}\n                    </Button>\n                    <Button\n                      onClick={() => {\n                        setShowSidebar(false);\n                        navigate('/register');\n                      }}\n                    >\n                      {t('header.register')}\n                    </Button>\n                  </>\n                )}\n              </Menu.Item>\n            </Menu>\n          </Segment>\n        ) : (\n          <></>\n        )}\n      </>\n    );\n  }\n\n  return (\n    <>\n      <Menu\n        borderless\n        style={{\n          borderTop: 'none',\n          boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px',\n          border: 'none',\n        }}\n      >\n        <Container\n          style={{\n            width: '100%',\n            maxWidth: isMobile() ? '100%' : '1200px',\n            padding: isMobile() ? '0 10px' : '0 20px',\n          }}\n        >\n          <Menu.Item as={Link} to='/' className={'hide-on-mobile'}>\n            <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />\n            <div\n              style={{\n                fontSize: '18px',\n                fontWeight: '500',\n                color: '#333',\n              }}\n            >\n              {systemName}\n            </div>\n          </Menu.Item>\n          {renderButtons(false)}\n          <Menu.Menu position='right'>\n            <Dropdown\n              item\n              trigger={\n                <Icon name='language' style={{ margin: 0, fontSize: '18px' }} />\n              }\n              options={languageOptions}\n              value={i18n.language}\n              onChange={(_, { value }) => changeLanguage(value)}\n              style={{\n                fontSize: '16px',\n                fontWeight: '400',\n                color: '#666',\n                padding: '0 10px',\n              }}\n            />\n            {userState.user ? (\n              <Dropdown\n                text={userState.user.username}\n                pointing\n                className='link item'\n                style={{\n                  fontSize: '15px',\n                  fontWeight: '400',\n                  color: '#666',\n                }}\n              >\n                <Dropdown.Menu>\n                  <Dropdown.Item\n                    onClick={logout}\n                    style={{\n                      fontSize: '15px',\n                      fontWeight: '400',\n                      color: '#666',\n                    }}\n                  >\n                    {t('header.logout')}\n                  </Dropdown.Item>\n                </Dropdown.Menu>\n              </Dropdown>\n            ) : (\n              <Menu.Item\n                name={t('header.login')}\n                as={Link}\n                to='/login'\n                className='btn btn-link'\n                style={{\n                  fontSize: '15px',\n                  fontWeight: '400',\n                  color: '#666',\n                }}\n              />\n            )}\n          </Menu.Menu>\n        </Container>\n      </Menu>\n    </>\n  );\n};\n\nexport default Header;\n"
  },
  {
    "path": "web/default/src/components/LarkOAuth.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { Dimmer, Loader, Segment } from 'semantic-ui-react';\nimport { useNavigate, useSearchParams } from 'react-router-dom';\nimport { API, showError, showSuccess } from '../helpers';\nimport { UserContext } from '../context/User';\n\nconst LarkOAuth = () => {\n  const [searchParams, setSearchParams] = useSearchParams();\n\n  const [userState, userDispatch] = useContext(UserContext);\n  const [prompt, setPrompt] = useState('处理中...');\n  const [processing, setProcessing] = useState(true);\n\n  let navigate = useNavigate();\n\n  const sendCode = async (code, state, count) => {\n    const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (message === 'bind') {\n        showSuccess('绑定成功！');\n        navigate('/setting');\n      } else {\n        userDispatch({ type: 'login', payload: data });\n        localStorage.setItem('user', JSON.stringify(data));\n        showSuccess('登录成功！');\n        navigate('/');\n      }\n    } else {\n      showError(message);\n      if (count === 0) {\n        setPrompt(`操作失败，重定向至登录界面中...`);\n        navigate('/setting'); // in case this is failed to bind lark\n        return;\n      }\n      count++;\n      setPrompt(`出现错误，第 ${count} 次重试中...`);\n      await new Promise((resolve) => setTimeout(resolve, count * 2000));\n      await sendCode(code, state, count);\n    }\n  };\n\n  useEffect(() => {\n    let code = searchParams.get('code');\n    let state = searchParams.get('state');\n    sendCode(code, state, 0).then();\n  }, []);\n\n  return (\n    <Segment style={{ minHeight: '300px' }}>\n      <Dimmer active inverted>\n        <Loader size='large'>{prompt}</Loader>\n      </Dimmer>\n    </Segment>\n  );\n};\n\nexport default LarkOAuth;\n"
  },
  {
    "path": "web/default/src/components/Loading.js",
    "content": "import React from 'react';\nimport { Segment, Dimmer, Loader } from 'semantic-ui-react';\n\nconst Loading = ({ prompt: name = 'page' }) => {\n  return (\n    <Segment style={{ height: 100 }}>\n      <Dimmer active inverted>\n        <Loader indeterminate>加载{name}中...</Loader>\n      </Dimmer>\n    </Segment>\n  );\n};\n\nexport default Loading;\n"
  },
  {
    "path": "web/default/src/components/LoginForm.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport {\n  Button,\n  Divider,\n  Form,\n  Grid,\n  Header,\n  Image,\n  Message,\n  Modal,\n  Segment,\n  Card,\n} from 'semantic-ui-react';\nimport { Link, useNavigate, useSearchParams } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { UserContext } from '../context/User';\nimport { API, getLogo, showError, showSuccess, showWarning } from '../helpers';\nimport { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils';\nimport larkIcon from '../images/lark.svg';\n\nconst LoginForm = () => {\n  const { t } = useTranslation();\n  const [inputs, setInputs] = useState({\n    username: '',\n    password: '',\n    wechat_verification_code: '',\n  });\n  const [searchParams, setSearchParams] = useSearchParams();\n  const [submitted, setSubmitted] = useState(false);\n  const { username, password } = inputs;\n  const [userState, userDispatch] = useContext(UserContext);\n  let navigate = useNavigate();\n  const [status, setStatus] = useState({});\n  const logo = getLogo();\n\n  useEffect(() => {\n    if (searchParams.get('expired')) {\n      showError(t('messages.error.login_expired'));\n    }\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      setStatus(status);\n    }\n  }, []);\n\n  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);\n\n  const onWeChatLoginClicked = () => {\n    setShowWeChatLoginModal(true);\n  };\n\n  const onSubmitWeChatVerificationCode = async () => {\n    const res = await API.get(\n      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      userDispatch({ type: 'login', payload: data });\n      localStorage.setItem('user', JSON.stringify(data));\n      navigate('/');\n      showSuccess(t('messages.success.login'));\n      setShowWeChatLoginModal(false);\n    } else {\n      showError(message);\n    }\n  };\n\n  function handleChange(e) {\n    const { name, value } = e.target;\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  }\n\n  async function handleSubmit(e) {\n    setSubmitted(true);\n    if (username && password) {\n      const res = await API.post(`/api/user/login`, {\n        username,\n        password,\n      });\n      const { success, message, data } = res.data;\n      if (success) {\n        userDispatch({ type: 'login', payload: data });\n        localStorage.setItem('user', JSON.stringify(data));\n        if (username === 'root' && password === '123456') {\n          navigate('/user/edit');\n          showSuccess(t('messages.success.login'));\n          showWarning(t('messages.error.root_password'));\n        } else {\n          navigate('/token');\n          showSuccess(t('messages.success.login'));\n        }\n      } else {\n        showError(message);\n      }\n    }\n  }\n\n  return (\n    <Grid textAlign='center' style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Card\n          fluid\n          className='chart-card'\n          style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}\n        >\n          <Card.Content>\n            <Card.Header>\n              <Header\n                as='h2'\n                textAlign='center'\n                style={{ marginBottom: '1.5em' }}\n              >\n                <Image src={logo} style={{ marginBottom: '10px' }} />\n                <Header.Content>{t('auth.login.title')}</Header.Content>\n              </Header>\n            </Card.Header>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                icon='user'\n                iconPosition='left'\n                placeholder={t('auth.login.username')}\n                name='username'\n                value={username}\n                onChange={handleChange}\n                style={{ marginBottom: '1em' }}\n              />\n              <Form.Input\n                fluid\n                icon='lock'\n                iconPosition='left'\n                placeholder={t('auth.login.password')}\n                name='password'\n                type='password'\n                value={password}\n                onChange={handleChange}\n                style={{ marginBottom: '1.5em' }}\n              />\n              <Button\n                fluid\n                size='large'\n                style={{\n                  background: '#2F73FF', // 使用更现代的蓝色\n                  color: 'white',\n                  marginBottom: '1.5em',\n                }}\n                onClick={handleSubmit}\n              >\n                {t('auth.login.button')}\n              </Button>\n            </Form>\n\n            <Divider />\n            <Message style={{ background: 'transparent', boxShadow: 'none' }}>\n              <div\n                style={{\n                  display: 'flex',\n                  justifyContent: 'space-between',\n                  fontSize: '0.9em',\n                  color: '#666',\n                }}\n              >\n                <div>\n                  {t('auth.login.forgot_password')}\n                  <Link\n                    to='/reset'\n                    style={{ color: '#2185d0', marginLeft: '2px' }}\n                  >\n                    {t('auth.login.reset_password')}\n                  </Link>\n                </div>\n                <div>\n                  {t('auth.login.no_account')}\n                  <Link\n                    to='/register'\n                    style={{ color: '#2185d0', marginLeft: '2px' }}\n                  >\n                    {t('auth.login.register')}\n                  </Link>\n                </div>\n              </div>\n            </Message>\n\n            {(status.github_oauth ||\n              status.wechat_login ||\n              status.lark_client_id) && (\n              <>\n                <Divider\n                  horizontal\n                  style={{ color: '#666', fontSize: '0.9em' }}\n                >\n                  {t('auth.login.other_methods')}\n                </Divider>\n                <div\n                  style={{\n                    display: 'flex',\n                    justifyContent: 'center',\n                    gap: '1em',\n                    marginTop: '1em',\n                  }}\n                >\n                  {status.github_oauth && (\n                    <Button\n                      circular\n                      color='black'\n                      icon='github'\n                      onClick={() =>\n                        onGitHubOAuthClicked(status.github_client_id)\n                      }\n                    />\n                  )}\n                  {status.wechat_login && (\n                    <Button\n                      circular\n                      color='green'\n                      icon='wechat'\n                      onClick={onWeChatLoginClicked}\n                    />\n                  )}\n                  {status.lark_client_id && (\n                    <div\n                      style={{\n                        background:\n                          'radial-gradient(circle, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF)',\n                        width: '36px',\n                        height: '36px',\n                        borderRadius: '10em',\n                        display: 'flex',\n                        cursor: 'pointer',\n                      }}\n                      onClick={() => onLarkOAuthClicked(status.lark_client_id)}\n                    >\n                      <Image\n                        src={larkIcon}\n                        avatar\n                        style={{\n                          width: '36px',\n                          height: '36px',\n                          cursor: 'pointer',\n                          margin: 'auto',\n                        }}\n                      />\n                    </div>\n                  )}\n                </div>\n              </>\n            )}\n          </Card.Content>\n        </Card>\n        <Modal\n          onClose={() => setShowWeChatLoginModal(false)}\n          onOpen={() => setShowWeChatLoginModal(true)}\n          open={showWeChatLoginModal}\n          size={'mini'}\n        >\n          <Modal.Content>\n            <Modal.Description>\n              <Image src={status.wechat_qrcode} fluid />\n              <div style={{ textAlign: 'center' }}>\n                <p>{t('auth.login.wechat.scan_tip')}</p>\n              </div>\n              <Form size='large'>\n                <Form.Input\n                  fluid\n                  placeholder={t('auth.login.wechat.code_placeholder')}\n                  name='wechat_verification_code'\n                  value={inputs.wechat_verification_code}\n                  onChange={handleChange}\n                />\n                <Button\n                  fluid\n                  size='large'\n                  style={{\n                    background: '#2F73FF',\n                    color: 'white',\n                    marginBottom: '1.5em',\n                  }}\n                  onClick={onSubmitWeChatVerificationCode}\n                >\n                  {t('auth.login.button')}\n                </Button>\n              </Form>\n            </Modal.Description>\n          </Modal.Content>\n        </Modal>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default LoginForm;\n"
  },
  {
    "path": "web/default/src/components/LogsTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport {\n  Button,\n  Form,\n  Header,\n  Label,\n  Pagination,\n  Segment,\n  Select,\n  Table,\n  Popup,\n} from 'semantic-ui-react';\nimport {\n  API,\n  copy,\n  isAdmin,\n  showError,\n  showSuccess,\n  showWarning,\n  timestamp2string,\n} from '../helpers';\nimport { useTranslation } from 'react-i18next';\n\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderColorLabel, renderQuota } from '../helpers/render';\nimport { Link } from 'react-router-dom';\n\nfunction renderTimestamp(timestamp, request_id) {\n  return (\n    <code\n      onClick={async () => {\n        if (await copy(request_id)) {\n          showSuccess(`已复制请求 ID：${request_id}`);\n        } else {\n          showWarning(`请求 ID 复制失败：${request_id}`);\n        }\n      }}\n      style={{ cursor: 'pointer' }}\n    >\n      {timestamp2string(timestamp)}\n    </code>\n  );\n}\n\nconst MODE_OPTIONS = [\n  { key: 'all', text: '全部用户', value: 'all' },\n  { key: 'self', text: '当前用户', value: 'self' },\n];\n\nfunction renderType(type) {\n  switch (type) {\n    case 1:\n      return (\n        <Label basic color='green'>\n          充值\n        </Label>\n      );\n    case 2:\n      return (\n        <Label basic color='olive'>\n          消费\n        </Label>\n      );\n    case 3:\n      return (\n        <Label basic color='orange'>\n          管理\n        </Label>\n      );\n    case 4:\n      return (\n        <Label basic color='purple'>\n          系统\n        </Label>\n      );\n    case 5:\n      return (\n        <Label basic color='violet'>\n          测试\n        </Label>\n      );\n    default:\n      return (\n        <Label basic color='black'>\n          未知\n        </Label>\n      );\n  }\n}\n\nfunction getColorByElapsedTime(elapsedTime) {\n  if (elapsedTime === undefined || 0) return 'black';\n  if (elapsedTime < 1000) return 'green';\n  if (elapsedTime < 3000) return 'olive';\n  if (elapsedTime < 5000) return 'yellow';\n  if (elapsedTime < 10000) return 'orange';\n  return 'red';\n}\n\nfunction renderDetail(log) {\n  return (\n    <>\n      {log.content}\n      <br />\n      {log.elapsed_time && (\n        <Label\n          basic\n          size={'mini'}\n          color={getColorByElapsedTime(log.elapsed_time)}\n        >\n          {log.elapsed_time} ms\n        </Label>\n      )}\n      {log.is_stream && (\n        <>\n          <Label size={'mini'} color='pink'>\n            Stream\n          </Label>\n        </>\n      )}\n      {log.system_prompt_reset && (\n        <>\n          <Label basic size={'mini'} color='red'>\n            System Prompt Reset\n          </Label>\n        </>\n      )}\n    </>\n  );\n}\n\nconst LogsTable = () => {\n  const { t } = useTranslation();\n  const [logs, setLogs] = useState([]);\n  const [showStat, setShowStat] = useState(false);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [logType, setLogType] = useState(0);\n  const isAdminUser = isAdmin();\n  let now = new Date();\n  const [inputs, setInputs] = useState({\n    username: '',\n    token_name: '',\n    model_name: '',\n    start_timestamp: timestamp2string(0),\n    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),\n    channel: '',\n  });\n  const {\n    username,\n    token_name,\n    model_name,\n    start_timestamp,\n    end_timestamp,\n    channel,\n  } = inputs;\n\n  const [stat, setStat] = useState({\n    quota: 0,\n    token: 0,\n  });\n\n  const LOG_OPTIONS = [\n    { key: '0', text: t('log.type.all'), value: 0 },\n    { key: '1', text: t('log.type.topup'), value: 1 },\n    { key: '2', text: t('log.type.usage'), value: 2 },\n    { key: '3', text: t('log.type.admin'), value: 3 },\n    { key: '4', text: t('log.type.system'), value: 4 },\n    { key: '5', text: t('log.type.test'), value: 5 },\n  ];\n\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const getLogSelfStat = async () => {\n    let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n    let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n    let res = await API.get(\n      `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      setStat(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const getLogStat = async () => {\n    let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n    let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n    let res = await API.get(\n      `/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      setStat(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  const handleEyeClick = async () => {\n    if (!showStat) {\n      if (isAdminUser) {\n        await getLogStat();\n      } else {\n        await getLogSelfStat();\n      }\n    }\n    setShowStat(!showStat);\n  };\n\n  const showUserTokenQuota = () => {\n    return logType !== 5;\n  };\n\n  const loadLogs = async (startIdx) => {\n    let url = '';\n    let localStartTimestamp = Date.parse(start_timestamp) / 1000;\n    let localEndTimestamp = Date.parse(end_timestamp) / 1000;\n    if (isAdminUser) {\n      url = `/api/log/?p=${startIdx}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`;\n    } else {\n      url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;\n    }\n    const res = await API.get(url);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setLogs(data);\n      } else {\n        let newLogs = [...logs];\n        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setLogs(newLogs);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadLogs(activePage - 1);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const refresh = async () => {\n    setLoading(true);\n    setActivePage(1);\n    await loadLogs(0);\n  };\n\n  useEffect(() => {\n    refresh().then();\n  }, [logType]);\n\n  const searchLogs = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadLogs(0);\n      setActivePage(1);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setLogs(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (e, { value }) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortLog = (key) => {\n    if (logs.length === 0) return;\n    setLoading(true);\n    let sortedLogs = [...logs];\n    if (typeof sortedLogs[0][key] === 'string') {\n      sortedLogs.sort((a, b) => {\n        return ('' + a[key]).localeCompare(b[key]);\n      });\n    } else {\n      sortedLogs.sort((a, b) => {\n        if (a[key] === b[key]) return 0;\n        if (a[key] > b[key]) return -1;\n        if (a[key] < b[key]) return 1;\n      });\n    }\n    if (sortedLogs[0].id === logs[0].id) {\n      sortedLogs.reverse();\n    }\n    setLogs(sortedLogs);\n    setLoading(false);\n  };\n\n  return (\n    <>\n      <Header as='h3'>\n        {t('log.usage_details')}（{t('log.total_quota')}：\n        {showStat && renderQuota(stat.quota, t)}\n        {!showStat && (\n          <span\n            onClick={handleEyeClick}\n            style={{ cursor: 'pointer', color: 'gray' }}\n          >\n            {t('log.click_to_view')}\n          </span>\n        )}\n        ）\n      </Header>\n      <Form>\n        <Form.Group>\n          <Form.Input\n            fluid\n            label={t('log.table.token_name')}\n            size={'small'}\n            width={3}\n            value={token_name}\n            placeholder={t('log.table.token_name_placeholder')}\n            name='token_name'\n            onChange={handleInputChange}\n          />\n          <Form.Input\n            fluid\n            label={t('log.table.model_name')}\n            size={'small'}\n            width={3}\n            value={model_name}\n            placeholder={t('log.table.model_name_placeholder')}\n            name='model_name'\n            onChange={handleInputChange}\n          />\n          <Form.Input\n            fluid\n            label={t('log.table.start_time')}\n            size={'small'}\n            width={4}\n            value={start_timestamp}\n            type='datetime-local'\n            name='start_timestamp'\n            onChange={handleInputChange}\n          />\n          <Form.Input\n            fluid\n            label={t('log.table.end_time')}\n            size={'small'}\n            width={4}\n            value={end_timestamp}\n            type='datetime-local'\n            name='end_timestamp'\n            onChange={handleInputChange}\n          />\n          <Form.Button\n            fluid\n            label={t('log.buttons.query')}\n            size={'small'}\n            width={2}\n            onClick={refresh}\n          >\n            {t('log.buttons.submit')}\n          </Form.Button>\n        </Form.Group>\n        {isAdminUser && (\n          <>\n            <Form.Group>\n              <Form.Input\n                fluid\n                label={t('log.table.channel_id')}\n                size={'small'}\n                width={3}\n                value={channel}\n                placeholder={t('log.table.channel_id_placeholder')}\n                name='channel'\n                onChange={handleInputChange}\n              />\n              <Form.Input\n                fluid\n                label={t('log.table.username')}\n                size={'small'}\n                width={3}\n                value={username}\n                placeholder={t('log.table.username_placeholder')}\n                name='username'\n                onChange={handleInputChange}\n              />\n            </Form.Group>\n          </>\n        )}\n        <Form.Input\n          icon='search'\n          placeholder={t('log.search')}\n          value={searchKeyword}\n          onChange={(e, { value }) => setSearchKeyword(value)}\n        />\n      </Form>\n      <Table basic={'very'} compact size='small'>\n        <Table.Header>\n          <Table.Row>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortLog('created_time');\n              }}\n              width={3}\n            >\n              {t('log.table.time')}\n            </Table.HeaderCell>\n            {isAdminUser && (\n              <Table.HeaderCell\n                style={{ cursor: 'pointer' }}\n                onClick={() => {\n                  sortLog('channel');\n                }}\n                width={1}\n              >\n                {t('log.table.channel')}\n              </Table.HeaderCell>\n            )}\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortLog('type');\n              }}\n              width={1}\n            >\n              {t('log.table.type')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortLog('model_name');\n              }}\n              width={2}\n            >\n              {t('log.table.model')}\n            </Table.HeaderCell>\n            {showUserTokenQuota() && (\n              <>\n                {isAdminUser && (\n                  <Table.HeaderCell\n                    style={{ cursor: 'pointer' }}\n                    onClick={() => {\n                      sortLog('username');\n                    }}\n                    width={2}\n                  >\n                    {t('log.table.username')}\n                  </Table.HeaderCell>\n                )}\n                <Table.HeaderCell\n                  style={{ cursor: 'pointer' }}\n                  onClick={() => {\n                    sortLog('token_name');\n                  }}\n                  width={2}\n                >\n                  {t('log.table.token_name')}\n                </Table.HeaderCell>\n                <Table.HeaderCell\n                  style={{ cursor: 'pointer' }}\n                  onClick={() => {\n                    sortLog('prompt_tokens');\n                  }}\n                  width={1}\n                >\n                  {t('log.table.prompt_tokens')}\n                </Table.HeaderCell>\n                <Table.HeaderCell\n                  style={{ cursor: 'pointer' }}\n                  onClick={() => {\n                    sortLog('completion_tokens');\n                  }}\n                  width={1}\n                >\n                  {t('log.table.completion_tokens')}\n                </Table.HeaderCell>\n                <Table.HeaderCell\n                  style={{ cursor: 'pointer' }}\n                  onClick={() => {\n                    sortLog('quota');\n                  }}\n                  width={1}\n                >\n                  {t('log.table.quota')}\n                </Table.HeaderCell>\n              </>\n            )}\n            <Table.HeaderCell>{t('log.table.detail')}</Table.HeaderCell>\n          </Table.Row>\n        </Table.Header>\n\n        <Table.Body>\n          {logs\n            .slice(\n              (activePage - 1) * ITEMS_PER_PAGE,\n              activePage * ITEMS_PER_PAGE\n            )\n            .map((log, idx) => {\n              if (log.deleted) return <></>;\n              return (\n                <Table.Row key={log.id}>\n                  <Table.Cell>\n                    {renderTimestamp(log.created_at, log.request_id)}\n                  </Table.Cell>\n                  {isAdminUser && (\n                    <Table.Cell>\n                      {log.channel ? (\n                        <Label\n                          basic\n                          as={Link}\n                          to={`/channel/edit/${log.channel}`}\n                        >\n                          {log.channel}\n                        </Label>\n                      ) : (\n                        ''\n                      )}\n                    </Table.Cell>\n                  )}\n                  <Table.Cell>{renderType(log.type)}</Table.Cell>\n                  <Table.Cell>\n                    {log.model_name ? renderColorLabel(log.model_name) : ''}\n                  </Table.Cell>\n                  {showUserTokenQuota() && (\n                    <>\n                      {isAdminUser && (\n                        <Table.Cell>\n                          {log.username ? (\n                            <Label\n                              basic\n                              as={Link}\n                              to={`/user/edit/${log.user_id}`}\n                            >\n                              {log.username}\n                            </Label>\n                          ) : (\n                            ''\n                          )}\n                        </Table.Cell>\n                      )}\n                      <Table.Cell>\n                        {log.token_name ? renderColorLabel(log.token_name) : ''}\n                      </Table.Cell>\n\n                      <Table.Cell>\n                        {log.prompt_tokens ? log.prompt_tokens : ''}\n                      </Table.Cell>\n                      <Table.Cell>\n                        {log.completion_tokens ? log.completion_tokens : ''}\n                      </Table.Cell>\n                      <Table.Cell>\n                        {log.quota ? renderQuota(log.quota, t, 6) : ''}\n                      </Table.Cell>\n                    </>\n                  )}\n\n                  <Table.Cell>{renderDetail(log)}</Table.Cell>\n                </Table.Row>\n              );\n            })}\n        </Table.Body>\n\n        <Table.Footer>\n          <Table.Row>\n            <Table.HeaderCell colSpan={'10'}>\n              <Select\n                placeholder={t('log.type.select')}\n                options={LOG_OPTIONS}\n                style={{ marginRight: '8px' }}\n                name='logType'\n                value={logType}\n                onChange={(e, { name, value }) => {\n                  setLogType(value);\n                }}\n              />\n              <Button size='small' onClick={refresh} loading={loading}>\n                {t('log.buttons.refresh')}\n              </Button>\n              <Pagination\n                floated='right'\n                activePage={activePage}\n                onPageChange={onPaginationChange}\n                size='small'\n                siblingRange={1}\n                totalPages={\n                  Math.ceil(logs.length / ITEMS_PER_PAGE) +\n                  (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)\n                }\n              />\n            </Table.HeaderCell>\n          </Table.Row>\n        </Table.Footer>\n      </Table>\n    </>\n  );\n};\n\nexport default LogsTable;\n"
  },
  {
    "path": "web/default/src/components/OperationSetting.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Divider, Form, Grid, Header } from 'semantic-ui-react';\nimport {\n  API,\n  showError,\n  showSuccess,\n  timestamp2string,\n  verifyJSON,\n} from '../helpers';\n\nconst OperationSetting = () => {\n  const { t } = useTranslation();\n  let now = new Date();\n  let [inputs, setInputs] = useState({\n    QuotaForNewUser: 0,\n    QuotaForInviter: 0,\n    QuotaForInvitee: 0,\n    QuotaRemindThreshold: 0,\n    PreConsumedQuota: 0,\n    ModelRatio: '',\n    CompletionRatio: '',\n    GroupRatio: '',\n    TopUpLink: '',\n    ChatLink: '',\n    QuotaPerUnit: 0,\n    AutomaticDisableChannelEnabled: '',\n    AutomaticEnableChannelEnabled: '',\n    ChannelDisableThreshold: 0,\n    LogConsumeEnabled: '',\n    DisplayInCurrencyEnabled: '',\n    DisplayTokenStatEnabled: '',\n    ApproximateTokenEnabled: '',\n    RetryTimes: 0,\n  });\n  const [originInputs, setOriginInputs] = useState({});\n  let [loading, setLoading] = useState(false);\n  let [historyTimestamp, setHistoryTimestamp] = useState(\n    timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)\n  ); // a month ago\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        if (\n          item.key === 'ModelRatio' ||\n          item.key === 'GroupRatio' ||\n          item.key === 'CompletionRatio'\n        ) {\n          item.value = JSON.stringify(JSON.parse(item.value), null, 2);\n        }\n        if (item.value === '{}') {\n          item.value = '';\n        }\n        newInputs[item.key] = item.value;\n      });\n      setInputs(newInputs);\n      setOriginInputs(newInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    if (key.endsWith('Enabled')) {\n      value = inputs[key] === 'true' ? 'false' : 'true';\n    }\n    const res = await API.put('/api/option/', {\n      key,\n      value,\n    });\n    const { success, message } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, [key]: value }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (e, { name, value }) => {\n    if (name.endsWith('Enabled')) {\n      await updateOption(name, value);\n    } else {\n      setInputs((inputs) => ({ ...inputs, [name]: value }));\n    }\n  };\n\n  const submitConfig = async (group) => {\n    switch (group) {\n      case 'monitor':\n        if (\n          originInputs['ChannelDisableThreshold'] !==\n          inputs.ChannelDisableThreshold\n        ) {\n          await updateOption(\n            'ChannelDisableThreshold',\n            inputs.ChannelDisableThreshold\n          );\n        }\n        if (\n          originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold\n        ) {\n          await updateOption(\n            'QuotaRemindThreshold',\n            inputs.QuotaRemindThreshold\n          );\n        }\n        break;\n      case 'ratio':\n        if (originInputs['ModelRatio'] !== inputs.ModelRatio) {\n          if (!verifyJSON(inputs.ModelRatio)) {\n            showError('模型倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('ModelRatio', inputs.ModelRatio);\n        }\n        if (originInputs['GroupRatio'] !== inputs.GroupRatio) {\n          if (!verifyJSON(inputs.GroupRatio)) {\n            showError('分组倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('GroupRatio', inputs.GroupRatio);\n        }\n        if (originInputs['CompletionRatio'] !== inputs.CompletionRatio) {\n          if (!verifyJSON(inputs.CompletionRatio)) {\n            showError('补全倍率不是合法的 JSON 字符串');\n            return;\n          }\n          await updateOption('CompletionRatio', inputs.CompletionRatio);\n        }\n        break;\n      case 'quota':\n        if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {\n          await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);\n        }\n        if (originInputs['QuotaForInvitee'] !== inputs.QuotaForInvitee) {\n          await updateOption('QuotaForInvitee', inputs.QuotaForInvitee);\n        }\n        if (originInputs['QuotaForInviter'] !== inputs.QuotaForInviter) {\n          await updateOption('QuotaForInviter', inputs.QuotaForInviter);\n        }\n        if (originInputs['PreConsumedQuota'] !== inputs.PreConsumedQuota) {\n          await updateOption('PreConsumedQuota', inputs.PreConsumedQuota);\n        }\n        break;\n      case 'general':\n        if (originInputs['TopUpLink'] !== inputs.TopUpLink) {\n          await updateOption('TopUpLink', inputs.TopUpLink);\n        }\n        if (originInputs['ChatLink'] !== inputs.ChatLink) {\n          await updateOption('ChatLink', inputs.ChatLink);\n        }\n        if (originInputs['QuotaPerUnit'] !== inputs.QuotaPerUnit) {\n          await updateOption('QuotaPerUnit', inputs.QuotaPerUnit);\n        }\n        if (originInputs['RetryTimes'] !== inputs.RetryTimes) {\n          await updateOption('RetryTimes', inputs.RetryTimes);\n        }\n        break;\n    }\n  };\n\n  const deleteHistoryLogs = async () => {\n    console.log(inputs);\n    const res = await API.delete(\n      `/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      showSuccess(`${data} 条日志已清理！`);\n      return;\n    }\n    showError('日志清理失败：' + message);\n  };\n\n  return (\n    <Grid columns={1}>\n      <Grid.Column>\n        <Form loading={loading}>\n          <Header as='h3'>{t('setting.operation.quota.title')}</Header>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={t('setting.operation.quota.new_user')}\n              name='QuotaForNewUser'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaForNewUser}\n              type='number'\n              min='0'\n              placeholder={t('setting.operation.quota.new_user_placeholder')}\n            />\n            <Form.Input\n              label={t('setting.operation.quota.pre_consume')}\n              name='PreConsumedQuota'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.PreConsumedQuota}\n              type='number'\n              min='0'\n              placeholder={t('setting.operation.quota.pre_consume_placeholder')}\n            />\n            <Form.Input\n              label={t('setting.operation.quota.inviter_reward')}\n              name='QuotaForInviter'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaForInviter}\n              type='number'\n              min='0'\n              placeholder={t(\n                'setting.operation.quota.inviter_reward_placeholder'\n              )}\n            />\n            <Form.Input\n              label={t('setting.operation.quota.invitee_reward')}\n              name='QuotaForInvitee'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaForInvitee}\n              type='number'\n              min='0'\n              placeholder={t(\n                'setting.operation.quota.invitee_reward_placeholder'\n              )}\n            />\n          </Form.Group>\n          <Form.Button\n            onClick={() => {\n              submitConfig('quota').then();\n            }}\n          >\n            {t('setting.operation.quota.buttons.save')}\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>{t('setting.operation.ratio.title')}</Header>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label={t('setting.operation.ratio.model.title')}\n              name='ModelRatio'\n              onChange={handleInputChange}\n              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}\n              autoComplete='new-password'\n              value={inputs.ModelRatio}\n              placeholder={t('setting.operation.ratio.model.placeholder')}\n            />\n          </Form.Group>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label={t('setting.operation.ratio.completion.title')}\n              name='CompletionRatio'\n              onChange={handleInputChange}\n              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}\n              autoComplete='new-password'\n              value={inputs.CompletionRatio}\n              placeholder={t('setting.operation.ratio.completion.placeholder')}\n            />\n          </Form.Group>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label={t('setting.operation.ratio.group.title')}\n              name='GroupRatio'\n              onChange={handleInputChange}\n              style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}\n              autoComplete='new-password'\n              value={inputs.GroupRatio}\n              placeholder={t('setting.operation.ratio.group.placeholder')}\n            />\n          </Form.Group>\n          <Form.Button\n            onClick={() => {\n              submitConfig('ratio').then();\n            }}\n          >\n            {t('setting.operation.ratio.buttons.save')}\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>{t('setting.operation.log.title')}</Header>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.LogConsumeEnabled === 'true'}\n              label={t('setting.operation.log.enable_consume')}\n              name='LogConsumeEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Group widths={4}>\n            <Form.Input\n              label={t('setting.operation.log.target_time')}\n              value={historyTimestamp}\n              type='datetime-local'\n              name='history_timestamp'\n              onChange={(e, { name, value }) => {\n                setHistoryTimestamp(value);\n              }}\n            />\n          </Form.Group>\n          <Form.Button\n            onClick={() => {\n              deleteHistoryLogs().then();\n            }}\n          >\n            {t('setting.operation.log.buttons.clean')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>{t('setting.operation.monitor.title')}</Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.operation.monitor.max_response_time')}\n              name='ChannelDisableThreshold'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.ChannelDisableThreshold}\n              type='number'\n              min='0'\n              placeholder={t(\n                'setting.operation.monitor.max_response_time_placeholder'\n              )}\n            />\n            <Form.Input\n              label={t('setting.operation.monitor.quota_reminder')}\n              name='QuotaRemindThreshold'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaRemindThreshold}\n              type='number'\n              min='0'\n              placeholder={t(\n                'setting.operation.monitor.quota_reminder_placeholder'\n              )}\n            />\n          </Form.Group>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.AutomaticDisableChannelEnabled === 'true'}\n              label={t('setting.operation.monitor.auto_disable')}\n              name='AutomaticDisableChannelEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.AutomaticEnableChannelEnabled === 'true'}\n              label={t('setting.operation.monitor.auto_enable')}\n              name='AutomaticEnableChannelEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button\n            onClick={() => {\n              submitConfig('monitor').then();\n            }}\n          >\n            {t('setting.operation.monitor.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>{t('setting.operation.general.title')}</Header>\n          <Form.Group widths={4}>\n            <Form.Input\n              label={t('setting.operation.general.topup_link')}\n              name='TopUpLink'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.TopUpLink}\n              type='link'\n              placeholder={t(\n                'setting.operation.general.topup_link_placeholder'\n              )}\n            />\n            <Form.Input\n              label={t('setting.operation.general.chat_link')}\n              name='ChatLink'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.ChatLink}\n              type='link'\n              placeholder={t('setting.operation.general.chat_link_placeholder')}\n            />\n            <Form.Input\n              label={t('setting.operation.general.quota_per_unit')}\n              name='QuotaPerUnit'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.QuotaPerUnit}\n              type='number'\n              step='0.01'\n              placeholder={t(\n                'setting.operation.general.quota_per_unit_placeholder'\n              )}\n            />\n            <Form.Input\n              label={t('setting.operation.general.retry_times')}\n              name='RetryTimes'\n              type={'number'}\n              step='1'\n              min='0'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.RetryTimes}\n              placeholder={t(\n                'setting.operation.general.retry_times_placeholder'\n              )}\n            />\n          </Form.Group>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.DisplayInCurrencyEnabled === 'true'}\n              label={t('setting.operation.general.display_in_currency')}\n              name='DisplayInCurrencyEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.DisplayTokenStatEnabled === 'true'}\n              label={t('setting.operation.general.display_token_stat')}\n              name='DisplayTokenStatEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.ApproximateTokenEnabled === 'true'}\n              label={t('setting.operation.general.approximate_token')}\n              name='ApproximateTokenEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button\n            onClick={() => {\n              submitConfig('general').then();\n            }}\n          >\n            {t('setting.operation.general.buttons.save')}\n          </Form.Button>\n        </Form>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default OperationSetting;\n"
  },
  {
    "path": "web/default/src/components/OtherSetting.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Button,\n  Divider,\n  Form,\n  Grid,\n  Header,\n  Message,\n  Modal,\n} from 'semantic-ui-react';\nimport { Link } from 'react-router-dom';\nimport { API, showError, showSuccess, verifyJSON } from '../helpers';\nimport { marked } from 'marked';\n\nconst OtherSetting = () => {\n  const { t } = useTranslation();\n  let [inputs, setInputs] = useState({\n    Footer: '',\n    Notice: '',\n    About: '',\n    SystemName: '',\n    Logo: '',\n    HomePageContent: '',\n    Theme: '',\n  });\n  let [loading, setLoading] = useState(false);\n  const [showUpdateModal, setShowUpdateModal] = useState(false);\n  const [updateData, setUpdateData] = useState({\n    tag_name: '',\n    content: '',\n  });\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        if (item.key in inputs) {\n          newInputs[item.key] = item.value;\n        }\n      });\n      setInputs(newInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    const res = await API.put('/api/option/', {\n      key,\n      value,\n    });\n    const { success, message } = res.data;\n    if (success) {\n      setInputs((inputs) => ({ ...inputs, [key]: value }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const submitNotice = async () => {\n    await updateOption('Notice', inputs.Notice);\n  };\n\n  const submitSystemName = async () => {\n    await updateOption('SystemName', inputs.SystemName);\n  };\n\n  const submitTheme = async () => {\n    await updateOption('Theme', inputs.Theme);\n  };\n\n  const submitLogo = async () => {\n    await updateOption('Logo', inputs.Logo);\n  };\n\n  const submitAbout = async () => {\n    await updateOption('About', inputs.About);\n  };\n\n  const submitOption = async (key) => {\n    await updateOption(key, inputs[key]);\n  };\n\n  const openGitHubRelease = () => {\n    window.location = 'https://github.com/songquanpeng/one-api/releases/latest';\n  };\n\n  const checkUpdate = async () => {\n    const res = await API.get(\n      'https://api.github.com/repos/songquanpeng/one-api/releases/latest'\n    );\n    const { tag_name, body } = res.data;\n    if (tag_name === process.env.REACT_APP_VERSION) {\n      showSuccess(`已是最新版本：${tag_name}`);\n    } else {\n      setUpdateData({\n        tag_name: tag_name,\n        content: marked.parse(body),\n      });\n      setShowUpdateModal(true);\n    }\n  };\n\n  return (\n    <Grid columns={1}>\n      <Grid.Column>\n        <Form loading={loading}>\n          <Header as='h3'>{t('setting.other.notice.title')}</Header>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label={t('setting.other.notice.content')}\n              placeholder={t('setting.other.notice.content_placeholder')}\n              value={inputs.Notice}\n              name='Notice'\n              onChange={handleInputChange}\n              style={{ minHeight: 100, fontFamily: 'JetBrains Mono, Consolas' }}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitNotice}>\n            {t('setting.other.notice.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>{t('setting.other.system.title')}</Header>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={t('setting.other.system.name')}\n              placeholder={t('setting.other.system.name_placeholder')}\n              value={inputs.SystemName}\n              name='SystemName'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitSystemName}>\n            {t('setting.other.system.buttons.save_name')}\n          </Form.Button>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={\n                <label>\n                  {t('setting.other.system.theme.title')}（\n                  <Link to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>\n                    {t('setting.other.system.theme.link')}\n                  </Link>\n                  ）\n                </label>\n              }\n              placeholder={t('setting.other.system.theme.placeholder')}\n              value={inputs.Theme}\n              name='Theme'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitTheme}>\n            {t('setting.other.system.buttons.save_theme')}\n          </Form.Button>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={t('setting.other.system.logo')}\n              placeholder={t('setting.other.system.logo_placeholder')}\n              value={inputs.Logo}\n              name='Logo'\n              type='url'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitLogo}>\n            {t('setting.other.system.buttons.save_logo')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>{t('setting.other.content.title')}</Header>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label={t('setting.other.content.homepage.title')}\n              placeholder={t('setting.other.content.homepage.placeholder')}\n              value={inputs.HomePageContent}\n              name='HomePageContent'\n              onChange={handleInputChange}\n              style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}\n            />\n          </Form.Group>\n          <Form.Button onClick={() => submitOption('HomePageContent')}>\n            {t('setting.other.content.buttons.save_homepage')}\n          </Form.Button>\n          <Form.Group widths='equal'>\n            <Form.TextArea\n              label={t('setting.other.content.about.title')}\n              placeholder={t('setting.other.content.about.placeholder')}\n              value={inputs.About}\n              name='About'\n              onChange={handleInputChange}\n              style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitAbout}>\n            {t('setting.other.content.buttons.save_about')}\n          </Form.Button>\n          <Message>{t('setting.other.copyright.notice')}</Message>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={t('setting.other.content.footer.title')}\n              placeholder={t('setting.other.content.footer.placeholder')}\n              value={inputs.Footer}\n              name='Footer'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={() => submitOption('Footer')}>\n            {t('setting.other.content.buttons.save_footer')}\n          </Form.Button>\n        </Form>\n      </Grid.Column>\n      <Modal\n        onClose={() => setShowUpdateModal(false)}\n        onOpen={() => setShowUpdateModal(true)}\n        open={showUpdateModal}\n      >\n        <Modal.Header>新版本：{updateData.tag_name}</Modal.Header>\n        <Modal.Content>\n          <Modal.Description>\n            <div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>\n          </Modal.Description>\n        </Modal.Content>\n        <Modal.Actions>\n          <Button onClick={() => setShowUpdateModal(false)}>关闭</Button>\n          <Button\n            content='详情'\n            onClick={() => {\n              setShowUpdateModal(false);\n              openGitHubRelease();\n            }}\n          />\n        </Modal.Actions>\n      </Modal>\n    </Grid>\n  );\n};\n\nexport default OtherSetting;\n"
  },
  {
    "path": "web/default/src/components/PasswordResetConfirm.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport {\n  Button,\n  Form,\n  Grid,\n  Header,\n  Image,\n  Card,\n  Message,\n} from 'semantic-ui-react';\nimport { useTranslation } from 'react-i18next';\nimport { API, copy, getLogo, showError, showNotice } from '../helpers';\nimport { useSearchParams } from 'react-router-dom';\n\nconst PasswordResetConfirm = () => {\n  const { t } = useTranslation();\n  const [inputs, setInputs] = useState({\n    email: '',\n    token: '',\n  });\n  const { email, token } = inputs;\n  const [loading, setLoading] = useState(false);\n  const [disableButton, setDisableButton] = useState(false);\n  const [newPassword, setNewPassword] = useState('');\n  const logo = getLogo();\n\n  const [countdown, setCountdown] = useState(30);\n\n  const [searchParams, setSearchParams] = useSearchParams();\n  useEffect(() => {\n    let token = searchParams.get('token');\n    let email = searchParams.get('email');\n    setInputs({\n      token,\n      email,\n    });\n  }, []);\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval);\n  }, [disableButton, countdown]);\n\n  async function handleSubmit(e) {\n    setDisableButton(true);\n    if (!email) return;\n    setLoading(true);\n    const res = await API.post(`/api/user/reset`, {\n      email,\n      token,\n    });\n    const { success, message } = res.data;\n    if (success) {\n      let password = res.data.data;\n      setNewPassword(password);\n      await copy(password);\n      showNotice(t('messages.notice.password_copied', { password }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  }\n\n  return (\n    <Grid textAlign='center' style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Card\n          fluid\n          className='chart-card'\n          style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}\n        >\n          <Card.Content>\n            <Card.Header>\n              <Header\n                as='h2'\n                textAlign='center'\n                style={{ marginBottom: '1.5em' }}\n              >\n                <Image src={logo} style={{ marginBottom: '10px' }} />\n                <Header.Content>{t('auth.reset.confirm.title')}</Header.Content>\n              </Header>\n            </Card.Header>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                icon='mail'\n                iconPosition='left'\n                placeholder={t('auth.reset.email')}\n                name='email'\n                value={email}\n                readOnly\n                style={{ marginBottom: '1em' }}\n              />\n              {newPassword && (\n                <Form.Input\n                  fluid\n                  icon='lock'\n                  iconPosition='left'\n                  placeholder={t('auth.reset.confirm.new_password')}\n                  name='newPassword'\n                  value={newPassword}\n                  readOnly\n                  style={{\n                    marginBottom: '1em',\n                    cursor: 'pointer',\n                    backgroundColor: '#f8f9fa',\n                  }}\n                  onClick={(e) => {\n                    e.target.select();\n                    navigator.clipboard.writeText(newPassword);\n                    showNotice(t('auth.reset.confirm.notice'));\n                  }}\n                />\n              )}\n              <Button\n                fluid\n                size='large'\n                onClick={handleSubmit}\n                loading={loading}\n                disabled={disableButton}\n                style={{\n                  background: '#2F73FF',\n                  color: 'white',\n                  marginBottom: '1.5em',\n                }}\n              >\n                {disableButton\n                  ? t('auth.reset.confirm.button_disabled')\n                  : t('auth.reset.confirm.button')}\n              </Button>\n            </Form>\n            {newPassword && (\n              <Message style={{ background: 'transparent', boxShadow: 'none' }}>\n                <p style={{ fontSize: '0.9em', color: '#666' }}>\n                  {t('auth.reset.confirm.notice')}\n                </p>\n              </Message>\n            )}\n          </Card.Content>\n        </Card>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default PasswordResetConfirm;\n"
  },
  {
    "path": "web/default/src/components/PasswordResetForm.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport {\n  Button,\n  Form,\n  Grid,\n  Header,\n  Image,\n  Card,\n  Message,\n} from 'semantic-ui-react';\nimport { useTranslation } from 'react-i18next';\nimport { API, getLogo, showError, showInfo, showSuccess } from '../helpers';\nimport Turnstile from 'react-turnstile';\n\nconst PasswordResetForm = () => {\n  const { t } = useTranslation();\n  const [inputs, setInputs] = useState({\n    email: '',\n  });\n  const { email } = inputs;\n  const [loading, setLoading] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n  const logo = getLogo();\n\n  useEffect(() => {\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n  }, []);\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval);\n  }, [disableButton, countdown]);\n\n  function handleChange(e) {\n    const { name, value } = e.target;\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  }\n\n  async function handleSubmit(e) {\n    setDisableButton(true);\n    if (!email) return;\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    setLoading(true);\n    const res = await API.get(\n      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('auth.reset.notice'));\n      setInputs({ ...inputs, email: '' });\n    } else {\n      showError(message);\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    setLoading(false);\n  }\n\n  return (\n    <Grid textAlign='center' style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Card\n          fluid\n          className='chart-card'\n          style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}\n        >\n          <Card.Content>\n            <Card.Header>\n              <Header\n                as='h2'\n                textAlign='center'\n                style={{ marginBottom: '1.5em' }}\n              >\n                <Image src={logo} style={{ marginBottom: '10px' }} />\n                <Header.Content>{t('auth.reset.title')}</Header.Content>\n              </Header>\n            </Card.Header>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                icon='mail'\n                iconPosition='left'\n                placeholder={t('auth.reset.email')}\n                name='email'\n                value={email}\n                onChange={handleChange}\n                style={{ marginBottom: '1em' }}\n              />\n              {turnstileEnabled && (\n                <div\n                  style={{\n                    marginBottom: '1em',\n                    display: 'flex',\n                    justifyContent: 'center',\n                  }}\n                >\n                  <Turnstile\n                    sitekey={turnstileSiteKey}\n                    onVerify={(token) => {\n                      setTurnstileToken(token);\n                    }}\n                  />\n                </div>\n              )}\n              <Button\n                color='blue'\n                fluid\n                size='large'\n                onClick={handleSubmit}\n                loading={loading}\n                disabled={disableButton}\n                style={{\n                  background: '#2F73FF', // 使用更现代的蓝色\n                  color: 'white',\n                  marginBottom: '1.5em',\n                }}\n              >\n                {disableButton\n                  ? t('auth.register.get_code_retry', { countdown })\n                  : t('auth.reset.button')}\n              </Button>\n            </Form>\n            <Message style={{ background: 'transparent', boxShadow: 'none' }}>\n              <p style={{ fontSize: '0.9em', color: '#666' }}>\n                {t('auth.reset.notice')}\n              </p>\n            </Message>\n          </Card.Content>\n        </Card>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default PasswordResetForm;\n"
  },
  {
    "path": "web/default/src/components/PersonalSetting.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Button,\n  Divider,\n  Form,\n  Header,\n  Image,\n  Message,\n  Modal,\n} from 'semantic-ui-react';\nimport { Link, useNavigate } from 'react-router-dom';\nimport {\n  API,\n  copy,\n  showError,\n  showInfo,\n  showNotice,\n  showSuccess,\n} from '../helpers';\nimport Turnstile from 'react-turnstile';\nimport { UserContext } from '../context/User';\nimport { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils';\n\nconst PersonalSetting = () => {\n  const { t } = useTranslation();\n  const [userState, userDispatch] = useContext(UserContext);\n  let navigate = useNavigate();\n\n  const [inputs, setInputs] = useState({\n    wechat_verification_code: '',\n    email_verification_code: '',\n    email: '',\n    self_account_deletion_confirmation: '',\n  });\n  const [status, setStatus] = useState({});\n  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);\n  const [showEmailBindModal, setShowEmailBindModal] = useState(false);\n  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [loading, setLoading] = useState(false);\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n  const [affLink, setAffLink] = useState('');\n  const [systemToken, setSystemToken] = useState('');\n\n  useEffect(() => {\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      setStatus(status);\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n  }, []);\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval); // Clean up on unmount\n  }, [disableButton, countdown]);\n\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const generateAccessToken = async () => {\n    const res = await API.get('/api/user/token');\n    const { success, message, data } = res.data;\n    if (success) {\n      setSystemToken(data);\n      setAffLink('');\n      await copy(data);\n      showSuccess(`令牌已重置并已复制到剪贴板`);\n    } else {\n      showError(message);\n    }\n  };\n\n  const getAffLink = async () => {\n    const res = await API.get('/api/user/aff');\n    const { success, message, data } = res.data;\n    if (success) {\n      let link = `${window.location.origin}/register?aff=${data}`;\n      setAffLink(link);\n      setSystemToken('');\n      await copy(link);\n      showSuccess(`邀请链接已复制到剪切板`);\n    } else {\n      showError(message);\n    }\n  };\n\n  const handleAffLinkClick = async (e) => {\n    e.target.select();\n    await copy(e.target.value);\n    showSuccess(`邀请链接已复制到剪切板`);\n  };\n\n  const handleSystemTokenClick = async (e) => {\n    e.target.select();\n    await copy(e.target.value);\n    showSuccess(`系统令牌已复制到剪切板`);\n  };\n\n  const deleteAccount = async () => {\n    if (inputs.self_account_deletion_confirmation !== userState.user.username) {\n      showError('请输入你的账户名以确认删除！');\n      return;\n    }\n\n    const res = await API.delete('/api/user/self');\n    const { success, message } = res.data;\n\n    if (success) {\n      showSuccess('账户已删除！');\n      await API.get('/api/user/logout');\n      userDispatch({ type: 'logout' });\n      localStorage.removeItem('user');\n      navigate('/login');\n    } else {\n      showError(message);\n    }\n  };\n\n  const bindWeChat = async () => {\n    if (inputs.wechat_verification_code === '') return;\n    const res = await API.get(\n      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('微信账户绑定成功！');\n      setShowWeChatBindModal(false);\n    } else {\n      showError(message);\n    }\n  };\n\n  const sendVerificationCode = async () => {\n    setDisableButton(true);\n    if (inputs.email === '') return;\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo('请稍后几秒重试，Turnstile 正在检查用户环境！');\n      return;\n    }\n    setLoading(true);\n    const res = await API.get(\n      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('验证码发送成功，请检查邮箱！');\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const bindEmail = async () => {\n    if (inputs.email_verification_code === '') return;\n    setLoading(true);\n    const res = await API.get(\n      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess('邮箱账户绑定成功！');\n      setShowEmailBindModal(false);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  return (\n    <div style={{ lineHeight: '40px' }}>\n      <Header as='h3'>{t('setting.personal.general.title')}</Header>\n      <Message>{t('setting.personal.general.system_token_notice')}</Message>\n      <Button as={Link} to={`/user/edit/`}>\n        {t('setting.personal.general.buttons.update_profile')}\n      </Button>\n      <Button onClick={generateAccessToken}>\n        {t('setting.personal.general.buttons.generate_token')}\n      </Button>\n      <Button onClick={getAffLink}>\n        {t('setting.personal.general.buttons.copy_invite')}\n      </Button>\n      <Button\n        onClick={() => {\n          setShowAccountDeleteModal(true);\n        }}\n      >\n        {t('setting.personal.general.buttons.delete_account')}\n      </Button>\n\n      {systemToken && (\n        <Form.Input\n          fluid\n          readOnly\n          value={systemToken}\n          onClick={handleSystemTokenClick}\n          style={{ marginTop: '10px' }}\n        />\n      )}\n      {affLink && (\n        <Form.Input\n          fluid\n          readOnly\n          value={affLink}\n          onClick={handleAffLinkClick}\n          style={{ marginTop: '10px' }}\n        />\n      )}\n      <Divider />\n      <Header as='h3'>{t('setting.personal.binding.title')}</Header>\n      {status.wechat_login && (\n        <Button onClick={() => setShowWeChatBindModal(true)}>\n          {t('setting.personal.binding.buttons.bind_wechat')}\n        </Button>\n      )}\n      <Modal\n        onClose={() => setShowWeChatBindModal(false)}\n        onOpen={() => setShowWeChatBindModal(true)}\n        open={showWeChatBindModal}\n        size={'mini'}\n      >\n        <Modal.Content>\n          <Modal.Description>\n            <Image src={status.wechat_qrcode} fluid />\n            <div style={{ textAlign: 'center' }}>\n              <p>{t('setting.personal.binding.wechat.description')}</p>\n            </div>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                placeholder={t(\n                  'setting.personal.binding.wechat.verification_code'\n                )}\n                name='wechat_verification_code'\n                value={inputs.wechat_verification_code}\n                onChange={handleInputChange}\n              />\n              <Button color='' fluid size='large' onClick={bindWeChat}>\n                {t('setting.personal.binding.wechat.bind')}\n              </Button>\n            </Form>\n          </Modal.Description>\n        </Modal.Content>\n      </Modal>\n      {status.github_oauth && (\n        <Button onClick={() => onGitHubOAuthClicked(status.github_client_id)}>\n          {t('setting.personal.binding.buttons.bind_github')}\n        </Button>\n      )}\n      {status.lark_client_id && (\n        <Button onClick={() => onLarkOAuthClicked(status.lark_client_id)}>\n          {t('setting.personal.binding.buttons.bind_lark')}\n        </Button>\n      )}\n      <Button onClick={() => setShowEmailBindModal(true)}>\n        {t('setting.personal.binding.buttons.bind_email')}\n      </Button>\n      <Modal\n        onClose={() => setShowEmailBindModal(false)}\n        onOpen={() => setShowEmailBindModal(true)}\n        open={showEmailBindModal}\n        size={'tiny'}\n        style={{ maxWidth: '450px' }}\n      >\n        <Modal.Header>{t('setting.personal.binding.email.title')}</Modal.Header>\n        <Modal.Content>\n          <Modal.Description>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                placeholder={t(\n                  'setting.personal.binding.email.email_placeholder'\n                )}\n                onChange={handleInputChange}\n                name='email'\n                type='email'\n                action={\n                  <Button\n                    onClick={sendVerificationCode}\n                    disabled={disableButton || loading}\n                  >\n                    {disableButton\n                      ? t('setting.personal.binding.email.get_code_retry', {\n                          countdown,\n                        })\n                      : t('setting.personal.binding.email.get_code')}\n                  </Button>\n                }\n              />\n              <Form.Input\n                fluid\n                placeholder={t(\n                  'setting.personal.binding.email.code_placeholder'\n                )}\n                name='email_verification_code'\n                value={inputs.email_verification_code}\n                onChange={handleInputChange}\n              />\n              {turnstileEnabled && (\n                <Turnstile\n                  sitekey={turnstileSiteKey}\n                  onVerify={(token) => {\n                    setTurnstileToken(token);\n                  }}\n                />\n              )}\n              <div\n                style={{\n                  display: 'flex',\n                  justifyContent: 'space-between',\n                  marginTop: '1rem',\n                }}\n              >\n                <Button\n                  color=''\n                  fluid\n                  size='large'\n                  onClick={bindEmail}\n                  loading={loading}\n                >\n                  {t('setting.personal.binding.email.bind')}\n                </Button>\n                <div style={{ width: '1rem' }}></div>\n                <Button\n                  fluid\n                  size='large'\n                  onClick={() => setShowEmailBindModal(false)}\n                >\n                  {t('setting.personal.binding.email.cancel')}\n                </Button>\n              </div>\n            </Form>\n          </Modal.Description>\n        </Modal.Content>\n      </Modal>\n      <Modal\n        onClose={() => setShowAccountDeleteModal(false)}\n        onOpen={() => setShowAccountDeleteModal(true)}\n        open={showAccountDeleteModal}\n        size={'tiny'}\n        style={{ maxWidth: '450px' }}\n      >\n        <Modal.Header>\n          {t('setting.personal.delete_account.title')}\n        </Modal.Header>\n        <Modal.Content>\n          <Message>{t('setting.personal.delete_account.warning')}</Message>\n          <Modal.Description>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                placeholder={t(\n                  'setting.personal.delete_account.confirm_placeholder',\n                  {\n                    username: userState?.user?.username,\n                  }\n                )}\n                name='self_account_deletion_confirmation'\n                value={inputs.self_account_deletion_confirmation}\n                onChange={handleInputChange}\n              />\n              {turnstileEnabled && (\n                <Turnstile\n                  sitekey={turnstileSiteKey}\n                  onVerify={(token) => {\n                    setTurnstileToken(token);\n                  }}\n                />\n              )}\n              <div\n                style={{\n                  display: 'flex',\n                  justifyContent: 'space-between',\n                  marginTop: '1rem',\n                }}\n              >\n                <Button\n                  color='red'\n                  fluid\n                  size='large'\n                  onClick={deleteAccount}\n                  loading={loading}\n                >\n                  {t('setting.personal.delete_account.buttons.confirm')}\n                </Button>\n                <div style={{ width: '1rem' }}></div>\n                <Button\n                  fluid\n                  size='large'\n                  onClick={() => setShowAccountDeleteModal(false)}\n                >\n                  {t('setting.personal.delete_account.buttons.cancel')}\n                </Button>\n              </div>\n            </Form>\n          </Modal.Description>\n        </Modal.Content>\n      </Modal>\n    </div>\n  );\n};\n\nexport default PersonalSetting;\n"
  },
  {
    "path": "web/default/src/components/PrivateRoute.js",
    "content": "import { Navigate } from 'react-router-dom';\n\nimport { history } from '../helpers';\n\n\nfunction PrivateRoute({ children }) {\n  if (!localStorage.getItem('user')) {\n    return <Navigate to='/login' state={{ from: history.location }} />;\n  }\n  return children;\n}\n\nexport { PrivateRoute };"
  },
  {
    "path": "web/default/src/components/RedemptionsTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Button,\n  Form,\n  Label,\n  Popup,\n  Pagination,\n  Table,\n} from 'semantic-ui-react';\nimport { Link } from 'react-router-dom';\nimport {\n  API,\n  copy,\n  showError,\n  showInfo,\n  showSuccess,\n  showWarning,\n  timestamp2string,\n} from '../helpers';\n\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderQuota } from '../helpers/render';\n\nfunction renderTimestamp(timestamp) {\n  return <>{timestamp2string(timestamp)}</>;\n}\n\nfunction renderStatus(status, t) {\n  switch (status) {\n    case 1:\n      return (\n        <Label basic color='green'>\n          {t('redemption.status.unused')}\n        </Label>\n      );\n    case 2:\n      return (\n        <Label basic color='red'>\n          {t('redemption.status.disabled')}\n        </Label>\n      );\n    case 3:\n      return (\n        <Label basic color='grey'>\n          {t('redemption.status.used')}\n        </Label>\n      );\n    default:\n      return (\n        <Label basic color='black'>\n          {t('redemption.status.unknown')}\n        </Label>\n      );\n  }\n}\n\nconst RedemptionsTable = () => {\n  const { t } = useTranslation();\n  const [redemptions, setRedemptions] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n\n  const loadRedemptions = async (startIdx) => {\n    const res = await API.get(`/api/redemption/?p=${startIdx}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setRedemptions(data);\n      } else {\n        let newRedemptions = redemptions;\n        newRedemptions.push(...data);\n        setRedemptions(newRedemptions);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadRedemptions(activePage - 1);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  useEffect(() => {\n    loadRedemptions(0)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, []);\n\n  const manageRedemption = async (id, action, idx) => {\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(`/api/redemption/${id}/`);\n        break;\n      case 'enable':\n        data.status = 1;\n        res = await API.put('/api/redemption/?status_only=true', data);\n        break;\n      case 'disable':\n        data.status = 2;\n        res = await API.put('/api/redemption/?status_only=true', data);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('token.messages.operation_success'));\n      let redemption = res.data.data;\n      let newRedemptions = [...redemptions];\n      let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      if (action === 'delete') {\n        newRedemptions[realIdx].deleted = true;\n      } else {\n        newRedemptions[realIdx].status = redemption.status;\n      }\n      setRedemptions(newRedemptions);\n    } else {\n      showError(message);\n    }\n  };\n\n  const searchRedemptions = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadRedemptions(0);\n      setActivePage(1);\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(\n      `/api/redemption/search?keyword=${searchKeyword}`\n    );\n    const { success, message, data } = res.data;\n    if (success) {\n      setRedemptions(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (e, { value }) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortRedemption = (key) => {\n    if (redemptions.length === 0) return;\n    setLoading(true);\n    let sortedRedemptions = [...redemptions];\n    sortedRedemptions.sort((a, b) => {\n      if (!isNaN(a[key])) {\n        // If the value is numeric, subtract to sort\n        return a[key] - b[key];\n      } else {\n        // If the value is not numeric, sort as strings\n        return ('' + a[key]).localeCompare(b[key]);\n      }\n    });\n    if (sortedRedemptions[0].id === redemptions[0].id) {\n      sortedRedemptions.reverse();\n    }\n    setRedemptions(sortedRedemptions);\n    setLoading(false);\n  };\n\n  const refresh = async () => {\n    setLoading(true);\n    await loadRedemptions(0);\n    setActivePage(1);\n  };\n\n  return (\n    <>\n      <Form onSubmit={searchRedemptions}>\n        <Form.Input\n          icon='search'\n          fluid\n          iconPosition='left'\n          placeholder={t('redemption.search')}\n          value={searchKeyword}\n          loading={searching}\n          onChange={handleKeywordChange}\n        />\n      </Form>\n\n      <Table basic={'very'} compact size='small'>\n        <Table.Header>\n          <Table.Row>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortRedemption('id');\n              }}\n            >\n              {t('redemption.table.id')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortRedemption('name');\n              }}\n            >\n              {t('redemption.table.name')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortRedemption('status');\n              }}\n            >\n              {t('redemption.table.status')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortRedemption('quota');\n              }}\n            >\n              {t('redemption.table.quota')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortRedemption('created_time');\n              }}\n            >\n              {t('redemption.table.created_time')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortRedemption('redeemed_time');\n              }}\n            >\n              {t('redemption.table.redeemed_time')}\n            </Table.HeaderCell>\n            <Table.HeaderCell>{t('redemption.table.actions')}</Table.HeaderCell>\n          </Table.Row>\n        </Table.Header>\n\n        <Table.Body>\n          {redemptions\n            .slice(\n              (activePage - 1) * ITEMS_PER_PAGE,\n              activePage * ITEMS_PER_PAGE\n            )\n            .map((redemption, idx) => {\n              if (redemption.deleted) return <></>;\n              return (\n                <Table.Row key={redemption.id}>\n                  <Table.Cell>{redemption.id}</Table.Cell>\n                  <Table.Cell>\n                    {redemption.name ? redemption.name : t('redemption.table.no_name')}\n                  </Table.Cell>\n                  <Table.Cell>{renderStatus(redemption.status, t)}</Table.Cell>\n                  <Table.Cell>{renderQuota(redemption.quota, t)}</Table.Cell>\n                  <Table.Cell>\n                    {renderTimestamp(redemption.created_time)}\n                  </Table.Cell>\n                  <Table.Cell>\n                    {redemption.redeemed_time\n                      ? renderTimestamp(redemption.redeemed_time)\n                      : t('redemption.table.not_redeemed')}{' '}\n                  </Table.Cell>\n                  <Table.Cell>\n                    <div>\n                      <Button\n                        size={'tiny'}\n                        positive\n                        onClick={async () => {\n                          if (await copy(redemption.key)) {\n                            showSuccess(t('token.messages.copy_success'));\n                          } else {\n                            showWarning(t('token.messages.copy_failed'));\n                            setSearchKeyword(redemption.key);\n                          }\n                        }}\n                      >\n                        {t('redemption.buttons.copy')}\n                      </Button>\n                      <Popup\n                        trigger={\n                          <Button size='tiny' negative>\n                            {t('redemption.buttons.delete')}\n                          </Button>\n                        }\n                        on='click'\n                        flowing\n                        hoverable\n                      >\n                        <Button\n                          negative\n                          onClick={() => {\n                            manageRedemption(redemption.id, 'delete', idx);\n                          }}\n                        >\n                          {t('redemption.buttons.confirm_delete')}\n                        </Button>\n                      </Popup>\n                      <Button\n                        size={'tiny'}\n                        disabled={redemption.status === 3} // used\n                        onClick={() => {\n                          manageRedemption(\n                            redemption.id,\n                            redemption.status === 1 ? 'disable' : 'enable',\n                            idx\n                          );\n                        }}\n                      >\n                        {redemption.status === 1\n                          ? t('redemption.buttons.disable')\n                          : t('redemption.buttons.enable')}\n                      </Button>\n                      <Button\n                        size={'tiny'}\n                        as={Link}\n                        to={'/redemption/edit/' + redemption.id}\n                      >\n                        {t('redemption.buttons.edit')}\n                      </Button>\n                    </div>\n                  </Table.Cell>\n                </Table.Row>\n              );\n            })}\n        </Table.Body>\n\n        <Table.Footer>\n          <Table.Row>\n            <Table.HeaderCell colSpan='7'>\n              <Button\n                size='small'\n                as={Link}\n                to='/redemption/add'\n                loading={loading}\n              >\n                {t('redemption.buttons.add')}\n              </Button>\n              <Button size='small' onClick={refresh} loading={loading}>\n                {t('redemption.buttons.refresh')}\n              </Button>\n              <Pagination\n                floated='right'\n                activePage={activePage}\n                onPageChange={onPaginationChange}\n                size='small'\n                siblingRange={1}\n                totalPages={\n                  Math.ceil(redemptions.length / ITEMS_PER_PAGE) +\n                  (redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)\n                }\n              />\n            </Table.HeaderCell>\n          </Table.Row>\n        </Table.Footer>\n      </Table>\n    </>\n  );\n};\n\nexport default RedemptionsTable;\n"
  },
  {
    "path": "web/default/src/components/RegisterForm.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport {\n  Button,\n  Form,\n  Grid,\n  Header,\n  Image,\n  Message,\n  Card,\n  Divider,\n} from 'semantic-ui-react';\nimport { Link, useNavigate } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { API, getLogo, showError, showInfo, showSuccess } from '../helpers';\nimport Turnstile from 'react-turnstile';\n\nconst RegisterForm = () => {\n  const { t } = useTranslation();\n  const [inputs, setInputs] = useState({\n    username: '',\n    password: '',\n    password2: '',\n    email: '',\n    verification_code: '',\n  });\n  const { username, password, password2 } = inputs;\n  const [showEmailVerification, setShowEmailVerification] = useState(false);\n  const [turnstileEnabled, setTurnstileEnabled] = useState(false);\n  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');\n  const [turnstileToken, setTurnstileToken] = useState('');\n  const [loading, setLoading] = useState(false);\n  const [disableButton, setDisableButton] = useState(false);\n  const [countdown, setCountdown] = useState(30);\n  const logo = getLogo();\n  let affCode = new URLSearchParams(window.location.search).get('aff');\n  if (affCode) {\n    localStorage.setItem('aff', affCode);\n  }\n\n  useEffect(() => {\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      setShowEmailVerification(status.email_verification);\n      if (status.turnstile_check) {\n        setTurnstileEnabled(true);\n        setTurnstileSiteKey(status.turnstile_site_key);\n      }\n    }\n  });\n\n  useEffect(() => {\n    let countdownInterval = null;\n    if (disableButton && countdown > 0) {\n      countdownInterval = setInterval(() => {\n        setCountdown(countdown - 1);\n      }, 1000);\n    } else if (countdown === 0) {\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    return () => clearInterval(countdownInterval);\n  }, [disableButton, countdown]);\n\n  let navigate = useNavigate();\n\n  function handleChange(e) {\n    const { name, value } = e.target;\n    console.log(name, value);\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  }\n\n  async function handleSubmit(e) {\n    if (password.length < 8) {\n      showInfo(t('messages.error.password_length'));\n      return;\n    }\n    if (password !== password2) {\n      showInfo(t('messages.error.password_mismatch'));\n      return;\n    }\n    if (username && password) {\n      if (turnstileEnabled && turnstileToken === '') {\n        showInfo(t('messages.error.turnstile_wait'));\n        return;\n      }\n      setLoading(true);\n      if (!affCode) {\n        affCode = localStorage.getItem('aff');\n      }\n      inputs.aff_code = affCode;\n      const res = await API.post(\n        `/api/user/register?turnstile=${turnstileToken}`,\n        inputs\n      );\n      const { success, message } = res.data;\n      if (success) {\n        navigate('/login');\n        showSuccess(t('messages.success.register'));\n      } else {\n        showError(message);\n      }\n      setLoading(false);\n    }\n  }\n\n  const sendVerificationCode = async () => {\n    if (inputs.email === '') return;\n    if (turnstileEnabled && turnstileToken === '') {\n      showInfo(t('messages.error.turnstile_wait'));\n      return;\n    }\n    setDisableButton(true);\n    setLoading(true);\n    const res = await API.get(\n      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`\n    );\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('messages.success.verification_code'));\n    } else {\n      showError(message);\n      setDisableButton(false);\n      setCountdown(30);\n    }\n    setLoading(false);\n  };\n\n  return (\n    <Grid textAlign='center' style={{ marginTop: '48px' }}>\n      <Grid.Column style={{ maxWidth: 450 }}>\n        <Card\n          fluid\n          className='chart-card'\n          style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}\n        >\n          <Card.Content>\n            <Card.Header>\n              <Header\n                as='h2'\n                textAlign='center'\n                style={{ marginBottom: '1.5em' }}\n              >\n                <Image src={logo} style={{ marginBottom: '10px' }} />\n                <Header.Content>{t('auth.register.title')}</Header.Content>\n              </Header>\n            </Card.Header>\n            <Form size='large'>\n              <Form.Input\n                fluid\n                icon='user'\n                iconPosition='left'\n                placeholder={t('auth.register.username')}\n                onChange={handleChange}\n                name='username'\n                style={{ marginBottom: '1em' }}\n              />\n              <Form.Input\n                fluid\n                icon='lock'\n                iconPosition='left'\n                placeholder={t('auth.register.password')}\n                onChange={handleChange}\n                name='password'\n                type='password'\n                style={{ marginBottom: '1em' }}\n              />\n              <Form.Input\n                fluid\n                icon='lock'\n                iconPosition='left'\n                placeholder={t('auth.register.confirm_password')}\n                onChange={handleChange}\n                name='password2'\n                type='password'\n                style={{ marginBottom: '1em' }}\n              />\n\n              {showEmailVerification && (\n                <>\n                  <Form.Input\n                    fluid\n                    icon='mail'\n                    iconPosition='left'\n                    placeholder={t('auth.register.email')}\n                    onChange={handleChange}\n                    name='email'\n                    type='email'\n                    action={\n                      <Button onClick={sendVerificationCode} disabled={loading}>\n                        {disableButton\n                          ? t('auth.register.get_code_retry', { countdown })\n                          : t('auth.register.get_code')}\n                      </Button>\n                    }\n                    style={{ marginBottom: '1em' }}\n                  />\n                  <Form.Input\n                    fluid\n                    icon='lock'\n                    iconPosition='left'\n                    placeholder={t('auth.register.verification_code')}\n                    onChange={handleChange}\n                    name='verification_code'\n                    style={{ marginBottom: '1em' }}\n                  />\n                </>\n              )}\n\n              {turnstileEnabled && (\n                <div\n                  style={{\n                    marginBottom: '1em',\n                    display: 'flex',\n                    justifyContent: 'center',\n                  }}\n                >\n                  <Turnstile\n                    sitekey={turnstileSiteKey}\n                    onVerify={(token) => {\n                      setTurnstileToken(token);\n                    }}\n                  />\n                </div>\n              )}\n\n              <Button\n                fluid\n                size='large'\n                onClick={handleSubmit}\n                style={{\n                  background: '#2F73FF', // 使用更现代的蓝色\n                  color: 'white',\n                  marginBottom: '1.5em',\n                }}\n                loading={loading}\n              >\n                {t('auth.register.button')}\n              </Button>\n            </Form>\n\n            <Divider />\n            <Message style={{ background: 'transparent', boxShadow: 'none' }}>\n              <div\n                style={{\n                  textAlign: 'center',\n                  fontSize: '0.9em',\n                  color: '#666',\n                }}\n              >\n                {t('auth.register.has_account')}\n                <Link\n                  to='/login'\n                  style={{ color: '#2185d0', marginLeft: '2px' }}\n                >\n                  {t('auth.register.login')}\n                </Link>\n              </div>\n            </Message>\n          </Card.Content>\n        </Card>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default RegisterForm;\n"
  },
  {
    "path": "web/default/src/components/SystemSetting.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Button,\n  Divider,\n  Form,\n  Grid,\n  Header,\n  Modal,\n  Message,\n} from 'semantic-ui-react';\nimport { API, removeTrailingSlash, showError } from '../helpers';\n\nconst SystemSetting = () => {\n  const { t } = useTranslation();\n  let [inputs, setInputs] = useState({\n    PasswordLoginEnabled: '',\n    PasswordRegisterEnabled: '',\n    EmailVerificationEnabled: '',\n    GitHubOAuthEnabled: '',\n    GitHubClientId: '',\n    GitHubClientSecret: '',\n    LarkClientId: '',\n    LarkClientSecret: '',\n    Notice: '',\n    SMTPServer: '',\n    SMTPPort: '',\n    SMTPAccount: '',\n    SMTPFrom: '',\n    SMTPToken: '',\n    ServerAddress: '',\n    Footer: '',\n    WeChatAuthEnabled: '',\n    WeChatServerAddress: '',\n    WeChatServerToken: '',\n    WeChatAccountQRCodeImageURL: '',\n    MessagePusherAddress: '',\n    MessagePusherToken: '',\n    TurnstileCheckEnabled: '',\n    TurnstileSiteKey: '',\n    TurnstileSecretKey: '',\n    RegisterEnabled: '',\n    EmailDomainRestrictionEnabled: '',\n    EmailDomainWhitelist: '',\n  });\n  const [originInputs, setOriginInputs] = useState({});\n  let [loading, setLoading] = useState(false);\n  const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);\n  const [restrictedDomainInput, setRestrictedDomainInput] = useState('');\n  const [showPasswordWarningModal, setShowPasswordWarningModal] =\n    useState(false);\n\n  const getOptions = async () => {\n    const res = await API.get('/api/option/');\n    const { success, message, data } = res.data;\n    if (success) {\n      let newInputs = {};\n      data.forEach((item) => {\n        newInputs[item.key] = item.value;\n      });\n      setInputs({\n        ...newInputs,\n        EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','),\n      });\n      setOriginInputs(newInputs);\n\n      setEmailDomainWhitelist(\n        newInputs.EmailDomainWhitelist.split(',').map((item) => {\n          return { key: item, text: item, value: item };\n        })\n      );\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    getOptions().then();\n  }, []);\n\n  const updateOption = async (key, value) => {\n    setLoading(true);\n    switch (key) {\n      case 'PasswordLoginEnabled':\n      case 'PasswordRegisterEnabled':\n      case 'EmailVerificationEnabled':\n      case 'GitHubOAuthEnabled':\n      case 'WeChatAuthEnabled':\n      case 'TurnstileCheckEnabled':\n      case 'EmailDomainRestrictionEnabled':\n      case 'RegisterEnabled':\n        value = inputs[key] === 'true' ? 'false' : 'true';\n        break;\n      default:\n        break;\n    }\n    const res = await API.put('/api/option/', {\n      key,\n      value,\n    });\n    const { success, message } = res.data;\n    if (success) {\n      if (key === 'EmailDomainWhitelist') {\n        value = value.split(',');\n      }\n      setInputs((inputs) => ({\n        ...inputs,\n        [key]: value,\n      }));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const handleInputChange = async (e, { name, value }) => {\n    if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {\n      // block disabling password login\n      setShowPasswordWarningModal(true);\n      return;\n    }\n    if (\n      name === 'Notice' ||\n      name.startsWith('SMTP') ||\n      name === 'ServerAddress' ||\n      name === 'GitHubClientId' ||\n      name === 'GitHubClientSecret' ||\n      name === 'LarkClientId' ||\n      name === 'LarkClientSecret' ||\n      name === 'WeChatServerAddress' ||\n      name === 'WeChatServerToken' ||\n      name === 'WeChatAccountQRCodeImageURL' ||\n      name === 'TurnstileSiteKey' ||\n      name === 'TurnstileSecretKey' ||\n      name === 'EmailDomainWhitelist'\n    ) {\n      setInputs((inputs) => ({ ...inputs, [name]: value }));\n    } else {\n      await updateOption(name, value);\n    }\n  };\n\n  const submitServerAddress = async () => {\n    let ServerAddress = removeTrailingSlash(inputs.ServerAddress);\n    await updateOption('ServerAddress', ServerAddress);\n  };\n\n  const submitSMTP = async () => {\n    if (originInputs['SMTPServer'] !== inputs.SMTPServer) {\n      await updateOption('SMTPServer', inputs.SMTPServer);\n    }\n    if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {\n      await updateOption('SMTPAccount', inputs.SMTPAccount);\n    }\n    if (originInputs['SMTPFrom'] !== inputs.SMTPFrom) {\n      await updateOption('SMTPFrom', inputs.SMTPFrom);\n    }\n    if (\n      originInputs['SMTPPort'] !== inputs.SMTPPort &&\n      inputs.SMTPPort !== ''\n    ) {\n      await updateOption('SMTPPort', inputs.SMTPPort);\n    }\n    if (\n      originInputs['SMTPToken'] !== inputs.SMTPToken &&\n      inputs.SMTPToken !== ''\n    ) {\n      await updateOption('SMTPToken', inputs.SMTPToken);\n    }\n  };\n\n  const submitEmailDomainWhitelist = async () => {\n    if (\n      originInputs['EmailDomainWhitelist'] !==\n        inputs.EmailDomainWhitelist.join(',') &&\n      inputs.SMTPToken !== ''\n    ) {\n      await updateOption(\n        'EmailDomainWhitelist',\n        inputs.EmailDomainWhitelist.join(',')\n      );\n    }\n  };\n\n  const submitWeChat = async () => {\n    if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {\n      await updateOption(\n        'WeChatServerAddress',\n        removeTrailingSlash(inputs.WeChatServerAddress)\n      );\n    }\n    if (\n      originInputs['WeChatAccountQRCodeImageURL'] !==\n      inputs.WeChatAccountQRCodeImageURL\n    ) {\n      await updateOption(\n        'WeChatAccountQRCodeImageURL',\n        inputs.WeChatAccountQRCodeImageURL\n      );\n    }\n    if (\n      originInputs['WeChatServerToken'] !== inputs.WeChatServerToken &&\n      inputs.WeChatServerToken !== ''\n    ) {\n      await updateOption('WeChatServerToken', inputs.WeChatServerToken);\n    }\n  };\n\n  const submitMessagePusher = async () => {\n    if (originInputs['MessagePusherAddress'] !== inputs.MessagePusherAddress) {\n      await updateOption(\n        'MessagePusherAddress',\n        removeTrailingSlash(inputs.MessagePusherAddress)\n      );\n    }\n    if (\n      originInputs['MessagePusherToken'] !== inputs.MessagePusherToken &&\n      inputs.MessagePusherToken !== ''\n    ) {\n      await updateOption('MessagePusherToken', inputs.MessagePusherToken);\n    }\n  };\n\n  const submitGitHubOAuth = async () => {\n    if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {\n      await updateOption('GitHubClientId', inputs.GitHubClientId);\n    }\n    if (\n      originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret &&\n      inputs.GitHubClientSecret !== ''\n    ) {\n      await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);\n    }\n  };\n\n  const submitLarkOAuth = async () => {\n    if (originInputs['LarkClientId'] !== inputs.LarkClientId) {\n      await updateOption('LarkClientId', inputs.LarkClientId);\n    }\n    if (\n      originInputs['LarkClientSecret'] !== inputs.LarkClientSecret &&\n      inputs.LarkClientSecret !== ''\n    ) {\n      await updateOption('LarkClientSecret', inputs.LarkClientSecret);\n    }\n  };\n\n  const submitTurnstile = async () => {\n    if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {\n      await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);\n    }\n    if (\n      originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey &&\n      inputs.TurnstileSecretKey !== ''\n    ) {\n      await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);\n    }\n  };\n\n  const submitNewRestrictedDomain = () => {\n    const localDomainList = inputs.EmailDomainWhitelist;\n    if (\n      restrictedDomainInput !== '' &&\n      !localDomainList.includes(restrictedDomainInput)\n    ) {\n      setRestrictedDomainInput('');\n      setInputs({\n        ...inputs,\n        EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],\n      });\n      setEmailDomainWhitelist([\n        ...EmailDomainWhitelist,\n        {\n          key: restrictedDomainInput,\n          text: restrictedDomainInput,\n          value: restrictedDomainInput,\n        },\n      ]);\n    }\n  };\n\n  return (\n    <Grid columns={1}>\n      <Grid.Column>\n        <Form loading={loading}>\n          <Header as='h3'>{t('setting.system.general.title')}</Header>\n          <Form.Group widths='equal'>\n            <Form.Input\n              label={t('setting.system.general.server_address')}\n              placeholder={t(\n                'setting.system.general.server_address_placeholder'\n              )}\n              value={inputs.ServerAddress}\n              name='ServerAddress'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitServerAddress}>\n            {t('setting.system.general.buttons.update')}\n          </Form.Button>\n          <Divider />\n          <Header as='h3'>{t('setting.system.login.title')}</Header>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.PasswordLoginEnabled === 'true'}\n              label={t('setting.system.login.password_login')}\n              name='PasswordLoginEnabled'\n              onChange={handleInputChange}\n            />\n            {showPasswordWarningModal && (\n              <Modal\n                open={showPasswordWarningModal}\n                onClose={() => setShowPasswordWarningModal(false)}\n                size={'tiny'}\n                style={{ maxWidth: '450px' }}\n              >\n                <Modal.Header>\n                  {t('setting.system.password_login.warning.title')}\n                </Modal.Header>\n                <Modal.Content>\n                  <p>{t('setting.system.password_login.warning.content')}</p>\n                </Modal.Content>\n                <Modal.Actions>\n                  <Button onClick={() => setShowPasswordWarningModal(false)}>\n                    {t('setting.system.password_login.warning.buttons.cancel')}\n                  </Button>\n                  <Button\n                    color='yellow'\n                    onClick={async () => {\n                      setShowPasswordWarningModal(false);\n                      await updateOption('PasswordLoginEnabled', 'false');\n                    }}\n                  >\n                    {t('setting.system.password_login.warning.buttons.confirm')}\n                  </Button>\n                </Modal.Actions>\n              </Modal>\n            )}\n            <Form.Checkbox\n              checked={inputs.PasswordRegisterEnabled === 'true'}\n              label={t('setting.system.login.password_register')}\n              name='PasswordRegisterEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.EmailVerificationEnabled === 'true'}\n              label={t('setting.system.login.email_verification')}\n              name='EmailVerificationEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.GitHubOAuthEnabled === 'true'}\n              label={t('setting.system.login.github_oauth')}\n              name='GitHubOAuthEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.WeChatAuthEnabled === 'true'}\n              label={t('setting.system.login.wechat_login')}\n              name='WeChatAuthEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.RegisterEnabled === 'true'}\n              label={t('setting.system.login.registration')}\n              name='RegisterEnabled'\n              onChange={handleInputChange}\n            />\n            <Form.Checkbox\n              checked={inputs.TurnstileCheckEnabled === 'true'}\n              label={t('setting.system.login.turnstile')}\n              name='TurnstileCheckEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Divider />\n          <Header as='h3'>{t('setting.system.email_restriction.title')}</Header>\n          <Message>{t('setting.system.email_restriction.subtitle')}</Message>\n          <Form.Group inline>\n            <Form.Checkbox\n              checked={inputs.EmailDomainRestrictionEnabled === 'true'}\n              label={t('setting.system.email_restriction.enable')}\n              name='EmailDomainRestrictionEnabled'\n              onChange={handleInputChange}\n            />\n          </Form.Group>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.email_restriction.add_domain')}\n              placeholder={t(\n                'setting.system.email_restriction.add_domain_placeholder'\n              )}\n              value={restrictedDomainInput}\n              onChange={(e, { value }) => {\n                setRestrictedDomainInput(value);\n              }}\n              action={\n                <Button\n                  onClick={() => {\n                    if (restrictedDomainInput === '') return;\n                    setEmailDomainWhitelist([\n                      ...EmailDomainWhitelist,\n                      {\n                        key: restrictedDomainInput,\n                        text: restrictedDomainInput,\n                        value: restrictedDomainInput,\n                      },\n                    ]);\n                    setRestrictedDomainInput('');\n                  }}\n                >\n                  {t('setting.system.email_restriction.buttons.fill')}\n                </Button>\n              }\n            />\n          </Form.Group>\n          <Form.Dropdown\n            label={t('setting.system.email_restriction.allowed_domains')}\n            placeholder={t('setting.system.email_restriction.allowed_domains')}\n            fluid\n            multiple\n            search\n            selection\n            allowAdditions\n            value={EmailDomainWhitelist.map((item) => item.value)}\n            options={EmailDomainWhitelist}\n            onAddItem={(e, { value }) => {\n              setEmailDomainWhitelist([\n                ...EmailDomainWhitelist,\n                {\n                  key: value,\n                  text: value,\n                  value: value,\n                },\n              ]);\n            }}\n            onChange={(e, { value }) => {\n              let newEmailDomainWhitelist = [];\n              value.forEach((item) => {\n                newEmailDomainWhitelist.push({\n                  key: item,\n                  text: item,\n                  value: item,\n                });\n              });\n              setEmailDomainWhitelist(newEmailDomainWhitelist);\n            }}\n          />\n          <Form.Button onClick={submitEmailDomainWhitelist}>\n            {t('setting.system.email_restriction.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>{t('setting.system.smtp.title')}</Header>\n          <Message>{t('setting.system.smtp.subtitle')}</Message>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.smtp.server')}\n              placeholder={t('setting.system.smtp.server_placeholder')}\n              name='SMTPServer'\n              onChange={handleInputChange}\n              value={inputs.SMTPServer}\n            />\n            <Form.Input\n              label={t('setting.system.smtp.port')}\n              placeholder={t('setting.system.smtp.port_placeholder')}\n              name='SMTPPort'\n              onChange={handleInputChange}\n              value={inputs.SMTPPort}\n            />\n            <Form.Input\n              label={t('setting.system.smtp.account')}\n              placeholder={t('setting.system.smtp.account_placeholder')}\n              name='SMTPAccount'\n              onChange={handleInputChange}\n              value={inputs.SMTPAccount}\n            />\n          </Form.Group>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.smtp.from')}\n              placeholder={t('setting.system.smtp.from_placeholder')}\n              name='SMTPFrom'\n              onChange={handleInputChange}\n              value={inputs.SMTPFrom}\n            />\n            <Form.Input\n              label={t('setting.system.smtp.token')}\n              placeholder={t('setting.system.smtp.token_placeholder')}\n              name='SMTPToken'\n              onChange={handleInputChange}\n              type='password'\n              value={inputs.SMTPToken}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitSMTP}>\n            {t('setting.system.smtp.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>{t('setting.system.github.title')}</Header>\n          <Message>\n            {t('setting.system.github.subtitle')}\n            <a href='https://github.com/settings/developers' target='_blank'>\n              {t('setting.system.github.manage_link')}\n            </a>\n            {t('setting.system.github.manage_text')}\n          </Message>\n          <Message>\n            {t('setting.system.github.url_notice', {\n              server_url: originInputs.ServerAddress,\n              callback_url: `${originInputs.ServerAddress}/oauth/github`,\n            })}\n          </Message>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.github.client_id')}\n              placeholder={t('setting.system.github.client_id_placeholder')}\n              name='GitHubClientId'\n              onChange={handleInputChange}\n              value={inputs.GitHubClientId}\n            />\n            <Form.Input\n              label={t('setting.system.github.client_secret')}\n              placeholder={t('setting.system.github.client_secret_placeholder')}\n              name='GitHubClientSecret'\n              onChange={handleInputChange}\n              type='password'\n              value={inputs.GitHubClientSecret}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitGitHubOAuth}>\n            {t('setting.system.github.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>\n            {t('setting.system.lark.title')}\n            <Header.Subheader>\n              {t('setting.system.lark.subtitle')}\n              <a href='https://open.feishu.cn/app' target='_blank'>\n                {t('setting.system.lark.manage_link')}\n              </a>\n              {t('setting.system.lark.manage_text')}\n            </Header.Subheader>\n          </Header>\n          <Message>\n            {t('setting.system.lark.url_notice', {\n              server_url: inputs.ServerAddress,\n              callback_url: `${inputs.ServerAddress}/oauth/lark`,\n            })}\n          </Message>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.lark.client_id')}\n              name='LarkClientId'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.LarkClientId}\n              placeholder={t('setting.system.lark.client_id_placeholder')}\n            />\n            <Form.Input\n              label={t('setting.system.lark.client_secret')}\n              name='LarkClientSecret'\n              onChange={handleInputChange}\n              type='password'\n              autoComplete='new-password'\n              value={inputs.LarkClientSecret}\n              placeholder={t('setting.system.lark.client_secret_placeholder')}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitLarkOAuth}>\n            {t('setting.system.lark.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>\n            {t('setting.system.wechat.title')}\n            <Header.Subheader>\n              {t('setting.system.wechat.subtitle')}\n              <a\n                href='https://github.com/songquanpeng/wechat-server'\n                target='_blank'\n              >\n                {t('setting.system.wechat.learn_more')}\n              </a>\n            </Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.wechat.server_address')}\n              name='WeChatServerAddress'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.WeChatServerAddress}\n              placeholder={t(\n                'setting.system.wechat.server_address_placeholder'\n              )}\n            />\n            <Form.Input\n              label={t('setting.system.wechat.token')}\n              name='WeChatServerToken'\n              onChange={handleInputChange}\n              type='password'\n              autoComplete='new-password'\n              value={inputs.WeChatServerToken}\n              placeholder={t('setting.system.wechat.token_placeholder')}\n            />\n            <Form.Input\n              label={t('setting.system.wechat.qrcode')}\n              name='WeChatAccountQRCodeImageURL'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.WeChatAccountQRCodeImageURL}\n              placeholder={t('setting.system.wechat.qrcode_placeholder')}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitWeChat}>\n            {t('setting.system.wechat.buttons.save')}\n          </Form.Button>\n\n          <Divider />\n          <Header as='h3'>\n            {t('setting.system.turnstile.title')}\n            <Header.Subheader>\n              {t('setting.system.turnstile.subtitle')}\n              <a href='https://dash.cloudflare.com/' target='_blank'>\n                {t('setting.system.turnstile.manage_link')}\n              </a>\n              {t('setting.system.turnstile.manage_text')}\n            </Header.Subheader>\n          </Header>\n          <Form.Group widths={3}>\n            <Form.Input\n              label={t('setting.system.turnstile.site_key')}\n              name='TurnstileSiteKey'\n              onChange={handleInputChange}\n              autoComplete='new-password'\n              value={inputs.TurnstileSiteKey}\n              placeholder={t('setting.system.turnstile.site_key_placeholder')}\n            />\n            <Form.Input\n              label={t('setting.system.turnstile.secret_key')}\n              name='TurnstileSecretKey'\n              onChange={handleInputChange}\n              type='password'\n              autoComplete='new-password'\n              value={inputs.TurnstileSecretKey}\n              placeholder={t('setting.system.turnstile.secret_key_placeholder')}\n            />\n          </Form.Group>\n          <Form.Button onClick={submitTurnstile}>\n            {t('setting.system.turnstile.buttons.save')}\n          </Form.Button>\n        </Form>\n      </Grid.Column>\n    </Grid>\n  );\n};\n\nexport default SystemSetting;\n"
  },
  {
    "path": "web/default/src/components/TokensTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Button,\n  Dropdown,\n  Form,\n  Label,\n  Pagination,\n  Popup,\n  Table,\n} from 'semantic-ui-react';\nimport { Link } from 'react-router-dom';\nimport {\n  API,\n  copy,\n  showError,\n  showSuccess,\n  showWarning,\n  timestamp2string,\n} from '../helpers';\n\nimport { ITEMS_PER_PAGE } from '../constants';\nimport { renderQuota } from '../helpers/render';\n\nfunction renderTimestamp(timestamp) {\n  return <>{timestamp2string(timestamp)}</>;\n}\n\nfunction renderStatus(status, t) {\n  switch (status) {\n    case 1:\n      return (\n        <Label basic color='green'>\n          {t('token.table.status_enabled')}\n        </Label>\n      );\n    case 2:\n      return (\n        <Label basic color='red'>\n          {t('token.table.status_disabled')}\n        </Label>\n      );\n    case 3:\n      return (\n        <Label basic color='yellow'>\n          {t('token.table.status_expired')}\n        </Label>\n      );\n    case 4:\n      return (\n        <Label basic color='grey'>\n          {t('token.table.status_depleted')}\n        </Label>\n      );\n    default:\n      return (\n        <Label basic color='black'>\n          {t('token.table.status_unknown')}\n        </Label>\n      );\n  }\n}\n\nconst TokensTable = () => {\n  const { t } = useTranslation();\n\n  const COPY_OPTIONS = [\n    { key: 'raw', text: t('token.copy_options.raw'), value: '' },\n    { key: 'next', text: t('token.copy_options.next'), value: 'next' },\n    { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' },\n    { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' },\n    { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' },\n  ];\n\n  const OPEN_LINK_OPTIONS = [\n    { key: 'next', text: t('token.copy_options.next'), value: 'next' },\n    { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' },\n    { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' },\n    { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' },\n  ];\n\n  const [tokens, setTokens] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [showTopUpModal, setShowTopUpModal] = useState(false);\n  const [targetTokenIdx, setTargetTokenIdx] = useState(0);\n  const [orderBy, setOrderBy] = useState('');\n\n  const loadTokens = async (startIdx) => {\n    const res = await API.get(`/api/token/?p=${startIdx}&order=${orderBy}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setTokens(data);\n      } else {\n        let newTokens = [...tokens];\n        newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);\n        setTokens(newTokens);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadTokens(activePage - 1, orderBy);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  const refresh = async () => {\n    setLoading(true);\n    await loadTokens(activePage - 1);\n  };\n\n  const onCopy = async (type, key) => {\n    let status = localStorage.getItem('status');\n    let serverAddress = '';\n    if (status) {\n      status = JSON.parse(status);\n      serverAddress = status.server_address;\n    }\n    if (serverAddress === '') {\n      serverAddress = window.location.origin;\n    }\n    let encodedServerAddress = encodeURIComponent(serverAddress);\n    const nextLink = localStorage.getItem('chat_link');\n    let nextUrl;\n\n    if (nextLink) {\n      nextUrl =\n        nextLink + `/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    } else {\n      nextUrl = `https://app.nextchat.dev/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    }\n\n    let url;\n    switch (type) {\n      case 'ama':\n        url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;\n        break;\n      case 'opencat':\n        url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;\n        break;\n      case 'next':\n        url = nextUrl;\n        break;\n      case 'lobechat':\n        url =\n          nextLink +\n          `/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"sk-${key}\",\"baseURL\":\"${serverAddress}/v1\"}}}`;\n        break;\n      default:\n        url = `sk-${key}`;\n    }\n    if (await copy(url)) {\n      showSuccess(t('token.messages.copy_success'));\n    } else {\n      showWarning(t('token.messages.copy_failed'));\n      setSearchKeyword(url);\n    }\n  };\n\n  const onOpenLink = async (type, key) => {\n    let status = localStorage.getItem('status');\n    let serverAddress = '';\n    if (status) {\n      status = JSON.parse(status);\n      serverAddress = status.server_address;\n    }\n    if (serverAddress === '') {\n      serverAddress = window.location.origin;\n    }\n    let encodedServerAddress = encodeURIComponent(serverAddress);\n    const chatLink = localStorage.getItem('chat_link');\n    let defaultUrl;\n\n    if (chatLink) {\n      defaultUrl =\n        chatLink + `/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    } else {\n      defaultUrl = `https://app.nextchat.dev/#/?settings={\"key\":\"sk-${key}\",\"url\":\"${serverAddress}\"}`;\n    }\n    let url;\n    switch (type) {\n      case 'ama':\n        url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;\n        break;\n\n      case 'opencat':\n        url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;\n        break;\n\n      case 'lobechat':\n        url =\n          chatLink +\n          `/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"sk-${key}\",\"baseURL\":\"${serverAddress}/v1\"}}}`;\n        break;\n\n      default:\n        url = defaultUrl;\n    }\n\n    window.open(url, '_blank');\n  };\n\n  useEffect(() => {\n    loadTokens(0, orderBy)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, [orderBy]);\n\n  const manageToken = async (id, action, idx) => {\n    let data = { id };\n    let res;\n    switch (action) {\n      case 'delete':\n        res = await API.delete(`/api/token/${id}/`);\n        break;\n      case 'enable':\n        data.status = 1;\n        res = await API.put('/api/token/?status_only=true', data);\n        break;\n      case 'disable':\n        data.status = 2;\n        res = await API.put('/api/token/?status_only=true', data);\n        break;\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('token.messages.operation_success'));\n      let token = res.data.data;\n      let newTokens = [...tokens];\n      let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n      if (action === 'delete') {\n        newTokens[realIdx].deleted = true;\n      } else {\n        newTokens[realIdx].status = token.status;\n      }\n      setTokens(newTokens);\n    } else {\n      showError(message);\n    }\n  };\n\n  const searchTokens = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadTokens(0);\n      setActivePage(1);\n      setOrderBy('');\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/token/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setTokens(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (e, { value }) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortToken = (key) => {\n    if (tokens.length === 0) return;\n    setLoading(true);\n    let sortedTokens = [...tokens];\n    sortedTokens.sort((a, b) => {\n      if (!isNaN(a[key])) {\n        // If the value is numeric, subtract to sort\n        return a[key] - b[key];\n      } else {\n        // If the value is not numeric, sort as strings\n        return ('' + a[key]).localeCompare(b[key]);\n      }\n    });\n    if (sortedTokens[0].id === tokens[0].id) {\n      sortedTokens.reverse();\n    }\n    setTokens(sortedTokens);\n    setLoading(false);\n  };\n\n  const handleOrderByChange = (e, { value }) => {\n    setOrderBy(value);\n    setActivePage(1);\n  };\n\n  return (\n    <>\n      <Form onSubmit={searchTokens}>\n        <Form.Input\n          icon='search'\n          fluid\n          iconPosition='left'\n          placeholder={t('token.search')}\n          value={searchKeyword}\n          loading={searching}\n          onChange={handleKeywordChange}\n        />\n      </Form>\n\n      <Table basic={'very'} compact size='small'>\n        <Table.Header>\n          <Table.Row>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortToken('name');\n              }}\n            >\n              {t('token.table.name')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortToken('status');\n              }}\n            >\n              {t('token.table.status')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortToken('used_quota');\n              }}\n            >\n              {t('token.table.used_quota')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortToken('remain_quota');\n              }}\n            >\n              {t('token.table.remain_quota')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortToken('created_time');\n              }}\n            >\n              {t('token.table.created_time')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortToken('expired_time');\n              }}\n            >\n              {t('token.table.expired_time')}\n            </Table.HeaderCell>\n            <Table.HeaderCell>{t('token.table.actions')}</Table.HeaderCell>\n          </Table.Row>\n        </Table.Header>\n\n        <Table.Body>\n          {tokens\n            .slice(\n              (activePage - 1) * ITEMS_PER_PAGE,\n              activePage * ITEMS_PER_PAGE\n            )\n            .map((token, idx) => {\n              if (token.deleted) return <></>;\n\n              const copyOptionsWithHandlers = COPY_OPTIONS.map((option) => ({\n                ...option,\n                onClick: async () => {\n                  await onCopy(option.value, token.key);\n                },\n              }));\n\n              const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map(\n                (option) => ({\n                  ...option,\n                  onClick: async () => {\n                    await onOpenLink(option.value, token.key);\n                  },\n                })\n              );\n\n              return (\n                <Table.Row key={token.id}>\n                  <Table.Cell>\n                    {token.name ? token.name : t('token.table.no_name')}\n                  </Table.Cell>\n                  <Table.Cell>{renderStatus(token.status, t)}</Table.Cell>\n                  <Table.Cell>{renderQuota(token.used_quota, t)}</Table.Cell>\n                  <Table.Cell>\n                    {token.unlimited_quota\n                      ? t('token.table.unlimited')\n                      : renderQuota(token.remain_quota, t, 2)}\n                  </Table.Cell>\n                  <Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>\n                  <Table.Cell>\n                    {token.expired_time === -1\n                      ? t('token.table.never_expire')\n                      : renderTimestamp(token.expired_time)}\n                  </Table.Cell>\n                  <Table.Cell>\n                    <div>\n                      <Button.Group color='green' size={'tiny'}>\n                        <Button\n                          size={'tiny'}\n                          positive\n                          onClick={async () => await onCopy('', token.key)}\n                        >\n                          {t('token.buttons.copy')}\n                        </Button>\n                        <Dropdown\n                          className='button icon'\n                          floating\n                          options={copyOptionsWithHandlers}\n                          trigger={<></>}\n                        />\n                      </Button.Group>{' '}\n                      <Button.Group color='olive' size={'tiny'}>\n                        <Button\n                          size={'tiny'}\n                          positive\n                          onClick={() => onOpenLink('', token.key)}\n                        >\n                          {t('token.buttons.chat')}\n                        </Button>\n                        <Dropdown\n                          className='button icon'\n                          floating\n                          options={openLinkOptionsWithHandlers}\n                          trigger={<></>}\n                        />\n                      </Button.Group>{' '}\n                      <Popup\n                        trigger={\n                          <Button size='mini' negative>\n                            {t('token.buttons.delete')}\n                          </Button>\n                        }\n                        on='click'\n                        flowing\n                        hoverable\n                      >\n                        <Button\n                          size={'tiny'}\n                          negative\n                          onClick={() => {\n                            manageToken(token.id, 'delete', idx);\n                          }}\n                        >\n                          {t('token.buttons.confirm_delete')} {token.name}\n                        </Button>\n                      </Popup>\n                      <Button\n                        size={'tiny'}\n                        onClick={() => {\n                          manageToken(\n                            token.id,\n                            token.status === 1 ? 'disable' : 'enable',\n                            idx\n                          );\n                        }}\n                      >\n                        {token.status === 1\n                          ? t('token.buttons.disable')\n                          : t('token.buttons.enable')}\n                      </Button>\n                      <Button\n                        size={'tiny'}\n                        as={Link}\n                        to={'/token/edit/' + token.id}\n                      >\n                        {t('token.buttons.edit')}\n                      </Button>\n                    </div>\n                  </Table.Cell>\n                </Table.Row>\n              );\n            })}\n        </Table.Body>\n\n        <Table.Footer>\n          <Table.Row>\n            <Table.HeaderCell colSpan='7'>\n              <Button size='small' as={Link} to='/token/add' loading={loading}>\n                {t('token.buttons.add')}\n              </Button>\n              <Button size='small' onClick={refresh} loading={loading}>\n                {t('token.buttons.refresh')}\n              </Button>\n              <Dropdown\n                placeholder={t('token.sort.placeholder')}\n                selection\n                options={[\n                  { key: '', text: t('token.sort.default'), value: '' },\n                  {\n                    key: 'remain_quota',\n                    text: t('token.sort.by_remain'),\n                    value: 'remain_quota',\n                  },\n                  {\n                    key: 'used_quota',\n                    text: t('token.sort.by_used'),\n                    value: 'used_quota',\n                  },\n                ]}\n                value={orderBy}\n                onChange={handleOrderByChange}\n                style={{ marginLeft: '10px' }}\n              />\n              <Pagination\n                floated='right'\n                activePage={activePage}\n                onPageChange={onPaginationChange}\n                size='small'\n                siblingRange={1}\n                totalPages={\n                  Math.ceil(tokens.length / ITEMS_PER_PAGE) +\n                  (tokens.length % ITEMS_PER_PAGE === 0 ? 1 : 0)\n                }\n              />\n            </Table.HeaderCell>\n          </Table.Row>\n        </Table.Footer>\n      </Table>\n    </>\n  );\n};\n\nexport default TokensTable;\n"
  },
  {
    "path": "web/default/src/components/UsersTable.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport {\n  Button,\n  Form,\n  Label,\n  Pagination,\n  Popup,\n  Table,\n  Dropdown,\n} from 'semantic-ui-react';\nimport { Link } from 'react-router-dom';\nimport { API, showError, showSuccess } from '../helpers';\nimport { useTranslation } from 'react-i18next';\n\nimport { ITEMS_PER_PAGE } from '../constants';\nimport {\n  renderGroup,\n  renderNumber,\n  renderQuota,\n  renderText,\n} from '../helpers/render';\n\nfunction renderRole(role, t) {\n  switch (role) {\n    case 1:\n      return <Label>{t('user.table.role_types.normal')}</Label>;\n    case 10:\n      return <Label color='yellow'>{t('user.table.role_types.admin')}</Label>;\n    case 100:\n      return (\n        <Label color='orange'>{t('user.table.role_types.super_admin')}</Label>\n      );\n    default:\n      return <Label color='red'>{t('user.table.role_types.unknown')}</Label>;\n  }\n}\n\nconst UsersTable = () => {\n  const { t } = useTranslation();\n  const [users, setUsers] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [activePage, setActivePage] = useState(1);\n  const [searchKeyword, setSearchKeyword] = useState('');\n  const [searching, setSearching] = useState(false);\n  const [orderBy, setOrderBy] = useState('');\n\n  const loadUsers = async (startIdx) => {\n    const res = await API.get(`/api/user/?p=${startIdx}&order=${orderBy}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (startIdx === 0) {\n        setUsers(data);\n      } else {\n        let newUsers = users;\n        newUsers.push(...data);\n        setUsers(newUsers);\n      }\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const onPaginationChange = (e, { activePage }) => {\n    (async () => {\n      if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {\n        // In this case we have to load more data and then append them.\n        await loadUsers(activePage - 1, orderBy);\n      }\n      setActivePage(activePage);\n    })();\n  };\n\n  useEffect(() => {\n    loadUsers(0, orderBy)\n      .then()\n      .catch((reason) => {\n        showError(reason);\n      });\n  }, [orderBy]);\n\n  const manageUser = (username, action, idx) => {\n    (async () => {\n      const res = await API.post('/api/user/manage', {\n        username,\n        action,\n      });\n      const { success, message } = res.data;\n      if (success) {\n        showSuccess(t('user.messages.operation_success'));\n        let user = res.data.data;\n        let newUsers = [...users];\n        let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;\n        if (action === 'delete') {\n          newUsers[realIdx].deleted = true;\n        } else {\n          newUsers[realIdx].status = user.status;\n          newUsers[realIdx].role = user.role;\n        }\n        setUsers(newUsers);\n      } else {\n        showError(message);\n      }\n    })();\n  };\n\n  const renderStatus = (status) => {\n    switch (status) {\n      case 1:\n        return <Label basic>{t('user.table.status_types.activated')}</Label>;\n      case 2:\n        return (\n          <Label basic color='red'>\n            {t('user.table.status_types.banned')}\n          </Label>\n        );\n      default:\n        return (\n          <Label basic color='grey'>\n            {t('user.table.status_types.unknown')}\n          </Label>\n        );\n    }\n  };\n\n  const searchUsers = async () => {\n    if (searchKeyword === '') {\n      // if keyword is blank, load files instead.\n      await loadUsers(0);\n      setActivePage(1);\n      setOrderBy('');\n      return;\n    }\n    setSearching(true);\n    const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setUsers(data);\n      setActivePage(1);\n    } else {\n      showError(message);\n    }\n    setSearching(false);\n  };\n\n  const handleKeywordChange = async (e, { value }) => {\n    setSearchKeyword(value.trim());\n  };\n\n  const sortUser = (key) => {\n    if (users.length === 0) return;\n    setLoading(true);\n    let sortedUsers = [...users];\n    sortedUsers.sort((a, b) => {\n      if (!isNaN(a[key])) {\n        // If the value is numeric, subtract to sort\n        return a[key] - b[key];\n      } else {\n        // If the value is not numeric, sort as strings\n        return ('' + a[key]).localeCompare(b[key]);\n      }\n    });\n    if (sortedUsers[0].id === users[0].id) {\n      sortedUsers.reverse();\n    }\n    setUsers(sortedUsers);\n    setLoading(false);\n  };\n\n  const handleOrderByChange = (e, { value }) => {\n    setOrderBy(value);\n    setActivePage(1);\n  };\n\n  return (\n    <>\n      <Form onSubmit={searchUsers}>\n        <Form.Input\n          icon='search'\n          fluid\n          iconPosition='left'\n          placeholder={t('user.search')}\n          value={searchKeyword}\n          loading={searching}\n          onChange={handleKeywordChange}\n        />\n      </Form>\n\n      <Table basic={'very'} compact size='small'>\n        <Table.Header>\n          <Table.Row>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortUser('id');\n              }}\n            >\n              {t('user.table.id')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortUser('username');\n              }}\n            >\n              {t('user.table.username')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortUser('group');\n              }}\n            >\n              {t('user.table.group')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortUser('quota');\n              }}\n            >\n              {t('user.table.quota')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortUser('role');\n              }}\n            >\n              {t('user.table.role_text')}\n            </Table.HeaderCell>\n            <Table.HeaderCell\n              style={{ cursor: 'pointer' }}\n              onClick={() => {\n                sortUser('status');\n              }}\n            >\n              {t('user.table.status_text')}\n            </Table.HeaderCell>\n            <Table.HeaderCell>{t('user.table.actions')}</Table.HeaderCell>\n          </Table.Row>\n        </Table.Header>\n\n        <Table.Body>\n          {users\n            .slice(\n              (activePage - 1) * ITEMS_PER_PAGE,\n              activePage * ITEMS_PER_PAGE\n            )\n            .map((user, idx) => {\n              if (user.deleted) return <></>;\n              return (\n                <Table.Row key={user.id}>\n                  <Table.Cell>{user.id}</Table.Cell>\n                  <Table.Cell>\n                    <Popup\n                      content={user.email ? user.email : '未绑定邮箱地址'}\n                      key={user.username}\n                      header={\n                        user.display_name ? user.display_name : user.username\n                      }\n                      trigger={<span>{renderText(user.username, 15)}</span>}\n                      hoverable\n                    />\n                  </Table.Cell>\n                  <Table.Cell>{renderGroup(user.group)}</Table.Cell>\n                  {/*<Table.Cell>*/}\n                  {/*  {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/}\n                  {/*</Table.Cell>*/}\n                  <Table.Cell>\n                    <Popup\n                      content={t('user.table.remaining_quota')}\n                      trigger={\n                        <Label basic>{renderQuota(user.quota, t)}</Label>\n                      }\n                    />\n                    <Popup\n                      content={t('user.table.used_quota')}\n                      trigger={\n                        <Label basic>{renderQuota(user.used_quota, t)}</Label>\n                      }\n                    />\n                    <Popup\n                      content={t('user.table.request_count')}\n                      trigger={\n                        <Label basic>{renderNumber(user.request_count)}</Label>\n                      }\n                    />\n                  </Table.Cell>\n                  <Table.Cell>{renderRole(user.role, t)}</Table.Cell>\n                  <Table.Cell>{renderStatus(user.status)}</Table.Cell>\n                  <Table.Cell>\n                    <div>\n                      <Button\n                        size={'tiny'}\n                        positive\n                        onClick={() => {\n                          manageUser(user.username, 'promote', idx);\n                        }}\n                        disabled={user.role === 100}\n                      >\n                        {t('user.buttons.promote')}\n                      </Button>\n                      <Button\n                        size={'tiny'}\n                        color={'yellow'}\n                        onClick={() => {\n                          manageUser(user.username, 'demote', idx);\n                        }}\n                        disabled={user.role === 100}\n                      >\n                        {t('user.buttons.demote')}\n                      </Button>\n                      <Popup\n                        trigger={\n                          <Button\n                            size='tiny'\n                            negative\n                            disabled={user.role === 100}\n                          >\n                            {t('user.buttons.delete')}\n                          </Button>\n                        }\n                        on='click'\n                        flowing\n                        hoverable\n                      >\n                        <Button\n                          negative\n                          size={'tiny'}\n                          onClick={() => {\n                            manageUser(user.username, 'delete', idx);\n                          }}\n                        >\n                          {t('user.buttons.delete_user')} {user.username}\n                        </Button>\n                      </Popup>\n                      <Button\n                        size={'tiny'}\n                        onClick={() => {\n                          manageUser(\n                            user.username,\n                            user.status === 1 ? 'disable' : 'enable',\n                            idx\n                          );\n                        }}\n                        disabled={user.role === 100}\n                      >\n                        {user.status === 1\n                          ? t('user.buttons.disable')\n                          : t('user.buttons.enable')}\n                      </Button>\n                      <Button\n                        size={'tiny'}\n                        as={Link}\n                        to={'/user/edit/' + user.id}\n                      >\n                        {t('user.buttons.edit')}\n                      </Button>\n                    </div>\n                  </Table.Cell>\n                </Table.Row>\n              );\n            })}\n        </Table.Body>\n\n        <Table.Footer>\n          <Table.Row>\n            <Table.HeaderCell colSpan='7'>\n              <Button size='small' as={Link} to='/user/add' loading={loading}>\n                {t('user.buttons.add')}\n              </Button>\n              <Dropdown\n                placeholder={t('user.table.sort_by')}\n                selection\n                options={[\n                  { key: '', text: t('user.table.sort.default'), value: '' },\n                  {\n                    key: 'quota',\n                    text: t('user.table.sort.by_quota'),\n                    value: 'quota',\n                  },\n                  {\n                    key: 'used_quota',\n                    text: t('user.table.sort.by_used_quota'),\n                    value: 'used_quota',\n                  },\n                  {\n                    key: 'request_count',\n                    text: t('user.table.sort.by_request_count'),\n                    value: 'request_count',\n                  },\n                ]}\n                value={orderBy}\n                onChange={handleOrderByChange}\n                style={{ marginLeft: '10px' }}\n              />\n              <Pagination\n                floated='right'\n                activePage={activePage}\n                onPageChange={onPaginationChange}\n                size='small'\n                siblingRange={1}\n                totalPages={\n                  Math.ceil(users.length / ITEMS_PER_PAGE) +\n                  (users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)\n                }\n              />\n            </Table.HeaderCell>\n          </Table.Row>\n        </Table.Footer>\n      </Table>\n    </>\n  );\n};\n\nexport default UsersTable;\n"
  },
  {
    "path": "web/default/src/components/utils.js",
    "content": "import { API, showError } from '../helpers';\n\nexport async function getOAuthState() {\n  const res = await API.get('/api/oauth/state');\n  const { success, message, data } = res.data;\n  if (success) {\n    return data;\n  } else {\n    showError(message);\n    return '';\n  }\n}\n\nexport async function onGitHubOAuthClicked(github_client_id) {\n  const state = await getOAuthState();\n  if (!state) return;\n  window.open(\n    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`\n  );\n}\n\nexport async function onLarkOAuthClicked(lark_client_id) {\n  const state = await getOAuthState();\n  if (!state) return;\n  let redirect_uri = `${window.location.origin}/oauth/lark`;\n  window.open(\n    `https://open.feishu.cn/open-apis/authen/v1/index?redirect_uri=${redirect_uri}&app_id=${lark_client_id}&state=${state}`\n  );\n}"
  },
  {
    "path": "web/default/src/constants/channel.constants.js",
    "content": "export const CHANNEL_OPTIONS = [\n  { key: 1, text: 'OpenAI', value: 1, color: 'green' },\n  {\n    key: 50,\n    text: 'OpenAI 兼容',\n    value: 50,\n    color: 'olive',\n    description: 'OpenAI 兼容渠道，支持设置 Base URL',\n  },\n  {key: 14, text: 'Anthropic', value: 14, color: 'black'},\n  { key: 33, text: 'AWS', value: 33, color: 'black' },\n  {key: 3, text: 'Azure', value: 3, color: 'olive'},\n  {key: 11, text: 'PaLM2', value: 11, color: 'orange'},\n  {key: 24, text: 'Gemini', value: 24, color: 'orange'},\n  {\n    key: 51,\n    text: 'Gemini (OpenAI)',\n    value: 51,\n    color: 'orange',\n    description: 'Gemini OpenAI 兼容格式',\n  },\n  { key: 28, text: 'Mistral AI', value: 28, color: 'orange' },\n  { key: 41, text: 'Novita', value: 41, color: 'purple' },\n  {\n    key: 40,\n    text: '字节火山引擎',\n    value: 40,\n    color: 'blue',\n    description: '原字节跳动豆包',\n  },\n  {\n    key: 15,\n    text: '百度文心千帆',\n    value: 15,\n    color: 'blue',\n    tip: '请前往<a href=\"https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application/v1\" target=\"_blank\">此处</a>获取 AK（API Key）以及 SK（Secret Key），注意，V2 版本接口请使用 <strong>百度文心千帆 V2 </strong>渠道类型',\n  },\n  {\n    key: 47,\n    text: '百度文心千帆 V2',\n    value: 47,\n    color: 'blue',\n    tip: '请前往<a href=\"https://console.bce.baidu.com/iam/#/iam/apikey/list\" target=\"_blank\">此处</a>获取 API Key，注意本渠道仅支持<a target=\"_blank\" href=\"https://cloud.baidu.com/doc/WENXINWORKSHOP/s/em4tsqo3v\">推理服务 V2</a>相关模型',\n  },\n  {\n    key: 17,\n    text: '阿里通义千问',\n    value: 17,\n    color: 'orange',\n    tip: '如需使用阿里云百炼，请使用<strong>阿里云百炼</strong>渠道',\n  },\n  { key: 49, text: '阿里云百炼', value: 49, color: 'orange' },\n  {\n    key: 18,\n    text: '讯飞星火认知',\n    value: 18,\n    color: 'blue',\n    tip: '本渠道基于讯飞 WebSocket 版本 API，如需 HTTP 版本，请使用<strong>讯飞星火认知 V2</strong>渠道',\n  },\n  {\n    key: 48,\n    text: '讯飞星火认知 V2',\n    value: 48,\n    color: 'blue',\n    tip: 'HTTP 版本的讯飞接口，前往<a href=\"https://console.xfyun.cn/services/cbm\" target=\"_blank\">此处</a>获取 HTTP 服务接口认证密钥',\n  },\n  { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' },\n  { key: 19, text: '360 智脑', value: 19, color: 'blue' },\n  { key: 25, text: 'Moonshot AI', value: 25, color: 'black' },\n  { key: 23, text: '腾讯混元', value: 23, color: 'teal' },\n  { key: 26, text: '百川大模型', value: 26, color: 'orange' },\n  { key: 27, text: 'MiniMax', value: 27, color: 'red' },\n  { key: 29, text: 'Groq', value: 29, color: 'orange' },\n  { key: 30, text: 'Ollama', value: 30, color: 'black' },\n  { key: 31, text: '零一万物', value: 31, color: 'green' },\n  { key: 32, text: '阶跃星辰', value: 32, color: 'blue' },\n  { key: 34, text: 'Coze', value: 34, color: 'blue' },\n  { key: 35, text: 'Cohere', value: 35, color: 'blue' },\n  { key: 36, text: 'DeepSeek', value: 36, color: 'black' },\n  { key: 37, text: 'Cloudflare', value: 37, color: 'orange' },\n  { key: 38, text: 'DeepL', value: 38, color: 'black' },\n  { key: 39, text: 'together.ai', value: 39, color: 'blue' },\n  { key: 42, text: 'VertexAI', value: 42, color: 'blue' },\n  { key: 43, text: 'Proxy', value: 43, color: 'blue' },\n  { key: 44, text: 'SiliconFlow', value: 44, color: 'blue' },\n  { key: 45, text: 'xAI', value: 45, color: 'blue' },\n  { key: 46, text: 'Replicate', value: 46, color: 'blue' },\n  {\n    key: 8,\n    text: '自定义渠道',\n    value: 8,\n    color: 'pink',\n    tip: '不推荐使用，请使用 <strong>OpenAI 兼容</strong>渠道类型。注意，这里所需要填入的代理地址仅会在实际请求时替换域名部分，如果你想填入 OpenAI SDK 中所要求的 Base URL，请使用 OpenAI 兼容渠道类型',\n    description: '不推荐使用，请使用 OpenAI 兼容渠道类型',\n  },\n  { key: 22, text: '知识库：FastGPT', value: 22, color: 'blue' },\n  { key: 21, text: '知识库：AI Proxy', value: 21, color: 'purple' },\n  { key: 20, text: 'OpenRouter', value: 20, color: 'black' },\n  { key: 2, text: '代理：API2D', value: 2, color: 'blue' },\n  { key: 5, text: '代理：OpenAI-SB', value: 5, color: 'brown' },\n  { key: 7, text: '代理：OhMyGPT', value: 7, color: 'purple' },\n  { key: 10, text: '代理：AI Proxy', value: 10, color: 'purple' },\n  { key: 4, text: '代理：CloseAI', value: 4, color: 'teal' },\n  { key: 6, text: '代理：OpenAI Max', value: 6, color: 'violet' },\n  { key: 9, text: '代理：AI.LS', value: 9, color: 'yellow' },\n  { key: 12, text: '代理：API2GPT', value: 12, color: 'blue' },\n  { key: 13, text: '代理：AIGC2D', value: 13, color: 'purple' },\n];\n"
  },
  {
    "path": "web/default/src/constants/common.constant.js",
    "content": "export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!\n"
  },
  {
    "path": "web/default/src/constants/index.js",
    "content": "export * from './toast.constants';\nexport * from './user.constants';\nexport * from './common.constant';\nexport * from './channel.constants';"
  },
  {
    "path": "web/default/src/constants/toast.constants.js",
    "content": "export const toastConstants = {\n  SUCCESS_TIMEOUT: 5000,\n  INFO_TIMEOUT: 8000,\n  ERROR_TIMEOUT: 10000,\n  WARNING_TIMEOUT: 10000,\n  NOTICE_TIMEOUT: 20000,\n};\n"
  },
  {
    "path": "web/default/src/constants/user.constants.js",
    "content": "export const userConstants = {\n    REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',\n    REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',\n    REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',\n\n    LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',\n    LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',\n    LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',\n    \n    LOGOUT: 'USERS_LOGOUT',\n\n    GETALL_REQUEST: 'USERS_GETALL_REQUEST',\n    GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',\n    GETALL_FAILURE: 'USERS_GETALL_FAILURE',\n\n    DELETE_REQUEST: 'USERS_DELETE_REQUEST',\n    DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',\n    DELETE_FAILURE: 'USERS_DELETE_FAILURE'    \n};\n"
  },
  {
    "path": "web/default/src/context/Status/index.js",
    "content": "// contexts/User/index.jsx\n\nimport React from 'react';\nimport { initialState, reducer } from './reducer';\n\nexport const StatusContext = React.createContext({\n  state: initialState,\n  dispatch: () => null,\n});\n\nexport const StatusProvider = ({ children }) => {\n  const [state, dispatch] = React.useReducer(reducer, initialState);\n\n  return (\n    <StatusContext.Provider value={[state, dispatch]}>\n      {children}\n    </StatusContext.Provider>\n  );\n};"
  },
  {
    "path": "web/default/src/context/Status/reducer.js",
    "content": "export const reducer = (state, action) => {\n  switch (action.type) {\n    case 'set':\n      return {\n        ...state,\n        status: action.payload,\n      };\n    case 'unset':\n      return {\n        ...state,\n        status: undefined,\n      };\n    default:\n      return state;\n  }\n};\n\nexport const initialState = {\n  status: undefined,\n};\n"
  },
  {
    "path": "web/default/src/context/User/index.js",
    "content": "// contexts/User/index.jsx\n\nimport React from \"react\"\nimport { reducer, initialState } from \"./reducer\"\n\nexport const UserContext = React.createContext({\n  state: initialState,\n  dispatch: () => null\n})\n\nexport const UserProvider = ({ children }) => {\n  const [state, dispatch] = React.useReducer(reducer, initialState)\n\n  return (\n    <UserContext.Provider value={[ state, dispatch ]}>\n      { children }\n    </UserContext.Provider>\n  )\n}"
  },
  {
    "path": "web/default/src/context/User/reducer.js",
    "content": "export const reducer = (state, action) => {\n  switch (action.type) {\n    case 'login':\n      return {\n        ...state,\n        user: action.payload\n      };\n    case 'logout':\n      return {\n        ...state,\n        user: undefined\n      };\n\n    default:\n      return state;\n  }\n};\n\nexport const initialState = {\n  user: undefined\n};"
  },
  {
    "path": "web/default/src/helpers/api.js",
    "content": "import { showError } from './utils';\nimport axios from 'axios';\n\nexport const API = axios.create({\n  baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',\n});\n\nAPI.interceptors.response.use(\n  (response) => response,\n  (error) => {\n    showError(error);\n  }\n);\n"
  },
  {
    "path": "web/default/src/helpers/auth-header.js",
    "content": "export function authHeader() {\n    // return authorization header with jwt token\n    let user = JSON.parse(localStorage.getItem('user'));\n\n    if (user && user.token) {\n        return { 'Authorization': 'Bearer ' + user.token };\n    } else {\n        return {};\n    }\n}"
  },
  {
    "path": "web/default/src/helpers/helper.js",
    "content": "import {CHANNEL_OPTIONS} from '../constants';\n\nlet channelMap = undefined;\n\nexport function getChannelOption(channelId) {\n    if (channelMap === undefined) {\n        channelMap = {};\n        CHANNEL_OPTIONS.forEach((option) => {\n            channelMap[option.key] = option;\n        });\n    }\n    return channelMap[channelId];\n}\n"
  },
  {
    "path": "web/default/src/helpers/history.js",
    "content": "import { createBrowserHistory } from 'history';\n\nexport const history = createBrowserHistory();"
  },
  {
    "path": "web/default/src/helpers/index.js",
    "content": "export * from './history';\nexport * from './auth-header';\nexport * from './utils';\nexport * from './api';"
  },
  {
    "path": "web/default/src/helpers/render.js",
    "content": "import { Label, Message } from 'semantic-ui-react';\nimport { getChannelOption } from './helper';\nimport React from 'react';\n\nexport function renderText(text, limit) {\n  if (text.length > limit) {\n    return text.slice(0, limit - 3) + '...';\n  }\n  return text;\n}\n\nexport function renderGroup(group) {\n  if (group === '') {\n    return <Label>default</Label>;\n  }\n  let groups = group.split(',');\n  groups.sort();\n  return (\n    <div\n      style={{\n        display: 'flex',\n        alignItems: 'center',\n        flexWrap: 'wrap',\n        gap: '2px',\n        rowGap: '6px',\n      }}\n    >\n      {groups.map((group) => {\n        if (group === 'vip' || group === 'pro') {\n          return <Label color='yellow'>{group}</Label>;\n        } else if (group === 'svip' || group === 'premium') {\n          return <Label color='red'>{group}</Label>;\n        }\n        return <Label>{group}</Label>;\n      })}\n    </div>\n  );\n}\n\nexport function renderNumber(num) {\n  if (num >= 1000000000) {\n    return (num / 1000000000).toFixed(1) + 'B';\n  } else if (num >= 1000000) {\n    return (num / 1000000).toFixed(1) + 'M';\n  } else if (num >= 10000) {\n    return (num / 1000).toFixed(1) + 'k';\n  } else {\n    return num;\n  }\n}\n\nexport function renderQuota(quota, t, precision = 2) {\n  const displayInCurrency =\n    localStorage.getItem('display_in_currency') === 'true';\n  const quotaPerUnit = parseFloat(\n    localStorage.getItem('quota_per_unit') || '1'\n  );\n\n  if (displayInCurrency) {\n    const amount = (quota / quotaPerUnit).toFixed(precision);\n    return t('common.quota.display_short', { amount });\n  }\n\n  return renderNumber(quota);\n}\n\nexport function renderQuotaWithPrompt(quota, t) {\n  const displayInCurrency =\n    localStorage.getItem('display_in_currency') === 'true';\n  const quotaPerUnit = parseFloat(\n    localStorage.getItem('quota_per_unit') || '1'\n  );\n\n  if (displayInCurrency) {\n    const amount = (quota / quotaPerUnit).toFixed(2);\n    return ` (${t('common.quota.display', { amount })})`;\n  }\n\n  return '';\n}\n\nconst colors = [\n  'red',\n  'orange',\n  'yellow',\n  'olive',\n  'green',\n  'teal',\n  'blue',\n  'violet',\n  'purple',\n  'pink',\n  'brown',\n  'grey',\n  'black',\n];\n\nexport function renderColorLabel(text) {\n  let hash = 0;\n  for (let i = 0; i < text.length; i++) {\n    hash = text.charCodeAt(i) + ((hash << 5) - hash);\n  }\n  let index = Math.abs(hash % colors.length);\n  return (\n    <Label basic color={colors[index]}>\n      {text}\n    </Label>\n  );\n}\n\nexport function renderChannelTip(channelId) {\n  let channel = getChannelOption(channelId);\n  if (channel === undefined || channel.tip === undefined) {\n    return <></>;\n  }\n  return (\n    <Message>\n      <div dangerouslySetInnerHTML={{ __html: channel.tip }}></div>\n    </Message>\n  );\n}\n"
  },
  {
    "path": "web/default/src/helpers/utils.js",
    "content": "import {toast} from 'react-toastify';\nimport {toastConstants} from '../constants';\nimport React from 'react';\nimport {API} from './api';\n\nconst HTMLToastContent = ({ htmlContent }) => {\n  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;\n};\nexport default HTMLToastContent;\n\nexport function isAdmin() {\n  let user = localStorage.getItem('user');\n  if (!user) return false;\n  user = JSON.parse(user);\n  return user.role >= 10;\n}\n\nexport function isRoot() {\n  let user = localStorage.getItem('user');\n  if (!user) return false;\n  user = JSON.parse(user);\n  return user.role >= 100;\n}\n\nexport function getSystemName() {\n  let system_name = localStorage.getItem('system_name');\n  if (!system_name) return 'One API';\n  return system_name;\n}\n\nexport function getLogo() {\n  let logo = localStorage.getItem('logo');\n  if (!logo) return '/logo.png';\n  return logo;\n}\n\nexport function getFooterHTML() {\n  return localStorage.getItem('footer_html');\n}\n\nexport async function copy(text) {\n  let okay = true;\n  try {\n    await navigator.clipboard.writeText(text);\n  } catch (e) {\n    okay = false;\n    console.error(e);\n  }\n  return okay;\n}\n\nexport function isMobile() {\n  return window.innerWidth <= 600;\n}\n\nlet showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };\nlet showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };\nlet showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };\nlet showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };\nlet showNoticeOptions = { autoClose: false };\n\nif (isMobile()) {\n  showErrorOptions.position = 'top-center';\n  // showErrorOptions.transition = 'flip';\n\n  showSuccessOptions.position = 'top-center';\n  // showSuccessOptions.transition = 'flip';\n\n  showInfoOptions.position = 'top-center';\n  // showInfoOptions.transition = 'flip';\n\n  showNoticeOptions.position = 'top-center';\n  // showNoticeOptions.transition = 'flip';\n}\n\nexport function showError(error) {\n  if (!error) return;\n  console.error(error);\n  if (error.message) {\n    if (error.name === 'AxiosError') {\n      switch (error.response.status) {\n        case 401:\n          // toast.error('错误：未登录或登录已过期，请重新登录！', showErrorOptions);\n          window.location.href = '/login?expired=true';\n          break;\n        case 429:\n          toast.error('错误：请求次数过多，请稍后再试！', showErrorOptions);\n          break;\n        case 500:\n          toast.error('错误：服务器内部错误，请联系管理员！', showErrorOptions);\n          break;\n        case 405:\n          toast.info('本站仅作演示之用，无服务端！');\n          break;\n        default:\n          toast.error('错误：' + error.message, showErrorOptions);\n      }\n      return;\n    }\n    toast.error('错误：' + error.message, showErrorOptions);\n  } else {\n    toast.error('错误：' + error, showErrorOptions);\n  }\n}\n\nexport function showWarning(message) {\n  toast.warn(message, showWarningOptions);\n}\n\nexport function showSuccess(message) {\n  toast.success(message, showSuccessOptions);\n}\n\nexport function showInfo(message) {\n  toast.info(message, showInfoOptions);\n}\n\nexport function showNotice(message, isHTML = false) {\n  if (isHTML) {\n    toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);\n  } else {\n    toast.info(message, showNoticeOptions);\n  }\n}\n\nexport function openPage(url) {\n  window.open(url);\n}\n\nexport function removeTrailingSlash(url) {\n  if (url.endsWith('/')) {\n    return url.slice(0, -1);\n  } else {\n    return url;\n  }\n}\n\nexport function timestamp2string(timestamp) {\n  let date = new Date(timestamp * 1000);\n  let year = date.getFullYear().toString();\n  let month = (date.getMonth() + 1).toString();\n  let day = date.getDate().toString();\n  let hour = date.getHours().toString();\n  let minute = date.getMinutes().toString();\n  let second = date.getSeconds().toString();\n  if (month.length === 1) {\n    month = '0' + month;\n  }\n  if (day.length === 1) {\n    day = '0' + day;\n  }\n  if (hour.length === 1) {\n    hour = '0' + hour;\n  }\n  if (minute.length === 1) {\n    minute = '0' + minute;\n  }\n  if (second.length === 1) {\n    second = '0' + second;\n  }\n  return (\n      year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second\n  );\n}\n\nexport function downloadTextAsFile(text, filename) {\n  let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });\n  let url = URL.createObjectURL(blob);\n  let a = document.createElement('a');\n  a.href = url;\n  a.download = filename;\n  a.click();\n}\n\nexport const verifyJSON = (str) => {\n  try {\n    JSON.parse(str);\n  } catch (e) {\n    return false;\n  }\n  return true;\n};\n\nexport function shouldShowPrompt(id) {\n  let prompt = localStorage.getItem(`prompt-${id}`);\n  return !prompt;\n}\n\nexport function setPromptShown(id) {\n  localStorage.setItem(`prompt-${id}`, 'true');\n}\n\nlet channelModels = undefined;\nexport async function loadChannelModels() {\n  const res = await API.get('/api/models');\n  const { success, data } = res.data;\n  if (!success) {\n    return;\n  }\n  channelModels = data;\n  localStorage.setItem('channel_models', JSON.stringify(data));\n}\n\nexport function getChannelModels(type) {\n  if (channelModels !== undefined && type in channelModels) {\n    return channelModels[type];\n  }\n  let models = localStorage.getItem('channel_models');\n  if (!models) {\n    return [];\n  }\n  channelModels = JSON.parse(models);\n  if (type in channelModels) {\n    return channelModels[type];\n  }\n  return [];\n}\n"
  },
  {
    "path": "web/default/src/i18n.js",
    "content": "import i18n from 'i18next';\nimport {initReactI18next} from 'react-i18next';\nimport LanguageDetector from 'i18next-browser-languagedetector';\nimport zhTranslation from './locales/zh/translation.json';\nimport enTranslation from './locales/en/translation.json';\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    fallbackLng: 'zh',\n    debug: process.env.NODE_ENV === 'development',\n\n    interpolation: {\n      escapeValue: false,\n    },\n\n      resources: {\n          zh: {\n              translation: zhTranslation\n          },\n          en: {\n              translation: enTranslation\n          }\n      }\n  });\n\nexport default i18n;\n"
  },
  {
    "path": "web/default/src/index.css",
    "content": "body {\n    margin: 0;\n    padding-top: 55px;\n    overflow-y: scroll;\n    font-family: Lato, 'Helvetica Neue', Arial, Helvetica, \"Microsoft YaHei\", sans-serif;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    scrollbar-width: none;\n}\n\nbody::-webkit-scrollbar {\n    display: none;\n}\n\ncode {\n    font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;\n}\n\n.main-content {\n    padding: 4px;\n}\n\n.small-icon .icon {\n    font-size: 1em !important;\n}\n\n.custom-footer {\n    font-size: 1.1em;\n}\n\n@media only screen and (max-width: 600px) {\n    .hide-on-mobile {\n        display: none !important;\n    }\n}\n\n@media screen and (max-width: 768px) {\n  .ui.container {\n    width: 100% !important;\n    margin-left: 0 !important;\n    margin-right: 0 !important;\n    padding: 0 10px !important;\n  }\n\n  .ui.card, \n  .ui.cards,\n  .ui.segment {\n    margin-left: 0 !important;\n    margin-right: 0 !important;\n  }\n\n  .ui.table {\n    padding-left: 0 !important;\n    padding-right: 0 !important;\n  }\n}\n\n/* 小屏笔记本 (13-14寸) */\n@media screen and (min-width: 769px) and (max-width: 1366px) {\n  .ui.container {\n    width: auto !important;\n    max-width: 100% !important;\n    margin-left: auto !important;\n    margin-right: auto !important;\n    padding: 0 24px !important;\n  }\n\n  /* 调整表格显示 */\n  .ui.table {\n    font-size: 0.9em;\n  }\n\n  /* 调整卡片布局 */\n  .ui.cards {\n    margin-left: -0.5em !important;\n    margin-right: -0.5em !important;\n  }\n\n  .ui.cards > .card {\n    margin: 0.5em !important;\n    width: calc(50% - 1em) !important;\n  }\n}\n\n/* 大屏幕 */\n@media screen and (min-width: 1367px) {\n  .ui.container {\n    width: 1200px !important;\n    margin-left: auto !important;\n    margin-right: auto !important;\n    padding: 0 !important;\n  }\n}\n\n/* 优化 Dashboard 网格布局 */\n@media screen and (max-width: 1366px) {\n  .charts-grid {\n    margin: 0 -0.5em !important;\n  }\n\n  .charts-grid .column {\n    padding: 0.5em !important;\n  }\n\n  .chart-card {\n    margin: 0 !important;\n  }\n\n  /* 调整字体大小 */\n  .ui.header {\n    font-size: 1.1em !important;\n  }\n\n  .stat-value {\n    font-size: 0.9em !important;\n  }\n}\n"
  },
  {
    "path": "web/default/src/index.js",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { BrowserRouter } from 'react-router-dom';\nimport { Container } from 'semantic-ui-react';\nimport App from './App';\nimport Header from './components/Header';\nimport Footer from './components/Footer';\nimport 'semantic-ui-css/semantic.min.css';\nimport './index.css';\nimport { UserProvider } from './context/User';\nimport { ToastContainer } from 'react-toastify';\nimport 'react-toastify/dist/ReactToastify.css';\nimport { StatusProvider } from './context/Status';\nimport './i18n';\n\nconst root = ReactDOM.createRoot(document.getElementById('root'));\nroot.render(\n  <React.StrictMode>\n    <StatusProvider>\n      <UserProvider>\n        <BrowserRouter>\n          <Header />\n          <Container className={'main-content'}>\n            <App />\n          </Container>\n          <ToastContainer />\n          <Footer />\n        </BrowserRouter>\n      </UserProvider>\n    </StatusProvider>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "web/default/src/locales/en/translation.json",
    "content": "{\n  \"header\": {\n    \"home\": \"Home\",\n    \"channel\": \"Channel\",\n    \"token\": \"Token\",\n    \"redemption\": \"Redemption\",\n    \"topup\": \"Top Up\",\n    \"user\": \"User\",\n    \"dashboard\": \"Dashboard\",\n    \"log\": \"Log\",\n    \"setting\": \"Settings\",\n    \"about\": \"About\",\n    \"chat\": \"Chat\",\n    \"login\": \"Login\",\n    \"logout\": \"Logout\",\n    \"register\": \"Register\"\n  },\n  \"topup\": {\n    \"title\": \"Top Up Center\",\n    \"get_code\": {\n      \"title\": \"Get Redemption Code\",\n      \"current_quota\": \"Current Available Quota\",\n      \"button\": \"Get Code Now\"\n    },\n    \"redeem_code\": {\n      \"title\": \"Redeem Code\",\n      \"placeholder\": \"Please enter redemption code\",\n      \"paste\": \"Paste\",\n      \"paste_error\": \"Cannot access clipboard, please paste manually\",\n      \"submit\": \"Redeem Now\",\n      \"submitting\": \"Redeeming...\",\n      \"empty_code\": \"Please enter the redemption code!\",\n      \"success\": \"Top up successful!\",\n      \"request_failed\": \"Request failed\",\n      \"no_link\": \"Admin has not set up the top-up link!\"\n    }\n  },\n  \"channel\": {\n    \"title\": \"Channel Management\",\n    \"search\": \"Search channels by ID, name and key...\",\n    \"balance_notice\": \"OpenAI channels no longer support getting balance via key, so balance shows as 0. For supported channel types, click balance to refresh.\",\n    \"test_notice\": \"Channel testing only supports chat models, preferring gpt-3.5-turbo. If unavailable, uses the first model in your configured list.\",\n    \"detail_notice\": \"Click the detail button below to show balance and set additional test models.\",\n    \"table\": {\n      \"id\": \"ID\",\n      \"name\": \"Name\",\n      \"group\": \"Group\",\n      \"type\": \"Type\",\n      \"status\": \"Status\",\n      \"response_time\": \"Response Time\",\n      \"balance\": \"Balance\",\n      \"priority\": \"Priority\",\n      \"test_model\": \"Test Model\",\n      \"actions\": \"Actions\",\n      \"no_name\": \"None\",\n      \"status_enabled\": \"Enabled\",\n      \"status_disabled\": \"Disabled\",\n      \"status_auto_disabled\": \"Disabled\",\n      \"status_disabled_tip\": \"This channel is manually disabled\",\n      \"status_auto_disabled_tip\": \"This channel is automatically disabled\",\n      \"status_unknown\": \"Unknown Status\",\n      \"not_tested\": \"Not Tested\",\n      \"priority_tip\": \"Channel selection priority, higher is preferred\",\n      \"select_test_model\": \"Please select test model\",\n      \"click_to_update\": \"Click to update\",\n      \"balance_not_supported\": \"-\"\n    },\n    \"buttons\": {\n      \"test\": \"Test\",\n      \"delete\": \"Delete\",\n      \"confirm_delete\": \"Delete Channel\",\n      \"enable\": \"Enable\",\n      \"disable\": \"Disable\",\n      \"edit\": \"Edit\",\n      \"add\": \"Add New Channel\",\n      \"test_all\": \"Test All Channels\",\n      \"test_disabled\": \"Test Disabled Channels\",\n      \"delete_disabled\": \"Delete Disabled Channels\",\n      \"confirm_delete_disabled\": \"Confirm Delete\",\n      \"refresh\": \"Refresh\",\n      \"show_detail\": \"Details\",\n      \"hide_detail\": \"Hide Details\"\n    },\n    \"messages\": {\n      \"test_success\": \"Channel {{name}} test successful, model {{model}}, time {{time}}s, output: {{message}}\",\n      \"test_all_started\": \"Channel testing started successfully, please refresh page to see results.\",\n      \"delete_disabled_success\": \"Deleted all disabled channels, total: {{count}}\",\n      \"balance_update_success\": \"Channel {{name}} balance updated successfully!\",\n      \"all_balance_updated\": \"All enabled channel balances have been updated!\",\n      \"operation_success\": \"Operation completed successfully!\"\n    },\n    \"edit\": {\n      \"title_edit\": \"Update Channel Information\",\n      \"title_create\": \"Create New Channel\",\n      \"type\": \"Type\",\n      \"name\": \"Name\",\n      \"name_placeholder\": \"Please enter name\",\n      \"group\": \"Group\",\n      \"group_placeholder\": \"Please select groups that can use this channel\",\n      \"group_addition\": \"Please edit group multipliers in system settings to add new group:\",\n      \"models\": \"Models\",\n      \"models_placeholder\": \"Please select models supported by this channel\",\n      \"model_mapping\": \"Model Mapping\",\n      \"model_mapping_placeholder\": \"Optional, used to modify model names in request body. A JSON string where keys are request model names and values are target model names\",\n      \"system_prompt\": \"System Prompt\",\n      \"system_prompt_placeholder\": \"Optional, used to force set system prompt. Use with custom model & model mapping. First create a unique custom model name above, then map it to a natively supported model\",\n      \"proxy_url\": \"Proxy\",\n      \"proxy_url_placeholder\": \"This is optional and used for API calls via a proxy. Please enter the proxy URL, formatted as: https://domain.com\",\n      \"base_url\": \"Base URL\",\n      \"base_url_placeholder\": \"The Base URL required by the OpenAPI SDK\",\n      \"key\": \"Key\",\n      \"key_placeholder\": \"Please enter key\",\n      \"batch\": \"Batch Create\",\n      \"batch_placeholder\": \"Please enter keys, one per line\",\n      \"buttons\": {\n        \"cancel\": \"Cancel\",\n        \"submit\": \"Submit\",\n        \"fill_models\": \"Fill Related Models\",\n        \"fill_all\": \"Fill All Models\",\n        \"clear\": \"Clear All Models\",\n        \"add_custom\": \"Add\",\n        \"custom_placeholder\": \"Enter custom model name\"\n      },\n      \"messages\": {\n        \"name_required\": \"Please enter channel name and key!\",\n        \"models_required\": \"Please select at least one model!\",\n        \"model_mapping_invalid\": \"Model mapping must be valid JSON format!\",\n        \"update_success\": \"Channel updated successfully!\",\n        \"create_success\": \"Channel created successfully!\"\n      },\n      \"spark_version\": \"Model Version\",\n      \"spark_version_placeholder\": \"Please enter Spark model version from API URL, e.g.: v2.1\",\n      \"knowledge_id\": \"Knowledge Base ID\",\n      \"knowledge_id_placeholder\": \"Please enter knowledge base ID, e.g.: 123456\",\n      \"plugin_param\": \"Plugin Parameter\",\n      \"plugin_param_placeholder\": \"Please enter plugin parameter (X-DashScope-Plugin header value)\",\n      \"coze_notice\": \"For Coze, model name is the Bot ID. You can add prefix `bot-`, e.g.: `bot-123456`.\",\n      \"douban_notice\": \"For Douban, you need to go to\",\n      \"douban_notice_link\": \"Model Inference Page\",\n      \"douban_notice_2\": \"to create an inference endpoint, and use the endpoint name as model name, e.g.: `ep-20240608051426-tkxvl`.\",\n      \"aws_region_placeholder\": \"region, e.g.: us-west-2\",\n      \"aws_ak_placeholder\": \"AWS IAM Access Key\",\n      \"aws_sk_placeholder\": \"AWS IAM Secret Key\",\n      \"vertex_region_placeholder\": \"Vertex AI Region, e.g.: us-east5\",\n      \"vertex_project_id\": \"Vertex AI Project ID\",\n      \"vertex_project_id_placeholder\": \"Vertex AI Project ID\",\n      \"vertex_credentials\": \"Google Cloud Application Default Credentials JSON\",\n      \"vertex_credentials_placeholder\": \"Google Cloud Application Default Credentials JSON\",\n      \"user_id\": \"User ID\",\n      \"user_id_placeholder\": \"User ID who generated this key\",\n      \"key_prompts\": {\n        \"default\": \"Please enter the authentication key for this channel\",\n        \"zhipu\": \"Enter in format: APIKey|SecretKey\",\n        \"spark\": \"Enter in format: APPID|APISecret|APIKey\",\n        \"fastgpt\": \"Enter in format: APIKey-AppId, e.g.: fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041\",\n        \"tencent\": \"Enter in format: AppId|SecretId|SecretKey\"\n      }\n    }\n  },\n  \"token\": {\n    \"title\": \"Token Management\",\n    \"search\": \"Search tokens by name ...\",\n    \"table\": {\n      \"name\": \"Name\",\n      \"status\": \"Status\",\n      \"used_quota\": \"Used Quota\",\n      \"remain_quota\": \"Remaining Quota\",\n      \"created_time\": \"Created Time\",\n      \"expired_time\": \"Expiry Time\",\n      \"actions\": \"Actions\",\n      \"no_name\": \"None\",\n      \"never_expire\": \"never\",\n      \"unlimited\": \"Unlimited\",\n      \"status_enabled\": \"Enabled\",\n      \"status_disabled\": \"Disabled\",\n      \"status_expired\": \"Expired\",\n      \"status_depleted\": \"Depleted\",\n      \"status_unknown\": \"Unknown Status\"\n    },\n    \"buttons\": {\n      \"copy\": \"Copy\",\n      \"chat\": \"Chat\",\n      \"delete\": \"Delete\",\n      \"confirm_delete\": \"Delete Token\",\n      \"enable\": \"Enable\",\n      \"disable\": \"Disable\",\n      \"edit\": \"Edit\",\n      \"add\": \"Add New Token\",\n      \"refresh\": \"Refresh\"\n    },\n    \"edit\": {\n      \"title_edit\": \"Update Token Information\",\n      \"title_create\": \"Create New Token\",\n      \"name\": \"Name\",\n      \"name_placeholder\": \"Please enter name\",\n      \"models\": \"Model Scope\",\n      \"models_placeholder\": \"Please select allowed models, leave empty for no restrictions\",\n      \"ip_limit\": \"IP Restriction\",\n      \"ip_limit_placeholder\": \"Please enter allowed subnets, e.g.: 192.168.0.0/24, use commas to separate multiple subnets\",\n      \"expire_time\": \"Expiry Time\",\n      \"expire_time_placeholder\": \"Please enter expiry time in yyyy-MM-dd HH:mm:ss format, -1 for no limit\",\n      \"quota_notice\": \"Note: Token quota only limits the maximum usage of the token itself, actual usage is subject to account remaining quota.\",\n      \"quota\": \"Quota\",\n      \"quota_placeholder\": \"Please enter quota\",\n      \"buttons\": {\n        \"never_expire\": \"Never Expire\",\n        \"expire_1_month\": \"Expire in 1 Month\",\n        \"expire_1_day\": \"Expire in 1 Day\",\n        \"expire_1_hour\": \"Expire in 1 Hour\",\n        \"expire_1_minute\": \"Expire in 1 Minute\",\n        \"unlimited_quota\": \"Set Unlimited Quota\",\n        \"cancel_unlimited\": \"Cancel Unlimited Quota\",\n        \"submit\": \"Submit\",\n        \"cancel\": \"Cancel\"\n      },\n      \"messages\": {\n        \"update_success\": \"Token updated successfully!\",\n        \"create_success\": \"Token created successfully, please copy it from the list page!\",\n        \"expire_time_invalid\": \"Invalid expiry time format!\"\n      }\n    },\n    \"copy_options\": {\n      \"raw\": \"Copy Raw Token\",\n      \"ama\": \"Copy AMA Link\",\n      \"opencat\": \"Copy OpenCat Link\",\n      \"next\": \"Copy NextChat Link\",\n      \"lobe\": \"Copy LobeChat Link\"\n    },\n    \"messages\": {\n      \"copy_success\": \"Copied to clipboard!\",\n      \"copy_failed\": \"Unable to copy to clipboard, please copy manually. Token has been filled in the search box.\",\n      \"operation_success\": \"Operation completed successfully!\"\n    },\n    \"sort\": {\n      \"placeholder\": \"Sort By\",\n      \"default\": \"Default Order\",\n      \"by_remain\": \"Sort by Remaining Quota\",\n      \"by_used\": \"Sort by Used Quota\"\n    }\n  },\n  \"common\": {\n    \"quota\": {\n      \"display\": \"Equivalent: ${{amount}}\",\n      \"display_short\": \"${{amount}}\",\n      \"unit\": \"$\"\n    }\n  },\n  \"redemption\": {\n    \"title\": \"Redemption Management\",\n    \"search\": \"Search redemption codes by ID and name ...\",\n    \"table\": {\n      \"id\": \"ID\",\n      \"name\": \"Name\",\n      \"status\": \"Status\",\n      \"quota\": \"Quota\",\n      \"created_time\": \"Created Time\",\n      \"redeemed_time\": \"Redeemed Time\",\n      \"actions\": \"Actions\",\n      \"no_name\": \"None\",\n      \"not_redeemed\": \"Not Redeemed\"\n    },\n    \"buttons\": {\n      \"copy\": \"Copy\",\n      \"delete\": \"Delete\",\n      \"confirm_delete\": \"Confirm Delete\",\n      \"enable\": \"Enable\",\n      \"disable\": \"Disable\",\n      \"edit\": \"Edit\",\n      \"add\": \"Add New Code\",\n      \"refresh\": \"Refresh\"\n    },\n    \"status\": {\n      \"unused\": \"Unused\",\n      \"disabled\": \"Disabled\",\n      \"used\": \"Used\",\n      \"unknown\": \"Unknown\"\n    },\n    \"edit\": {\n      \"title_edit\": \"Update Redemption Code\",\n      \"title_create\": \"Create New Redemption Code\",\n      \"name\": \"Name\",\n      \"name_placeholder\": \"Please enter name\",\n      \"quota\": \"Quota\",\n      \"quota_placeholder\": \"Please enter quota per redemption code\",\n      \"count\": \"Generate Count\",\n      \"count_placeholder\": \"Please enter number of codes to generate\",\n      \"buttons\": {\n        \"submit\": \"Submit\",\n        \"cancel\": \"Cancel\"\n      }\n    },\n    \"messages\": {\n      \"update_success\": \"Redemption code updated successfully!\",\n      \"create_success\": \"Redemption code created successfully!\"\n    }\n  },\n  \"log\": {\n    \"title\": \"Operation Log\",\n    \"search\": \"Search logs...\",\n    \"usage_details\": \"Usage Details\",\n    \"total_quota\": \"Total Quota Used\",\n    \"click_to_view\": \"Click to View\",\n    \"type\": {\n      \"select\": \"Select Log Type\",\n      \"all\": \"All\",\n      \"topup\": \"Top Up\",\n      \"usage\": \"Usage\",\n      \"admin\": \"Admin\",\n      \"system\": \"System\",\n      \"test\": \"Test\"\n    },\n    \"table\": {\n      \"time\": \"Time\",\n      \"channel\": \"Channel\",\n      \"type\": \"Type\",\n      \"model\": \"Model\",\n      \"username\": \"Username\",\n      \"token_name\": \"Token Name\",\n      \"token_name_placeholder\": \"Optional\",\n      \"model_name\": \"Model Name\",\n      \"model_name_placeholder\": \"Optional\",\n      \"start_time\": \"Start Time\",\n      \"end_time\": \"End Time\",\n      \"channel_id\": \"Channel ID\",\n      \"channel_id_placeholder\": \"Optional\",\n      \"username_placeholder\": \"Optional\",\n      \"prompt_tokens\": \"Prompt Tokens\",\n      \"completion_tokens\": \"Completion Tokens\",\n      \"quota\": \"Quota\",\n      \"detail\": \"Detail\"\n    },\n    \"buttons\": {\n      \"query\": \"Action\",\n      \"submit\": \"Query\",\n      \"refresh\": \"Refresh\"\n    }\n  },\n  \"user\": {\n    \"title\": \"User Management\",\n    \"edit\": {\n      \"title\": \"Update User Information\",\n      \"username\": \"Username\",\n      \"username_placeholder\": \"Please enter new username\",\n      \"password\": \"Password\",\n      \"password_placeholder\": \"Please enter new password, minimum 8 characters\",\n      \"display_name\": \"Display Name\",\n      \"display_name_placeholder\": \"Please enter new display name\",\n      \"group\": \"Group\",\n      \"group_placeholder\": \"Please select group\",\n      \"group_addition\": \"Please edit group multipliers in system settings to add new group:\",\n      \"quota\": \"Remaining Quota\",\n      \"quota_placeholder\": \"Please enter new remaining quota\",\n      \"github_id\": \"Linked GitHub Account\",\n      \"github_id_placeholder\": \"Read-only, user must link through personal settings page, cannot be modified directly\",\n      \"wechat_id\": \"Linked WeChat Account\",\n      \"wechat_id_placeholder\": \"Read-only, user must link through personal settings page, cannot be modified directly\",\n      \"email\": \"Linked Email Account\",\n      \"email_placeholder\": \"Read-only, user must link through personal settings page, cannot be modified directly\",\n      \"buttons\": {\n        \"submit\": \"Submit\",\n        \"cancel\": \"Cancel\"\n      }\n    },\n    \"add\": {\n      \"title\": \"Create New User Account\"\n    },\n    \"messages\": {\n      \"update_success\": \"User information updated successfully!\",\n      \"create_success\": \"User account created successfully!\",\n      \"operation_success\": \"Operation completed successfully!\"\n    },\n    \"search\": \"Search users...\",\n    \"table\": {\n      \"id\": \"ID\",\n      \"username\": \"Username\",\n      \"group\": \"Group\",\n      \"quota\": \"Quota\",\n      \"role_text\": \"Role\",\n      \"status_text\": \"Status\",\n      \"actions\": \"Actions\",\n      \"remaining_quota\": \"Remaining Quota\",\n      \"used_quota\": \"Used Quota\",\n      \"request_count\": \"Request Count\",\n      \"role_types\": {\n        \"normal\": \"Normal User\",\n        \"admin\": \"Admin\",\n        \"super_admin\": \"Super Admin\",\n        \"unknown\": \"Unknown Role\"\n      },\n      \"status_types\": {\n        \"activated\": \"Activated\",\n        \"banned\": \"Banned\",\n        \"unknown\": \"Unknown Status\"\n      },\n      \"sort\": {\n        \"default\": \"Default Order\",\n        \"by_quota\": \"Sort by Remaining Quota\",\n        \"by_used_quota\": \"Sort by Used Quota\",\n        \"by_request_count\": \"Sort by Request Count\"\n      },\n      \"sort_by\": \"Sort By\"\n    },\n    \"buttons\": {\n      \"add\": \"Add New User\",\n      \"delete\": \"Delete\",\n      \"delete_user\": \"Delete User\",\n      \"enable\": \"Enable\",\n      \"disable\": \"Disable\",\n      \"edit\": \"Edit\",\n      \"promote\": \"Promote\",\n      \"demote\": \"Demote\"\n    }\n  },\n  \"dashboard\": {\n    \"charts\": {\n      \"requests\": {\n        \"title\": \"Model Request Trend\",\n        \"tooltip\": \"Request Count\"\n      },\n      \"quota\": {\n        \"title\": \"Quota Usage Trend\",\n        \"tooltip\": \"Quota Used\"\n      },\n      \"tokens\": {\n        \"title\": \"Token Usage Trend\",\n        \"tooltip\": \"Token Count\"\n      }\n    },\n    \"statistics\": {\n      \"title\": \"Statistics\",\n      \"tooltip\": {\n        \"date\": \"Date\",\n        \"value\": \"Value\"\n      }\n    }\n  },\n  \"setting\": {\n    \"title\": \"System Settings\",\n    \"tabs\": {\n      \"personal\": \"Personal Settings\",\n      \"operation\": \"Operation Settings\",\n      \"system\": \"System Settings\",\n      \"other\": \"Other Settings\"\n    },\n    \"personal\": {\n      \"general\": {\n        \"title\": \"General Settings\",\n        \"system_token_notice\": \"Note: The token generated here is for system management, not for requesting OpenAI related services.\",\n        \"buttons\": {\n          \"update_profile\": \"Update Profile\",\n          \"generate_token\": \"Generate System Token\",\n          \"copy_invite\": \"Copy Invite Link\",\n          \"delete_account\": \"Delete Account\"\n        }\n      },\n      \"binding\": {\n        \"title\": \"Account Binding\",\n        \"buttons\": {\n          \"bind_wechat\": \"Bind WeChat Account\",\n          \"bind_github\": \"Bind GitHub Account\",\n          \"bind_email\": \"Bind Email Address\",\n          \"bind_lark\": \"Bind Lark Account\"\n        },\n        \"wechat\": {\n          \"title\": \"WeChat Binding\",\n          \"description\": \"Scan QR code to follow the official account, enter 'verification code' to get the code (valid for 3 minutes)\",\n          \"verification_code\": \"Verification Code\",\n          \"bind\": \"Bind\"\n        },\n        \"email\": {\n          \"title\": \"Bind Email Address\",\n          \"email_placeholder\": \"Enter email address\",\n          \"code_placeholder\": \"Verification code\",\n          \"get_code\": \"Get Code\",\n          \"get_code_retry\": \"Resend({{countdown}})\",\n          \"bind\": \"Confirm Binding\",\n          \"cancel\": \"Cancel\"\n        }\n      },\n      \"delete_account\": {\n        \"title\": \"Dangerous Operation\",\n        \"warning\": \"You are deleting your account. All data will be cleared and cannot be recovered\",\n        \"confirm_placeholder\": \"Enter your username {{username}} to confirm deletion\",\n        \"buttons\": {\n          \"confirm\": \"Confirm Delete\",\n          \"cancel\": \"Cancel\"\n        }\n      }\n    },\n    \"system\": {\n      \"general\": {\n        \"title\": \"General Settings\",\n        \"server_address\": \"Server Address\",\n        \"server_address_placeholder\": \"e.g.: https://yourdomain.com\",\n        \"buttons\": {\n          \"update\": \"Update Server Address\"\n        }\n      },\n      \"login\": {\n        \"title\": \"Login & Registration Settings\",\n        \"password_login\": \"Allow Password Login\",\n        \"password_register\": \"Allow Password Registration\",\n        \"email_verification\": \"Require Email Verification for Password Registration\",\n        \"github_oauth\": \"Allow GitHub OAuth Login & Registration\",\n        \"wechat_login\": \"Allow WeChat Login & Registration\",\n        \"registration\": \"Allow New User Registration (When disabled, new users cannot register by any means)\",\n        \"turnstile\": \"Enable Turnstile User Verification\"\n      },\n      \"email_restriction\": {\n        \"title\": \"Email Domain Whitelist\",\n        \"subtitle\": \"Used to prevent malicious users from batch registering using temporary emails\",\n        \"enable\": \"Enable Email Domain Whitelist\",\n        \"allowed_domains\": \"Allowed Email Domains\",\n        \"add_domain\": \"Add New Allowed Email Domain\",\n        \"add_domain_placeholder\": \"Enter new allowed email domain\",\n        \"buttons\": {\n          \"fill\": \"Fill\",\n          \"save\": \"Save Email Domain Whitelist Settings\"\n        }\n      },\n      \"smtp\": {\n        \"title\": \"SMTP Configuration\",\n        \"subtitle\": \"Used to support system email sending\",\n        \"server\": \"SMTP Server Address\",\n        \"server_placeholder\": \"e.g.: smtp.gmail.com\",\n        \"port\": \"SMTP Port\",\n        \"port_placeholder\": \"Default: 587\",\n        \"account\": \"SMTP Account\",\n        \"account_placeholder\": \"Usually your email address\",\n        \"from\": \"SMTP Sender Email\",\n        \"from_placeholder\": \"Usually same as email address\",\n        \"token\": \"SMTP Access Token\",\n        \"token_placeholder\": \"Sensitive information will not be sent to frontend\",\n        \"buttons\": {\n          \"save\": \"Save SMTP Settings\"\n        }\n      },\n      \"github\": {\n        \"title\": \"GitHub OAuth App Configuration\",\n        \"subtitle\": \"Used to support GitHub login and registration\",\n        \"manage_link\": \"Click here\",\n        \"manage_text\": \"to manage your GitHub OAuth Apps\",\n        \"url_notice\": \"Set Homepage URL to {{server_url}}, and Authorization callback URL to {{callback_url}}\",\n        \"client_id\": \"GitHub Client ID\",\n        \"client_id_placeholder\": \"Enter your registered GitHub OAuth APP ID\",\n        \"client_secret\": \"GitHub Client Secret\",\n        \"client_secret_placeholder\": \"Sensitive information will not be sent to frontend\",\n        \"buttons\": {\n          \"save\": \"Save GitHub OAuth Settings\"\n        }\n      },\n      \"lark\": {\n        \"title\": \"Lark OAuth Configuration\",\n        \"subtitle\": \"Used to support Lark login and registration\",\n        \"manage_link\": \"Click here\",\n        \"manage_text\": \"to manage your Lark applications\",\n        \"url_notice\": \"Set Homepage URL to {{server_url}}, and Redirect URL to {{callback_url}}\",\n        \"client_id\": \"App ID\",\n        \"client_id_placeholder\": \"Enter App ID\",\n        \"client_secret\": \"App Secret\",\n        \"client_secret_placeholder\": \"Sensitive information will not be sent to frontend\",\n        \"buttons\": {\n          \"save\": \"Save Lark OAuth Settings\"\n        }\n      },\n      \"wechat\": {\n        \"title\": \"WeChat Server Configuration\",\n        \"subtitle\": \"Used to support WeChat login and registration\",\n        \"learn_more\": \"Learn about WeChat Server\",\n        \"server_address\": \"WeChat Server Address\",\n        \"server_address_placeholder\": \"e.g.: https://yourdomain.com\",\n        \"token\": \"WeChat Server Access Token\",\n        \"token_placeholder\": \"Sensitive information will not be sent to frontend\",\n        \"qrcode\": \"WeChat Official Account QR Code Image URL\",\n        \"qrcode_placeholder\": \"Enter an image URL\",\n        \"buttons\": {\n          \"save\": \"Save WeChat Server Settings\"\n        },\n        \"scan_tip\": \"Scan QR code to follow WeChat Official Account, enter 'code' to get verification code (valid for 3 minutes)\",\n        \"code_placeholder\": \"Verification code\"\n      },\n      \"turnstile\": {\n        \"title\": \"Turnstile Configuration\",\n        \"subtitle\": \"Used to support user verification\",\n        \"manage_link\": \"Click here\",\n        \"manage_text\": \"to manage your Turnstile Sites, Invisible Widget Type recommended\",\n        \"site_key\": \"Turnstile Site Key\",\n        \"site_key_placeholder\": \"Enter your registered Turnstile Site Key\",\n        \"secret_key\": \"Turnstile Secret Key\",\n        \"secret_key_placeholder\": \"Sensitive information will not be sent to frontend\",\n        \"buttons\": {\n          \"save\": \"Save Turnstile Settings\"\n        }\n      },\n      \"password_login\": {\n        \"warning\": {\n          \"title\": \"Warning\",\n          \"content\": \"Disabling password login will prevent all users (including administrators) who haven't bound other login methods from logging in via password. Confirm disable?\",\n          \"buttons\": {\n            \"confirm\": \"Confirm\",\n            \"cancel\": \"Cancel\"\n          }\n        }\n      }\n    },\n    \"operation\": {\n      \"quota\": {\n        \"title\": \"Quota Settings\",\n        \"new_user\": \"Initial Quota for New Users\",\n        \"new_user_placeholder\": \"e.g.: 100\",\n        \"pre_consume\": \"Pre-consumed Quota per Request\",\n        \"pre_consume_placeholder\": \"Refund or charge difference after request\",\n        \"inviter_reward\": \"Reward Quota for Inviter\",\n        \"inviter_reward_placeholder\": \"e.g.: 2000\",\n        \"invitee_reward\": \"Reward Quota for Using Invite Code\",\n        \"invitee_reward_placeholder\": \"e.g.: 1000\",\n        \"buttons\": {\n          \"save\": \"Save Quota Settings\"\n        }\n      },\n      \"ratio\": {\n        \"title\": \"Ratio Settings\",\n        \"model\": {\n          \"title\": \"Model Ratio\",\n          \"placeholder\": \"A JSON text where keys are model names and values are ratios\"\n        },\n        \"completion\": {\n          \"title\": \"Completion Ratio\",\n          \"placeholder\": \"A JSON text where keys are model names and values are ratios. These ratios are the proportion of completion to prompt ratio, which can override One API's internal ratios\"\n        },\n        \"group\": {\n          \"title\": \"Group Ratio\",\n          \"placeholder\": \"A JSON text where keys are group names and values are ratios\"\n        },\n        \"buttons\": {\n          \"save\": \"Save Ratio Settings\"\n        }\n      },\n      \"log\": {\n        \"title\": \"Log Settings\",\n        \"enable_consume\": \"Enable Quota Consumption Logging\",\n        \"target_time\": \"Target Time\",\n        \"buttons\": {\n          \"clean\": \"Clean Historical Logs\"\n        }\n      },\n      \"monitor\": {\n        \"title\": \"Monitor Settings\",\n        \"max_response_time\": \"Maximum Response Time\",\n        \"max_response_time_placeholder\": \"In seconds, channels exceeding this time during testing will be automatically disabled\",\n        \"quota_reminder\": \"Quota Reminder Threshold\",\n        \"quota_reminder_placeholder\": \"Users will receive email reminders when quota falls below this value\",\n        \"auto_disable\": \"Automatically Disable Channel on Failure\",\n        \"auto_enable\": \"Automatically Enable Channel on Success\",\n        \"buttons\": {\n          \"save\": \"Save Monitor Settings\"\n        }\n      },\n      \"general\": {\n        \"title\": \"General Settings\",\n        \"topup_link\": \"Top-up Link\",\n        \"topup_link_placeholder\": \"e.g.: Card selling website purchase link\",\n        \"chat_link\": \"Chat Page Link\",\n        \"chat_link_placeholder\": \"e.g.: ChatGPT Next Web deployment address\",\n        \"quota_per_unit\": \"Quota per Dollar\",\n        \"quota_per_unit_placeholder\": \"Quota exchangeable per unit of currency\",\n        \"retry_times\": \"Retry Times on Failure\",\n        \"retry_times_placeholder\": \"Number of retry attempts on failure\",\n        \"display_in_currency\": \"Display Quota in Currency Format\",\n        \"display_token_stat\": \"Show Token Quota Instead of User Quota in Billing APIs\",\n        \"approximate_token\": \"Use Approximate Method to Estimate Token Count\",\n        \"buttons\": {\n          \"save\": \"Save General Settings\"\n        }\n      }\n    },\n    \"other\": {\n      \"notice\": {\n        \"title\": \"Notice Settings\",\n        \"content\": \"Notice Content\",\n        \"content_placeholder\": \"Enter new notice content here, supports Markdown & HTML code\",\n        \"buttons\": {\n          \"save\": \"Save Notice\"\n        }\n      },\n      \"system\": {\n        \"title\": \"System Settings\",\n        \"name\": \"System Name\",\n        \"name_placeholder\": \"Please enter system name\",\n        \"logo\": \"Logo Image URL\",\n        \"logo_placeholder\": \"Enter Logo image URL here\",\n        \"theme\": {\n          \"title\": \"Theme Name\",\n          \"link\": \"Available Themes\",\n          \"placeholder\": \"Please enter theme name\"\n        },\n        \"buttons\": {\n          \"save_name\": \"Set System Name\",\n          \"save_logo\": \"Set Logo\",\n          \"save_theme\": \"Set Theme (Restart Required)\"\n        }\n      },\n      \"content\": {\n        \"title\": \"Content Settings\",\n        \"homepage\": {\n          \"title\": \"Homepage Content\",\n          \"placeholder\": \"Enter homepage content here, supports Markdown & HTML code. Status information will not be shown after setting. If a link is entered, it will be used as the src attribute of an iframe, allowing you to set any webpage as homepage.\"\n        },\n        \"about\": {\n          \"title\": \"About System\",\n          \"description\": \"You can set about content in settings page, supports HTML & Markdown\",\n          \"repository\": \"Project Repository:\",\n          \"loading_failed\": \"Failed to load about content...\"\n        },\n        \"footer\": {\n          \"title\": \"Footer\",\n          \"placeholder\": \"Enter new footer here, leave empty to use default footer, supports HTML code\"\n        },\n        \"buttons\": {\n          \"save_homepage\": \"Save Homepage Content\",\n          \"save_about\": \"Save About\",\n          \"save_footer\": \"Set Footer\"\n        }\n      },\n      \"copyright\": {\n        \"notice\": \"Removing One API's copyright notice requires authorization. Project maintenance requires significant effort, if this project is meaningful to you, please actively support it.\"\n      }\n    }\n  },\n  \"footer\": {\n    \"built_by\": \"built by\",\n    \"built_by_name\": \"JustSong\",\n    \"license\": \", source code is licensed under the\",\n    \"mit\": \"MIT License\"\n  },\n  \"home\": {\n    \"welcome\": {\n      \"title\": \"Welcome to One API\",\n      \"description\": \"One API is a LLM API management and distribution system that helps you better manage and use LLM APIs from various providers.\",\n      \"login_notice\": \"To use the service, please login or register first.\"\n    },\n    \"system_status\": {\n      \"title\": \"System Status\",\n      \"info\": {\n        \"title\": \"System Information\",\n        \"name\": \"Name: \",\n        \"version\": \"Version: \",\n        \"source\": \"Source: \",\n        \"source_link\": \"GitHub Repository\",\n        \"start_time\": \"Start Time: \"\n      },\n      \"config\": {\n        \"title\": \"System Configuration\",\n        \"email_verify\": \"Email Verification: \",\n        \"github_oauth\": \"GitHub OAuth: \",\n        \"wechat_login\": \"WeChat Login: \",\n        \"turnstile\": \"Turnstile Check: \",\n        \"enabled\": \"Enabled\",\n        \"disabled\": \"Disabled\"\n      }\n    },\n    \"loading_failed\": \"Failed to load homepage content...\"\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"User Login\",\n      \"username\": \"Username / Email\",\n      \"password\": \"Password\",\n      \"button\": \"Login\",\n      \"forgot_password\": \"Forgot password?\",\n      \"reset_password\": \"Reset\",\n      \"no_account\": \"No account?\",\n      \"register\": \"Register\",\n      \"other_methods\": \"Other login methods\",\n      \"wechat\": {\n        \"scan_tip\": \"Scan QR code to follow WeChat Official Account, enter 'code' to get verification code (valid for 3 minutes)\",\n        \"code_placeholder\": \"Verification code\"\n      }\n    },\n    \"register\": {\n      \"title\": \"New User Registration\",\n      \"username\": \"Username (max 12 characters)\",\n      \"password\": \"Password (8-20 characters)\",\n      \"confirm_password\": \"Confirm password\",\n      \"email\": \"Email address\",\n      \"verification_code\": \"Verification code\",\n      \"get_code\": \"Get code\",\n      \"get_code_retry\": \"Retry ({{countdown}})\",\n      \"button\": \"Register\",\n      \"has_account\": \"Have an account?\",\n      \"login\": \"Login\"\n    },\n    \"reset\": {\n      \"title\": \"Password Reset\",\n      \"email\": \"Email address\",\n      \"button\": \"Submit\",\n      \"notice\": \"The system will send an email containing a reset link to your mailbox. Please check your email.\",\n      \"confirm\": {\n        \"title\": \"Password Reset Confirmation\",\n        \"new_password\": \"New password\",\n        \"button\": \"Submit\",\n        \"button_disabled\": \"Password reset completed\",\n        \"notice\": \"New password has been generated, please click the password field or button above to copy. Please login and change your password as soon as possible!\"\n      }\n    }\n  },\n  \"about\": {\n    \"title\": \"About\",\n    \"description\": \"One API is an open-source API management and proxy platform.\",\n    \"repository\": \"Repository: \",\n    \"loading_failed\": \"Loading failed\"\n  },\n  \"messages\": {\n    \"success\": {\n      \"login\": \"Login successful!\",\n      \"register\": \"Registration successful!\",\n      \"verification_code\": \"Verification code sent, please check your email!\",\n      \"password_reset\": \"Reset email sent, please check your inbox!\"\n    },\n    \"error\": {\n      \"login_expired\": \"Not logged in or session expired, please login again!\",\n      \"password_length\": \"Password must be at least 8 characters!\",\n      \"password_mismatch\": \"Passwords do not match\",\n      \"turnstile_wait\": \"Please wait a few seconds, Turnstile is checking the environment!\",\n      \"root_password\": \"Please change the default password immediately!\"\n    },\n    \"notice\": {\n      \"password_copied\": \"New password copied to clipboard: {{password}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/default/src/locales/zh/translation.json",
    "content": "{\n  \"header\": {\n    \"home\": \"首页\",\n    \"channel\": \"渠道\",\n    \"token\": \"令牌\",\n    \"redemption\": \"兑换\",\n    \"topup\": \"充值\",\n    \"user\": \"用户\",\n    \"dashboard\": \"总览\",\n    \"log\": \"日志\",\n    \"setting\": \"设置\",\n    \"about\": \"关于\",\n    \"chat\": \"聊天\",\n    \"login\": \"登录\",\n    \"logout\": \"注销\",\n    \"register\": \"注册\"\n  },\n  \"topup\": {\n    \"title\": \"充值中心\",\n    \"get_code\": {\n      \"title\": \"获取兑换码\",\n      \"current_quota\": \"当前可用额度\",\n      \"button\": \"立即获取兑换码\"\n    },\n    \"redeem_code\": {\n      \"title\": \"兑换码充值\",\n      \"placeholder\": \"请输入兑换码\",\n      \"paste\": \"粘贴\",\n      \"paste_error\": \"无法访问剪贴板，请手动粘贴\",\n      \"submit\": \"立即兑换\",\n      \"submitting\": \"兑换中...\",\n      \"empty_code\": \"请输入兑换码！\",\n      \"success\": \"充值成功！\",\n      \"request_failed\": \"请求失败\",\n      \"no_link\": \"超级管理员未设置充值链接！\"\n    }\n  },\n  \"channel\": {\n    \"title\": \"管理渠道\",\n    \"search\": \"搜索渠道的 ID，名称和密钥 ...\",\n    \"balance_notice\": \"OpenAI 渠道已经不再支持通过 key 获取余额，因此余额显示为 0。对于支持的渠道类型，请点击余额进行刷新。\",\n    \"test_notice\": \"渠道测试仅支持 chat 模型，优先使用 gpt-3.5-turbo，如果该模型不可用则使用你所配置的模型列表中的第一个模型。\",\n    \"detail_notice\": \"点击下方详情按钮可以显示余额以及设置额外的测试模型。\",\n    \"table\": {\n      \"id\": \"ID\",\n      \"name\": \"名称\",\n      \"group\": \"分组\",\n      \"type\": \"类型\",\n      \"status\": \"状态\",\n      \"response_time\": \"响应时间\",\n      \"balance\": \"余额\",\n      \"priority\": \"优先级\",\n      \"test_model\": \"测试模型\",\n      \"actions\": \"操作\",\n      \"no_name\": \"无\",\n      \"status_enabled\": \"已启用\",\n      \"status_disabled\": \"已禁用\",\n      \"status_auto_disabled\": \"已禁用\",\n      \"status_disabled_tip\": \"本渠道被手动禁用\",\n      \"status_auto_disabled_tip\": \"本渠道被程序自动禁用\",\n      \"status_unknown\": \"未知状态\",\n      \"not_tested\": \"未测试\",\n      \"priority_tip\": \"渠道选择优先级，越高越优先\",\n      \"select_test_model\": \"请选择测试模型\",\n      \"click_to_update\": \"点击更新\",\n      \"balance_not_supported\": \"-\"\n    },\n    \"buttons\": {\n      \"test\": \"测试\",\n      \"delete\": \"删除\",\n      \"confirm_delete\": \"删除渠道\",\n      \"enable\": \"启用\",\n      \"disable\": \"禁用\",\n      \"edit\": \"编辑\",\n      \"add\": \"添加新的渠道\",\n      \"test_all\": \"测试所有渠道\",\n      \"test_disabled\": \"测试禁用渠道\",\n      \"delete_disabled\": \"删除禁用渠道\",\n      \"confirm_delete_disabled\": \"确认删除\",\n      \"refresh\": \"刷新\",\n      \"show_detail\": \"详情\",\n      \"hide_detail\": \"隐藏详情\"\n    },\n    \"messages\": {\n      \"test_success\": \"渠道 {{name}} 测试成功，模型 {{model}}，耗时 {{time}} 秒，模型输出：{{message}}\",\n      \"test_all_started\": \"已成功开始测试渠道，请刷新页面查看结果。\",\n      \"delete_disabled_success\": \"已删除所有禁用渠道，共计 {{count}} 个\",\n      \"balance_update_success\": \"渠道 {{name}} 余额更新成功！\",\n      \"all_balance_updated\": \"已更新完毕所有已启用渠道余额！\",\n      \"operation_success\": \"操作成功完成！\"\n    },\n    \"edit\": {\n      \"title_edit\": \"更新渠道信息\",\n      \"title_create\": \"创建新的渠道\",\n      \"type\": \"类型\",\n      \"name\": \"名称\",\n      \"name_placeholder\": \"请输入名称\",\n      \"group\": \"分组\",\n      \"group_placeholder\": \"请选择可以使用该渠道的分组\",\n      \"group_addition\": \"请在系统设置页面编辑分组倍率以添加新的分组：\",\n      \"models\": \"模型\",\n      \"models_placeholder\": \"请选择该渠道所支持的模型\",\n      \"model_mapping\": \"模型重定向\",\n      \"model_mapping_placeholder\": \"此项可选，用于修改请求体中的模型名称，为一个 JSON 字符串，键为请求中模型名称，值为要替换的模型名称\",\n      \"system_prompt\": \"系统提示词\",\n      \"system_prompt_placeholder\": \"此项可选，用于强制设置给定的系统提示词，请配合自定义模型 & 模型重定向使用，首先创建一个唯一的自定义模型名称并在上面填入，之后将该自定义模型重定向映射到该渠道一个原生支持的模型\",\n      \"proxy_url\": \"代理\",\n      \"proxy_url_placeholder\": \"此项可选，用于通过代理站来进行 API 调用，请输入代理站地址，格式为：https://domain.com。注意，这里所需要填入的代理地址仅会在实际请求时替换域名部分，如果你想填入 OpenAI SDK 中所要求的 Base URL，请使用 OpenAI 兼容渠道类型\",\n      \"base_url\": \"Base URL\",\n      \"base_url_placeholder\": \"OpenAPI SDK 中所要求的 Base URL\",\n      \"key\": \"密钥\",\n      \"key_placeholder\": \"请输入密钥\",\n      \"batch\": \"批量创建\",\n      \"batch_placeholder\": \"请输入密钥，一行一个\",\n      \"buttons\": {\n        \"cancel\": \"取消\",\n        \"submit\": \"提交\",\n        \"fill_models\": \"填入相关模型\",\n        \"fill_all\": \"填入所有模型\",\n        \"clear\": \"清除所有模型\",\n        \"add_custom\": \"填入\",\n        \"custom_placeholder\": \"输入自定义模型名称\"\n      },\n      \"messages\": {\n        \"name_required\": \"请填写渠道名称和渠道密钥！\",\n        \"models_required\": \"请至少选择一个模型！\",\n        \"model_mapping_invalid\": \"模型映射必须是合法的 JSON 格式！\",\n        \"update_success\": \"渠道更新成功！\",\n        \"create_success\": \"渠道创建成功！\"\n      },\n      \"spark_version\": \"模型版本\",\n      \"spark_version_placeholder\": \"请输入星火大模型版本，注意是接口地址中的版本号，例如：v2.1\",\n      \"knowledge_id\": \"知识库 ID\",\n      \"knowledge_id_placeholder\": \"请输入知识库 ID，例如：123456\",\n      \"plugin_param\": \"插件参数\",\n      \"plugin_param_placeholder\": \"请输入插件参数，即 X-DashScope-Plugin 请求头的取值\",\n      \"coze_notice\": \"对于 Coze 而言，模型名称即 Bot ID，你可以添加一个前缀 `bot-`，例如：`bot-123456`。\",\n      \"douban_notice\": \"对于豆包而言，需要手动去\",\n      \"douban_notice_link\": \"模型推理页面\",\n      \"douban_notice_2\": \"创建推理接入点，以接入点名称作为模型名称，例如：`ep-20240608051426-tkxvl`。你可以结合模型重定向功能将其转换为常规的模型名称，例如：doubao-lite-4k -> ep-20240608051426-tkxvl（前者作为 JSON 的 key，后者作为 value）。注意，doubao-lite-4k 和 ep-20240608051426-tkxvl 都需要通过自定义模型的方式填入到本渠道的模型列表中。\",\n      \"aws_region_placeholder\": \"region，例如：us-west-2\",\n      \"aws_ak_placeholder\": \"AWS IAM Access Key\",\n      \"aws_sk_placeholder\": \"AWS IAM Secret Key\",\n      \"vertex_region_placeholder\": \"Vertex AI Region，例如：us-east5\",\n      \"vertex_project_id\": \"Vertex AI Project ID\",\n      \"vertex_project_id_placeholder\": \"Vertex AI Project ID\",\n      \"vertex_credentials\": \"Google Cloud Application Default Credentials JSON\",\n      \"vertex_credentials_placeholder\": \"Google Cloud Application Default Credentials JSON\",\n      \"user_id\": \"User ID\",\n      \"user_id_placeholder\": \"生成该密钥的用户 ID\",\n      \"key_prompts\": {\n        \"default\": \"请输入渠道对应的鉴权密钥\",\n        \"zhipu\": \"按照如下格式输入：APIKey|SecretKey\",\n        \"spark\": \"按照如下格式输入：APPID|APISecret|APIKey\",\n        \"fastgpt\": \"按照如下格式输入：APIKey-AppId，例如：fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041\",\n        \"tencent\": \"按照如下格式输入：AppId|SecretId|SecretKey\"\n      }\n    }\n  },\n  \"token\": {\n    \"title\": \"令牌管理\",\n    \"search\": \"搜索令牌的名称 ...\",\n    \"table\": {\n      \"name\": \"名称\",\n      \"status\": \"状态\",\n      \"used_quota\": \"已用额度\",\n      \"remain_quota\": \"剩余额度\",\n      \"created_time\": \"创建时间\",\n      \"expired_time\": \"过期时间\",\n      \"actions\": \"操作\",\n      \"no_name\": \"无\",\n      \"never_expire\": \"永不过期\",\n      \"unlimited\": \"无限制\",\n      \"status_enabled\": \"已启用\",\n      \"status_disabled\": \"已禁用\",\n      \"status_expired\": \"已过期\",\n      \"status_depleted\": \"已耗尽\",\n      \"status_unknown\": \"未知状态\"\n    },\n    \"buttons\": {\n      \"copy\": \"复制\",\n      \"chat\": \"聊天\",\n      \"delete\": \"删除\",\n      \"confirm_delete\": \"删除令牌\",\n      \"enable\": \"启用\",\n      \"disable\": \"禁用\",\n      \"edit\": \"编辑\",\n      \"add\": \"添加新的令牌\",\n      \"refresh\": \"刷新\"\n    },\n    \"edit\": {\n      \"title_edit\": \"更新令牌信息\",\n      \"title_create\": \"创建新的令牌\",\n      \"name\": \"名称\",\n      \"name_placeholder\": \"请输入名称\",\n      \"models\": \"模型范围\",\n      \"models_placeholder\": \"请选择允许使用的模型，留空则不进行限制\",\n      \"ip_limit\": \"IP 限制\",\n      \"ip_limit_placeholder\": \"请输入允许访问的网段，例如：192.168.0.0/24，请使用英文逗号分隔多个网段\",\n      \"expire_time\": \"过期时间\",\n      \"expire_time_placeholder\": \"请输入过期时间，格式为 yyyy-MM-dd HH:mm:ss，-1 表示无限制\",\n      \"quota_notice\": \"注意，令牌的额度仅用于限制令牌本身的最大额度使用量，实际的使用受到账户的剩余额度限制。\",\n      \"quota\": \"额度\",\n      \"quota_placeholder\": \"请输入额度\",\n      \"buttons\": {\n        \"never_expire\": \"永不过期\",\n        \"expire_1_month\": \"一个月后过期\",\n        \"expire_1_day\": \"一天后过期\",\n        \"expire_1_hour\": \"一小时后过期\",\n        \"expire_1_minute\": \"一分钟后过期\",\n        \"unlimited_quota\": \"设为无限额度\",\n        \"cancel_unlimited\": \"取消无限额度\",\n        \"submit\": \"提交\",\n        \"cancel\": \"取消\"\n      },\n      \"messages\": {\n        \"update_success\": \"令牌更新成功！\",\n        \"create_success\": \"令牌创建成功，请在列表页面点击复制获取令牌！\",\n        \"expire_time_invalid\": \"过期时间格式错误！\"\n      }\n    },\n    \"copy_options\": {\n      \"raw\": \"复制原始令牌\",\n      \"ama\": \"复制 AMA 链接\",\n      \"opencat\": \"复制 OpenCat 链接\",\n      \"next\": \"复制 NextChat 链接\",\n      \"lobe\": \"复制 LobeChat 链接\"\n    },\n    \"messages\": {\n      \"copy_success\": \"已复制到剪贴板！\",\n      \"copy_failed\": \"无法复制到剪贴板，请手动复制，已将令牌填入搜索框。\",\n      \"operation_success\": \"操作成功完成！\"\n    },\n    \"sort\": {\n      \"placeholder\": \"排序方式\",\n      \"default\": \"默认排序\",\n      \"by_remain\": \"按剩余额度排序\",\n      \"by_used\": \"按已用额度排序\"\n    }\n  },\n  \"common\": {\n    \"quota\": {\n      \"display\": \"等价金额：${{amount}}\",\n      \"display_short\": \"${{amount}}\",\n      \"unit\": \"$\"\n    }\n  },\n  \"redemption\": {\n    \"title\": \"兑换管理\",\n    \"search\": \"搜索兑换码的 ID 和名称 ...\",\n    \"table\": {\n      \"id\": \"ID\",\n      \"name\": \"名称\",\n      \"status\": \"状态\",\n      \"quota\": \"额度\",\n      \"created_time\": \"创建时间\",\n      \"redeemed_time\": \"兑换时间\",\n      \"actions\": \"操作\",\n      \"no_name\": \"无\",\n      \"not_redeemed\": \"尚未兑换\"\n    },\n    \"buttons\": {\n      \"copy\": \"复制\",\n      \"delete\": \"删除\",\n      \"confirm_delete\": \"确认删除\",\n      \"enable\": \"启用\",\n      \"disable\": \"禁用\",\n      \"edit\": \"编辑\",\n      \"add\": \"添加新的兑换码\",\n      \"refresh\": \"刷新\"\n    },\n    \"status\": {\n      \"unused\": \"未使用\",\n      \"disabled\": \"已禁用\",\n      \"used\": \"已使用\",\n      \"unknown\": \"未知状态\"\n    },\n    \"edit\": {\n      \"title_edit\": \"更新兑换码信息\",\n      \"title_create\": \"创建新的兑换码\",\n      \"name\": \"名称\",\n      \"name_placeholder\": \"请输入名称\",\n      \"quota\": \"额度\",\n      \"quota_placeholder\": \"请输入单个兑换码中包含的额度\",\n      \"count\": \"生成数量\",\n      \"count_placeholder\": \"请输入生成数量\",\n      \"buttons\": {\n        \"submit\": \"提交\",\n        \"cancel\": \"取消\"\n      }\n    },\n    \"messages\": {\n      \"update_success\": \"兑换码更新成功！\",\n      \"create_success\": \"兑换码创建成功！\"\n    }\n  },\n  \"log\": {\n    \"title\": \"操作日志\",\n    \"search\": \"搜索日志...\",\n    \"usage_details\": \"使用明细\",\n    \"total_quota\": \"总消耗额度\",\n    \"click_to_view\": \"点击查看\",\n    \"type\": {\n      \"select\": \"选择明细分类\",\n      \"all\": \"全部\",\n      \"topup\": \"充值\",\n      \"usage\": \"消费\",\n      \"admin\": \"管理\",\n      \"system\": \"系统\",\n      \"test\": \"测试\"\n    },\n    \"table\": {\n      \"time\": \"时间\",\n      \"channel\": \"渠道\",\n      \"type\": \"类型\",\n      \"model\": \"模型\",\n      \"username\": \"用户名\",\n      \"token_name\": \"令牌名称\",\n      \"token_name_placeholder\": \"可选值\",\n      \"model_name\": \"模型名称\",\n      \"model_name_placeholder\": \"可选值\",\n      \"start_time\": \"起始时间\",\n      \"end_time\": \"结束时间\",\n      \"channel_id\": \"渠道 ID\",\n      \"channel_id_placeholder\": \"可选值\",\n      \"username_placeholder\": \"可选值\",\n      \"prompt_tokens\": \"提示词消耗\",\n      \"completion_tokens\": \"补全消耗\",\n      \"quota\": \"额度\",\n      \"detail\": \"详情\"\n    },\n    \"buttons\": {\n      \"query\": \"操作\",\n      \"submit\": \"查询\",\n      \"refresh\": \"刷新\"\n    }\n  },\n  \"user\": {\n    \"title\": \"用户管理\",\n    \"edit\": {\n      \"title\": \"更新用户信息\",\n      \"username\": \"用户名\",\n      \"username_placeholder\": \"请输入新的用户名\",\n      \"password\": \"密码\",\n      \"password_placeholder\": \"请输入新的密码，最短 8 位\",\n      \"display_name\": \"显示名称\",\n      \"display_name_placeholder\": \"请输入新的显示名称\",\n      \"group\": \"分组\",\n      \"group_placeholder\": \"请选择分组\",\n      \"group_addition\": \"请在系统设置页面编辑分组倍率以添加新的分组：\",\n      \"quota\": \"剩余额度\",\n      \"quota_placeholder\": \"请输入新的剩余额度\",\n      \"github_id\": \"已绑定的 GitHub 账户\",\n      \"github_id_placeholder\": \"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\",\n      \"wechat_id\": \"已绑定的微信账户\",\n      \"wechat_id_placeholder\": \"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\",\n      \"email\": \"已绑定的邮箱账户\",\n      \"email_placeholder\": \"此项只读，需要用户通过个人设置页面的相关绑定按钮进行绑定，不可直接修改\",\n      \"buttons\": {\n        \"submit\": \"提交\",\n        \"cancel\": \"取消\"\n      }\n    },\n    \"add\": {\n      \"title\": \"创建新用户账户\"\n    },\n    \"messages\": {\n      \"update_success\": \"用户信息更新成功！\",\n      \"create_success\": \"用户账户创建成功！\",\n      \"operation_success\": \"操作成功完成！\"\n    },\n    \"search\": \"搜索用户...\",\n    \"table\": {\n      \"id\": \"ID\",\n      \"username\": \"用户名\",\n      \"group\": \"分组\",\n      \"quota\": \"额度\",\n      \"role_text\": \"角色\",\n      \"status_text\": \"状态\",\n      \"actions\": \"操作\",\n      \"remaining_quota\": \"剩余额度\",\n      \"used_quota\": \"已用额度\",\n      \"request_count\": \"请求次数\",\n      \"role_types\": {\n        \"normal\": \"普通用户\",\n        \"admin\": \"管理员\",\n        \"super_admin\": \"超级管理员\",\n        \"unknown\": \"未知身份\"\n      },\n      \"status_types\": {\n        \"activated\": \"已激活\",\n        \"banned\": \"已封禁\",\n        \"unknown\": \"未知状态\"\n      },\n      \"sort\": {\n        \"default\": \"默认排序\",\n        \"by_quota\": \"按剩余额度排序\",\n        \"by_used_quota\": \"按已用额度排序\",\n        \"by_request_count\": \"按请求次数排序\"\n      },\n      \"sort_by\": \"排序方式\"\n    },\n    \"buttons\": {\n      \"add\": \"添加新的用户\",\n      \"delete\": \"删除\",\n      \"delete_user\": \"删除用户\",\n      \"enable\": \"启用\",\n      \"disable\": \"禁用\",\n      \"edit\": \"编辑\",\n      \"promote\": \"提升\",\n      \"demote\": \"降级\"\n    }\n  },\n  \"dashboard\": {\n    \"charts\": {\n      \"requests\": {\n        \"title\": \"模型请求趋势\",\n        \"tooltip\": \"请求次数\"\n      },\n      \"quota\": {\n        \"title\": \"额度消费趋势\",\n        \"tooltip\": \"消费额度\"\n      },\n      \"tokens\": {\n        \"title\": \"Token 消费趋势\",\n        \"tooltip\": \"Token 数量\"\n      }\n    },\n    \"statistics\": {\n      \"title\": \"统计\",\n      \"tooltip\": {\n        \"date\": \"日期\",\n        \"value\": \"数值\"\n      }\n    }\n  },\n  \"setting\": {\n    \"title\": \"系统设置\",\n    \"tabs\": {\n      \"personal\": \"个人设置\",\n      \"operation\": \"运营设置\",\n      \"system\": \"系统设置\",\n      \"other\": \"其他设置\"\n    },\n    \"personal\": {\n      \"general\": {\n        \"title\": \"通用设置\",\n        \"system_token_notice\": \"注意，此处生成的令牌用于系统管理，而非用于请求 OpenAI 相关的服务，请知悉。\",\n        \"buttons\": {\n          \"update_profile\": \"更新个人信息\",\n          \"generate_token\": \"生成系统访问令牌\",\n          \"copy_invite\": \"复制邀请链接\",\n          \"delete_account\": \"删除个人账户\"\n        }\n      },\n      \"binding\": {\n        \"title\": \"账号绑定\",\n        \"buttons\": {\n          \"bind_wechat\": \"绑定微信账号\",\n          \"bind_github\": \"绑定 GitHub 账号\",\n          \"bind_email\": \"绑定邮箱地址\",\n          \"bind_lark\": \"绑定飞书账号\"\n        },\n        \"wechat\": {\n          \"title\": \"微信绑定\",\n          \"description\": \"微信扫码关注公众号，输入「验证码」获取验证码（三分钟内有效）\",\n          \"verification_code\": \"验证码\",\n          \"bind\": \"绑定\"\n        },\n        \"email\": {\n          \"title\": \"绑定邮箱地址\",\n          \"email_placeholder\": \"输入邮箱地址\",\n          \"code_placeholder\": \"验证码\",\n          \"get_code\": \"获取验证码\",\n          \"get_code_retry\": \"重新发送({{countdown}})\",\n          \"bind\": \"确认绑定\",\n          \"cancel\": \"取消\"\n        }\n      },\n      \"delete_account\": {\n        \"title\": \"危险操作\",\n        \"warning\": \"您正在删除自己的帐户，将清空所有数据且不可恢复\",\n        \"confirm_placeholder\": \"输入你的账户名 {{username}} 以确认删除\",\n        \"buttons\": {\n          \"confirm\": \"确认删除\",\n          \"cancel\": \"取消\"\n        }\n      }\n    },\n    \"system\": {\n      \"general\": {\n        \"title\": \"通用设置\",\n        \"server_address\": \"服务器地址\",\n        \"server_address_placeholder\": \"例如：https://yourdomain.com\",\n        \"buttons\": {\n          \"update\": \"更新服务器地址\"\n        }\n      },\n      \"login\": {\n        \"title\": \"配置登录注册\",\n        \"password_login\": \"允许通过密码进行登录\",\n        \"password_register\": \"允许通过密码进行注册\",\n        \"email_verification\": \"通过密码注册时需要进行邮箱验证\",\n        \"github_oauth\": \"允许通过 GitHub 账户登录 & 注册\",\n        \"wechat_login\": \"允许通过微信登录 & 注册\",\n        \"registration\": \"允许新用户注册（此项为否时，新用户将无法以任何方式进行注册）\",\n        \"turnstile\": \"启用 Turnstile 用户校验\"\n      },\n      \"email_restriction\": {\n        \"title\": \"配置邮箱域名白名单\",\n        \"subtitle\": \"用以防止恶意用户利用临时邮箱批量注册\",\n        \"enable\": \"启用邮箱域名白名单\",\n        \"allowed_domains\": \"允许的邮箱域名\",\n        \"add_domain\": \"添加新的允许的邮箱域名\",\n        \"add_domain_placeholder\": \"输入新的允许的邮箱域名\",\n        \"buttons\": {\n          \"fill\": \"填入\",\n          \"save\": \"保存邮箱域名白名单设置\"\n        }\n      },\n      \"smtp\": {\n        \"title\": \"配置 SMTP\",\n        \"subtitle\": \"用以支持系统的邮件发送\",\n        \"server\": \"SMTP 服务器地址\",\n        \"server_placeholder\": \"例如：smtp.qq.com\",\n        \"port\": \"SMTP 端口\",\n        \"port_placeholder\": \"默认: 587\",\n        \"account\": \"SMTP 账户\",\n        \"account_placeholder\": \"通常是邮箱地址\",\n        \"from\": \"SMTP 发送者邮箱\",\n        \"from_placeholder\": \"通常和邮箱地址保持一致\",\n        \"token\": \"SMTP 访问凭证\",\n        \"token_placeholder\": \"敏感信息不会发送到前端显示\",\n        \"buttons\": {\n          \"save\": \"保存 SMTP 设置\"\n        }\n      },\n      \"github\": {\n        \"title\": \"配置 GitHub OAuth App\",\n        \"subtitle\": \"用以支持通过 GitHub 进行登录注册\",\n        \"manage_link\": \"点击此处\",\n        \"manage_text\": \"管理你的 GitHub OAuth App\",\n        \"url_notice\": \"Homepage URL 填 {{server_url}}，Authorization callback URL 填 {{callback_url}}\",\n        \"client_id\": \"GitHub Client ID\",\n        \"client_id_placeholder\": \"输入你注册的 GitHub OAuth APP 的 ID\",\n        \"client_secret\": \"GitHub Client Secret\",\n        \"client_secret_placeholder\": \"敏感信息不会发送到前端显示\",\n        \"buttons\": {\n          \"save\": \"保存 GitHub OAuth 设置\"\n        }\n      },\n      \"lark\": {\n        \"title\": \"配置飞书授权登录\",\n        \"subtitle\": \"用以支持通过飞书进行登录注册\",\n        \"manage_link\": \"点击此处\",\n        \"manage_text\": \"管理你的飞书应用\",\n        \"url_notice\": \"主页链接填 {{server_url}}，重定向 URL 填 {{callback_url}}\",\n        \"client_id\": \"App ID\",\n        \"client_id_placeholder\": \"输入 App ID\",\n        \"client_secret\": \"App Secret\",\n        \"client_secret_placeholder\": \"敏感信息不会发送到前端显示\",\n        \"buttons\": {\n          \"save\": \"保存飞书 OAuth 设置\"\n        }\n      },\n      \"wechat\": {\n        \"title\": \"配置 WeChat Server\",\n        \"subtitle\": \"用以支持通过微信进行登录注册\",\n        \"learn_more\": \"了解 WeChat Server\",\n        \"server_address\": \"WeChat Server 服务器地址\",\n        \"server_address_placeholder\": \"例如：https://yourdomain.com\",\n        \"token\": \"WeChat Server 访问凭证\",\n        \"token_placeholder\": \"敏感信息不会发送到前端显示\",\n        \"qrcode\": \"微信公众号二维码图片链接\",\n        \"qrcode_placeholder\": \"输入一个图片链接\",\n        \"buttons\": {\n          \"save\": \"保存 WeChat Server 设置\"\n        }\n      },\n      \"turnstile\": {\n        \"title\": \"配置 Turnstile\",\n        \"subtitle\": \"用以支持用户校验\",\n        \"manage_link\": \"点击此处\",\n        \"manage_text\": \"管理你的 Turnstile Sites，推荐选择 Invisible Widget Type\",\n        \"site_key\": \"Turnstile Site Key\",\n        \"site_key_placeholder\": \"输入你注册的 Turnstile Site Key\",\n        \"secret_key\": \"Turnstile Secret Key\",\n        \"secret_key_placeholder\": \"敏感信息不会发送到前端显示\",\n        \"buttons\": {\n          \"save\": \"保存 Turnstile 设置\"\n        }\n      },\n      \"password_login\": {\n        \"warning\": {\n          \"title\": \"警告\",\n          \"content\": \"取消密码登录将导致所有未绑定其他登录方式的用户（包括管理员）无法通过密码登录，确认取消？\",\n          \"buttons\": {\n            \"confirm\": \"确定\",\n            \"cancel\": \"取消\"\n          }\n        }\n      }\n    },\n    \"operation\": {\n      \"quota\": {\n        \"title\": \"额度设置\",\n        \"new_user\": \"新用户初始额度\",\n        \"new_user_placeholder\": \"例如：100\",\n        \"pre_consume\": \"请求预扣费额度\",\n        \"pre_consume_placeholder\": \"请求结束后多退少补\",\n        \"inviter_reward\": \"邀请新用户奖励额度\",\n        \"inviter_reward_placeholder\": \"例如：2000\",\n        \"invitee_reward\": \"新用户使用邀请码奖励额度\",\n        \"invitee_reward_placeholder\": \"例如：1000\",\n        \"buttons\": {\n          \"save\": \"保存额度设置\"\n        }\n      },\n      \"ratio\": {\n        \"title\": \"倍率设置\",\n        \"model\": {\n          \"title\": \"模型倍率\",\n          \"placeholder\": \"为一个 JSON 文本，键为模型名称，值为倍率\"\n        },\n        \"completion\": {\n          \"title\": \"补全倍率\",\n          \"placeholder\": \"为一个 JSON 文本，键为模型名称，值为倍率，此处的倍率设置是模型补全倍率相较于提示倍率的比例，使用该设置可强制覆盖 One API 的内部比例\"\n        },\n        \"group\": {\n          \"title\": \"分组倍率\",\n          \"placeholder\": \"为一个 JSON 文本，键为分组名称，值为倍率\"\n        },\n        \"buttons\": {\n          \"save\": \"保存倍率设置\"\n        }\n      },\n      \"log\": {\n        \"title\": \"日志设置\",\n        \"enable_consume\": \"启用额度消费日志记录\",\n        \"target_time\": \"目标时间\",\n        \"buttons\": {\n          \"clean\": \"清理历史日志\"\n        }\n      },\n      \"monitor\": {\n        \"title\": \"监控设置\",\n        \"max_response_time\": \"最长响应时间\",\n        \"max_response_time_placeholder\": \"单位秒，当运行渠道全部测试时，超过此时间将自动禁用渠道\",\n        \"quota_reminder\": \"额度提醒阈值\",\n        \"quota_reminder_placeholder\": \"低于此额度时将发送邮件提醒用户\",\n        \"auto_disable\": \"失败时自动禁用渠道\",\n        \"auto_enable\": \"成功时自动启用渠道\",\n        \"buttons\": {\n          \"save\": \"保存监控设置\"\n        }\n      },\n      \"general\": {\n        \"title\": \"通用设置\",\n        \"topup_link\": \"充值链接\",\n        \"topup_link_placeholder\": \"例如发卡网站的购买链接\",\n        \"chat_link\": \"聊天页面链接\",\n        \"chat_link_placeholder\": \"例如 ChatGPT Next Web 的部署地址\",\n        \"quota_per_unit\": \"单位美元额度\",\n        \"quota_per_unit_placeholder\": \"一单位货币能兑换的额度\",\n        \"retry_times\": \"失败重试次数\",\n        \"retry_times_placeholder\": \"失败重试次数\",\n        \"display_in_currency\": \"以货币形式显示额度\",\n        \"display_token_stat\": \"Billing 相关 API 显示令牌额度而非用户额度\",\n        \"approximate_token\": \"使用近似的方式估算 token 数以减少计算量\",\n        \"buttons\": {\n          \"save\": \"保存通用设置\"\n        }\n      }\n    },\n    \"other\": {\n      \"notice\": {\n        \"title\": \"公告设置\",\n        \"content\": \"公告内容\",\n        \"content_placeholder\": \"在此输入新的公告内容，支持 Markdown & HTML 代码\",\n        \"buttons\": {\n          \"save\": \"保存公告\"\n        }\n      },\n      \"system\": {\n        \"title\": \"系统设置\",\n        \"name\": \"系统名称\",\n        \"name_placeholder\": \"请输入系统名称\",\n        \"logo\": \"Logo 图片地址\",\n        \"logo_placeholder\": \"在此输入 Logo 图片地址\",\n        \"theme\": {\n          \"title\": \"主题名称\",\n          \"link\": \"当前可用主题\",\n          \"placeholder\": \"请输入主题名称\"\n        },\n        \"buttons\": {\n          \"save_name\": \"设置系统名称\",\n          \"save_logo\": \"设置 Logo\",\n          \"save_theme\": \"设置主题（重启生效）\"\n        }\n      },\n      \"content\": {\n        \"title\": \"内容设置\",\n        \"homepage\": {\n          \"title\": \"首页内容\",\n          \"placeholder\": \"在此输入首页内容，支持 Markdown & HTML 代码，设置后首页的状态信息将不再显示。如果输入的是一个链接，则会使用该链接作为 iframe 的 src 属性，这允许你设置任意网页作为首页。\"\n        },\n        \"about\": {\n          \"title\": \"关于\",\n          \"placeholder\": \"在此输入新的关于内容，支持 Markdown & HTML 代码。如果输入的是一个链接，则会使用该链接作为 iframe 的 src 属性，这允许你设置任意网页作为关于页面。\"\n        },\n        \"footer\": {\n          \"title\": \"页脚\",\n          \"placeholder\": \"在此输入新的页脚，留空则使用默认页脚，支持 HTML 代码\"\n        },\n        \"buttons\": {\n          \"save_homepage\": \"保存首页内容\",\n          \"save_about\": \"保存关于\",\n          \"save_footer\": \"设置页脚\"\n        }\n      },\n      \"copyright\": {\n        \"notice\": \"移除 One API 的版权标识必须首先获得授权，项目维护需要花费大量精力，如果本项目对你有意义，请主动支持本项目。\"\n      }\n    }\n  },\n  \"about\": {\n    \"title\": \"关于\",\n    \"description\": \"One API 是一个开源的接口管理和代理平台。\",\n    \"repository\": \"项目地址：\",\n    \"loading_failed\": \"加载失败\"\n  },\n  \"footer\": {\n    \"built_by\": \"由\",\n    \"built_by_name\": \"JustSong\",\n    \"license\": \"构建，源代码遵循\",\n    \"mit\": \"MIT 协议\"\n  },\n  \"home\": {\n    \"welcome\": {\n      \"title\": \"欢迎使用 One API\",\n      \"description\": \"One API 是一个 LLM API 接口管理和分发系统，可以帮助您更好地管理和使用各大厂商的 LLM API。\",\n      \"login_notice\": \"如需使用，请先登录或注册。\"\n    },\n    \"system_status\": {\n      \"title\": \"系统状况\",\n      \"info\": {\n        \"title\": \"系统信息\",\n        \"name\": \"名称：\",\n        \"version\": \"版本：\",\n        \"source\": \"源码：\",\n        \"source_link\": \"GitHub 仓库\",\n        \"start_time\": \"启动时间：\"\n      },\n      \"config\": {\n        \"title\": \"系统配置\",\n        \"email_verify\": \"邮箱验证：\",\n        \"github_oauth\": \"GitHub 身份验证：\",\n        \"wechat_login\": \"微信身份验证：\",\n        \"turnstile\": \"Turnstile 校验：\",\n        \"enabled\": \"已启用\",\n        \"disabled\": \"未启用\"\n      }\n    },\n    \"loading_failed\": \"加载首页内容失败...\"\n  },\n  \"auth\": {\n    \"login\": {\n      \"title\": \"用户登录\",\n      \"username\": \"用户名 / 邮箱地址\",\n      \"password\": \"密码\",\n      \"button\": \"登录\",\n      \"forgot_password\": \"忘记密码？\",\n      \"reset_password\": \"点击重置\",\n      \"no_account\": \"没有账户？\",\n      \"register\": \"点击注册\",\n      \"other_methods\": \"使用其他方式登录\",\n      \"wechat\": {\n        \"scan_tip\": \"微信扫码关注公众号，输入「验证码」获取验证码（三分钟内有效）\",\n        \"code_placeholder\": \"验证码\"\n      }\n    },\n    \"register\": {\n      \"title\": \"新用户注册\",\n      \"username\": \"输入用户名，最长 12 位\",\n      \"password\": \"输入密码，最短 8 位，最长 20 位\",\n      \"confirm_password\": \"再次输入密码\",\n      \"email\": \"输入邮箱地址\",\n      \"verification_code\": \"输入验证码\",\n      \"get_code\": \"获取验证码\",\n      \"get_code_retry\": \"重试 ({{countdown}})\",\n      \"button\": \"注册\",\n      \"has_account\": \"已有账户？\",\n      \"login\": \"点击登录\"\n    },\n    \"reset\": {\n      \"title\": \"密码重置\",\n      \"email\": \"邮箱地址\",\n      \"button\": \"提交\",\n      \"notice\": \"系统将向您的邮箱发送一封包含重置链接的邮件，请注意查收。\",\n      \"confirm\": {\n        \"title\": \"密码重置确认\",\n        \"new_password\": \"新密码\",\n        \"button\": \"提交\",\n        \"button_disabled\": \"密码重置完成\",\n        \"notice\": \"新密码已生成，请点击密码框或上方按钮复制。请及时登录并修改密码！\"\n      }\n    }\n  },\n  \"messages\": {\n    \"success\": {\n      \"login\": \"登录成功！\",\n      \"register\": \"注册成功！\",\n      \"verification_code\": \"验证码发送成功，请检查你的邮箱！\",\n      \"password_reset\": \"重置邮件发送成功，请检查邮箱！\"\n    },\n    \"error\": {\n      \"login_expired\": \"未登录或登录已过期，请重新登录！\",\n      \"password_length\": \"密码长度不得小于 8 位！\",\n      \"password_mismatch\": \"两次输入的密码不一致\",\n      \"turnstile_wait\": \"请稍后几秒重试，Turnstile 正在检查用户环境！\",\n      \"root_password\": \"请立刻修改默认密码！\"\n    },\n    \"notice\": {\n      \"password_copied\": \"新密码已复制到剪贴板：{{password}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/default/src/pages/About/index.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Card } from 'semantic-ui-react';\nimport { API, showError } from '../../helpers';\nimport { marked } from 'marked';\n\nconst About = () => {\n  const { t } = useTranslation();\n  const [about, setAbout] = useState('');\n  const [aboutLoaded, setAboutLoaded] = useState(false);\n\n  const displayAbout = async () => {\n    setAbout(localStorage.getItem('about') || '');\n    const res = await API.get('/api/about');\n    const { success, message, data } = res.data;\n    if (success) {\n      let aboutContent = data;\n      if (!data.startsWith('https://')) {\n        aboutContent = marked.parse(data);\n      }\n      setAbout(aboutContent);\n      localStorage.setItem('about', aboutContent);\n    } else {\n      showError(message);\n      setAbout(t('about.loading_failed'));\n    }\n    setAboutLoaded(true);\n  };\n\n  useEffect(() => {\n    displayAbout().then();\n  }, []);\n\n  return (\n    <>\n      {aboutLoaded && about === '' ? (\n        <div className='dashboard-container'>\n          <Card fluid className='chart-card'>\n            <Card.Content>\n              <Card.Header className='header'>{t('about.title')}</Card.Header>\n              <p>{t('about.description')}</p>\n              {t('about.repository')}\n              <a href='https://github.com/songquanpeng/one-api'>\n                https://github.com/songquanpeng/one-api\n              </a>\n            </Card.Content>\n          </Card>\n        </div>\n      ) : (\n        <>\n          {about.startsWith('https://') ? (\n            <iframe\n              src={about}\n              style={{ width: '100%', height: '100vh', border: 'none' }}\n            />\n          ) : (\n            <div className='dashboard-container'>\n              <Card fluid className='chart-card'>\n                <Card.Content>\n                  <div\n                    style={{ fontSize: 'larger' }}\n                    dangerouslySetInnerHTML={{ __html: about }}\n                  ></div>\n                </Card.Content>\n              </Card>\n            </div>\n          )}\n        </>\n      )}\n    </>\n  );\n};\n\nexport default About;\n"
  },
  {
    "path": "web/default/src/pages/Channel/EditChannel.js",
    "content": "import React, {useEffect, useState} from 'react';\nimport {useTranslation} from 'react-i18next';\nimport {Button, Card, Form, Input, Message} from 'semantic-ui-react';\nimport {useNavigate, useParams} from 'react-router-dom';\nimport {API, copy, getChannelModels, showError, showInfo, showSuccess, verifyJSON,} from '../../helpers';\nimport {CHANNEL_OPTIONS} from '../../constants';\nimport {renderChannelTip} from '../../helpers/render';\n\nconst MODEL_MAPPING_EXAMPLE = {\n  'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',\n  'gpt-4-0314': 'gpt-4',\n  'gpt-4-32k-0314': 'gpt-4-32k',\n};\n\nfunction type2secretPrompt(type, t) {\n  switch (type) {\n    case 15:\n      return t('channel.edit.key_prompts.zhipu');\n    case 18:\n      return t('channel.edit.key_prompts.spark');\n    case 22:\n      return t('channel.edit.key_prompts.fastgpt');\n    case 23:\n      return t('channel.edit.key_prompts.tencent');\n    default:\n      return t('channel.edit.key_prompts.default');\n  }\n}\n\nconst EditChannel = () => {\n  const { t } = useTranslation();\n  const params = useParams();\n  const navigate = useNavigate();\n  const channelId = params.id;\n  const isEdit = channelId !== undefined;\n  const [loading, setLoading] = useState(isEdit);\n  const handleCancel = () => {\n    navigate('/channel');\n  };\n\n  const originInputs = {\n    name: '',\n    type: 1,\n    key: '',\n    base_url: '',\n    other: '',\n    model_mapping: '',\n    system_prompt: '',\n    models: [],\n    groups: ['default'],\n  };\n  const [batch, setBatch] = useState(false);\n  const [inputs, setInputs] = useState(originInputs);\n  const [originModelOptions, setOriginModelOptions] = useState([]);\n  const [modelOptions, setModelOptions] = useState([]);\n  const [groupOptions, setGroupOptions] = useState([]);\n  const [basicModels, setBasicModels] = useState([]);\n  const [fullModels, setFullModels] = useState([]);\n  const [customModel, setCustomModel] = useState('');\n  const [config, setConfig] = useState({\n    region: '',\n    sk: '',\n    ak: '',\n    user_id: '',\n    vertex_ai_project_id: '',\n    vertex_ai_adc: '',\n  });\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n    if (name === 'type') {\n      let localModels = getChannelModels(value);\n      if (inputs.models.length === 0) {\n        setInputs((inputs) => ({ ...inputs, models: localModels }));\n      }\n      setBasicModels(localModels);\n    }\n  };\n\n  const handleConfigChange = (e, { name, value }) => {\n    setConfig((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const loadChannel = async () => {\n    let res = await API.get(`/api/channel/${channelId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      if (data.models === '') {\n        data.models = [];\n      } else {\n        data.models = data.models.split(',');\n      }\n      if (data.group === '') {\n        data.groups = [];\n      } else {\n        data.groups = data.group.split(',');\n      }\n      if (data.model_mapping !== '') {\n        data.model_mapping = JSON.stringify(\n          JSON.parse(data.model_mapping),\n          null,\n          2\n        );\n      }\n      setInputs(data);\n      if (data.config !== '') {\n        setConfig(JSON.parse(data.config));\n      }\n      setBasicModels(getChannelModels(data.type));\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n\n  const fetchModels = async () => {\n    try {\n      let res = await API.get(`/api/channel/models`);\n      let localModelOptions = res.data.data.map((model) => ({\n        key: model.id,\n        text: model.id,\n        value: model.id,\n      }));\n      setOriginModelOptions(localModelOptions);\n      setFullModels(res.data.data.map((model) => model.id));\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n\n  const fetchGroups = async () => {\n    try {\n      let res = await API.get(`/api/group/`);\n      setGroupOptions(\n        res.data.data.map((group) => ({\n          key: group,\n          text: group,\n          value: group,\n        }))\n      );\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n\n  useEffect(() => {\n    let localModelOptions = [...originModelOptions];\n    inputs.models.forEach((model) => {\n      if (!localModelOptions.find((option) => option.key === model)) {\n        localModelOptions.push({\n          key: model,\n          text: model,\n          value: model,\n        });\n      }\n    });\n    setModelOptions(localModelOptions);\n  }, [originModelOptions, inputs.models]);\n\n  useEffect(() => {\n    if (isEdit) {\n      loadChannel().then();\n    } else {\n      let localModels = getChannelModels(inputs.type);\n      setBasicModels(localModels);\n    }\n    fetchModels().then();\n    fetchGroups().then();\n  }, []);\n\n  const submit = async () => {\n    if (inputs.key === '') {\n      if (config.ak !== '' && config.sk !== '' && config.region !== '') {\n        inputs.key = `${config.ak}|${config.sk}|${config.region}`;\n      } else if (\n        config.region !== '' &&\n        config.vertex_ai_project_id !== '' &&\n        config.vertex_ai_adc !== ''\n      ) {\n        inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`;\n      }\n    }\n    if (!isEdit && (inputs.name === '' || inputs.key === '')) {\n      showInfo(t('channel.edit.messages.name_required'));\n      return;\n    }\n    if (inputs.type !== 43 && inputs.models.length === 0) {\n      showInfo(t('channel.edit.messages.models_required'));\n      return;\n    }\n    if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {\n      showInfo(t('channel.edit.messages.model_mapping_invalid'));\n      return;\n    }\n    let localInputs = { ...inputs };\n    if (localInputs.key === 'undefined|undefined|undefined') {\n      localInputs.key = ''; // prevent potential bug\n    }\n    if (localInputs.base_url && localInputs.base_url.endsWith('/')) {\n      localInputs.base_url = localInputs.base_url.slice(\n        0,\n        localInputs.base_url.length - 1\n      );\n    }\n    if (localInputs.type === 3 && localInputs.other === '') {\n      localInputs.other = '2024-03-01-preview';\n    }\n    let res;\n    localInputs.models = localInputs.models.join(',');\n    localInputs.group = localInputs.groups.join(',');\n    localInputs.config = JSON.stringify(config);\n    if (isEdit) {\n      res = await API.put(`/api/channel/`, {\n        ...localInputs,\n        id: parseInt(channelId),\n      });\n    } else {\n      res = await API.post(`/api/channel/`, localInputs);\n    }\n    const { success, message } = res.data;\n    if (success) {\n      if (isEdit) {\n        showSuccess(t('channel.edit.messages.update_success'));\n      } else {\n        showSuccess(t('channel.edit.messages.create_success'));\n        setInputs(originInputs);\n      }\n    } else {\n      showError(message);\n    }\n  };\n\n  const addCustomModel = () => {\n    if (customModel.trim() === '') return;\n    if (inputs.models.includes(customModel)) return;\n    let localModels = [...inputs.models];\n    localModels.push(customModel);\n    let localModelOptions = [];\n    localModelOptions.push({\n      key: customModel,\n      text: customModel,\n      value: customModel,\n    });\n    setModelOptions((modelOptions) => {\n      return [...modelOptions, ...localModelOptions];\n    });\n    setCustomModel('');\n    handleInputChange(null, { name: 'models', value: localModels });\n  };\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>\n            {isEdit\n              ? t('channel.edit.title_edit')\n              : t('channel.edit.title_create')}\n          </Card.Header>\n          <Form loading={loading} autoComplete='new-password'>\n            <Form.Field>\n              <Form.Select\n                label={t('channel.edit.type')}\n                name='type'\n                required\n                search\n                options={CHANNEL_OPTIONS}\n                value={inputs.type}\n                onChange={handleInputChange}\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('channel.edit.name')}\n                name='name'\n                placeholder={t('channel.edit.name_placeholder')}\n                onChange={handleInputChange}\n                value={inputs.name}\n                required\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Dropdown\n                label={t('channel.edit.group')}\n                placeholder={t('channel.edit.group_placeholder')}\n                name='groups'\n                required\n                fluid\n                multiple\n                selection\n                allowAdditions\n                additionLabel={t('channel.edit.group_addition')}\n                onChange={handleInputChange}\n                value={inputs.groups}\n                autoComplete='new-password'\n                options={groupOptions}\n              />\n            </Form.Field>\n            {renderChannelTip(inputs.type)}\n\n            {/* Azure OpenAI specific fields */}\n            {inputs.type === 3 && (\n              <>\n                <Message>\n                  注意，<strong>模型部署名称必须和模型名称保持一致</strong>\n                  ，因为 One API 会把请求体中的 model\n                  参数替换为你的部署名称（模型名称中的点会被剔除），\n                  <a\n                    target='_blank'\n                    href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'\n                  >\n                    图片演示\n                  </a>\n                  。\n                </Message>\n                <Form.Field>\n                  <Form.Input\n                    label='AZURE_OPENAI_ENDPOINT'\n                    name='base_url'\n                    placeholder='请输入 AZURE_OPENAI_ENDPOINT，例如：https://docs-test-001.openai.azure.com'\n                    onChange={handleInputChange}\n                    value={inputs.base_url}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n                <Form.Field>\n                  <Form.Input\n                    label='默认 API 版本'\n                    name='other'\n                    placeholder='请输入默认 API 版本，例如：2024-03-01-preview，该配置可以被实际的请求查询参数所覆盖'\n                    onChange={handleInputChange}\n                    value={inputs.other}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n              </>\n            )}\n\n            {/* Custom base URL field */}\n            {inputs.type === 8 && (\n              <Form.Field>\n                <Form.Input\n                    required\n                    label={t('channel.edit.proxy_url')}\n                    name='base_url'\n                    placeholder={t('channel.edit.proxy_url_placeholder')}\n                    onChange={handleInputChange}\n                    value={inputs.base_url}\n                    autoComplete='new-password'\n                />\n              </Form.Field>\n            )}\n            {inputs.type === 50 && (\n                <Form.Field>\n                  <Form.Input\n                      required\n                  label={t('channel.edit.base_url')}\n                  name='base_url'\n                  placeholder={t('channel.edit.base_url_placeholder')}\n                  onChange={handleInputChange}\n                  value={inputs.base_url}\n                  autoComplete='new-password'\n                />\n              </Form.Field>\n            )}\n\n            {inputs.type === 18 && (\n              <Form.Field>\n                <Form.Input\n                  label={t('channel.edit.spark_version')}\n                  name='other'\n                  placeholder={t('channel.edit.spark_version_placeholder')}\n                  onChange={handleInputChange}\n                  value={inputs.other}\n                  autoComplete='new-password'\n                />\n              </Form.Field>\n            )}\n            {inputs.type === 21 && (\n              <Form.Field>\n                <Form.Input\n                  label={t('channel.edit.knowledge_id')}\n                  name='other'\n                  placeholder={t('channel.edit.knowledge_id_placeholder')}\n                  onChange={handleInputChange}\n                  value={inputs.other}\n                  autoComplete='new-password'\n                />\n              </Form.Field>\n            )}\n            {inputs.type === 17 && (\n              <Form.Field>\n                <Form.Input\n                  label={t('channel.edit.plugin_param')}\n                  name='other'\n                  placeholder={t('channel.edit.plugin_param_placeholder')}\n                  onChange={handleInputChange}\n                  value={inputs.other}\n                  autoComplete='new-password'\n                />\n              </Form.Field>\n            )}\n            {inputs.type === 34 && (\n              <Message>{t('channel.edit.coze_notice')}</Message>\n            )}\n            {inputs.type === 40 && (\n              <Message>\n                {t('channel.edit.douban_notice')}\n                <a\n                  target='_blank'\n                  href='https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'\n                >\n                  {t('channel.edit.douban_notice_link')}\n                </a>\n                {t('channel.edit.douban_notice_2')}\n              </Message>\n            )}\n            {inputs.type !== 43 && (\n              <Form.Field>\n                <Form.Dropdown\n                  label={t('channel.edit.models')}\n                  placeholder={t('channel.edit.models_placeholder')}\n                  name='models'\n                  required\n                  fluid\n                  multiple\n                  search\n                  onLabelClick={(e, { value }) => {\n                    copy(value).then();\n                  }}\n                  selection\n                  onChange={handleInputChange}\n                  value={inputs.models}\n                  autoComplete='new-password'\n                  options={modelOptions}\n                />\n              </Form.Field>\n            )}\n            {inputs.type !== 43 && (\n              <div style={{ lineHeight: '40px', marginBottom: '12px' }}>\n                <Button\n                  type={'button'}\n                  onClick={() => {\n                    handleInputChange(null, {\n                      name: 'models',\n                      value: basicModels,\n                    });\n                  }}\n                >\n                  {t('channel.edit.buttons.fill_models')}\n                </Button>\n                <Button\n                  type={'button'}\n                  onClick={() => {\n                    handleInputChange(null, {\n                      name: 'models',\n                      value: fullModels,\n                    });\n                  }}\n                >\n                  {t('channel.edit.buttons.fill_all')}\n                </Button>\n                <Button\n                  type={'button'}\n                  onClick={() => {\n                    handleInputChange(null, { name: 'models', value: [] });\n                  }}\n                >\n                  {t('channel.edit.buttons.clear')}\n                </Button>\n                <Input\n                  action={\n                    <Button type={'button'} onClick={addCustomModel}>\n                      {t('channel.edit.buttons.add_custom')}\n                    </Button>\n                  }\n                  placeholder={t('channel.edit.buttons.custom_placeholder')}\n                  value={customModel}\n                  onChange={(e, { value }) => {\n                    setCustomModel(value);\n                  }}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter') {\n                      addCustomModel();\n                      e.preventDefault();\n                    }\n                  }}\n                />\n              </div>\n            )}\n            {inputs.type !== 43 && (\n              <>\n                <Form.Field>\n                  <Form.TextArea\n                    label={t('channel.edit.model_mapping')}\n                    placeholder={`${t(\n                      'channel.edit.model_mapping_placeholder'\n                    )}\\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}\n                    name='model_mapping'\n                    onChange={handleInputChange}\n                    value={inputs.model_mapping}\n                    style={{\n                      minHeight: 150,\n                      fontFamily: 'JetBrains Mono, Consolas',\n                    }}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n                <Form.Field>\n                  <Form.TextArea\n                    label={t('channel.edit.system_prompt')}\n                    placeholder={t('channel.edit.system_prompt_placeholder')}\n                    name='system_prompt'\n                    onChange={handleInputChange}\n                    value={inputs.system_prompt}\n                    style={{\n                      minHeight: 150,\n                      fontFamily: 'JetBrains Mono, Consolas',\n                    }}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n              </>\n            )}\n            {inputs.type === 33 && (\n              <Form.Field>\n                <Form.Input\n                  label='Region'\n                  name='region'\n                  required\n                  placeholder={t('channel.edit.aws_region_placeholder')}\n                  onChange={handleConfigChange}\n                  value={config.region}\n                  autoComplete=''\n                />\n                <Form.Input\n                  label='AK'\n                  name='ak'\n                  required\n                  placeholder={t('channel.edit.aws_ak_placeholder')}\n                  onChange={handleConfigChange}\n                  value={config.ak}\n                  autoComplete=''\n                />\n                <Form.Input\n                  label='SK'\n                  name='sk'\n                  required\n                  placeholder={t('channel.edit.aws_sk_placeholder')}\n                  onChange={handleConfigChange}\n                  value={config.sk}\n                  autoComplete=''\n                />\n              </Form.Field>\n            )}\n            {inputs.type === 42 && (\n              <Form.Field>\n                <Form.Input\n                  label='Region'\n                  name='region'\n                  required\n                  placeholder={t('channel.edit.vertex_region_placeholder')}\n                  onChange={handleConfigChange}\n                  value={config.region}\n                  autoComplete=''\n                />\n                <Form.Input\n                  label={t('channel.edit.vertex_project_id')}\n                  name='vertex_ai_project_id'\n                  required\n                  placeholder={t('channel.edit.vertex_project_id_placeholder')}\n                  onChange={handleConfigChange}\n                  value={config.vertex_ai_project_id}\n                  autoComplete=''\n                />\n                <Form.Input\n                  label={t('channel.edit.vertex_credentials')}\n                  name='vertex_ai_adc'\n                  required\n                  placeholder={t('channel.edit.vertex_credentials_placeholder')}\n                  onChange={handleConfigChange}\n                  value={config.vertex_ai_adc}\n                  autoComplete=''\n                />\n              </Form.Field>\n            )}\n            {inputs.type === 34 && (\n              <Form.Input\n                label={t('channel.edit.user_id')}\n                name='user_id'\n                required\n                placeholder={t('channel.edit.user_id_placeholder')}\n                onChange={handleConfigChange}\n                value={config.user_id}\n                autoComplete=''\n              />\n            )}\n            {inputs.type !== 33 &&\n              inputs.type !== 42 &&\n              (batch ? (\n                <Form.Field>\n                  <Form.TextArea\n                    label={t('channel.edit.key')}\n                    name='key'\n                    required\n                    placeholder={t('channel.edit.batch_placeholder')}\n                    onChange={handleInputChange}\n                    value={inputs.key}\n                    style={{\n                      minHeight: 150,\n                      fontFamily: 'JetBrains Mono, Consolas',\n                    }}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n              ) : (\n                <Form.Field>\n                  <Form.Input\n                    label={t('channel.edit.key')}\n                    name='key'\n                    required\n                    placeholder={type2secretPrompt(inputs.type, t)}\n                    onChange={handleInputChange}\n                    value={inputs.key}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n              ))}\n            {inputs.type === 37 && (\n              <Form.Field>\n                <Form.Input\n                  label='Account ID'\n                  name='user_id'\n                  required\n                  placeholder={\n                    '请输入 Account ID，例如：d8d7c61dbc334c32d3ced580e4bf42b4'\n                  }\n                  onChange={handleConfigChange}\n                  value={config.user_id}\n                  autoComplete=''\n                />\n              </Form.Field>\n            )}\n            {inputs.type !== 33 && !isEdit && (\n              <Form.Checkbox\n                checked={batch}\n                label={t('channel.edit.batch')}\n                name='batch'\n                onChange={() => setBatch(!batch)}\n              />\n            )}\n            {inputs.type !== 3 &&\n              inputs.type !== 33 &&\n              inputs.type !== 8 &&\n                inputs.type !== 50 &&\n              inputs.type !== 22 && (\n                <Form.Field>\n                  <Form.Input\n                      label={t('channel.edit.proxy_url')}\n                    name='base_url'\n                      placeholder={t('channel.edit.proxy_url_placeholder')}\n                    onChange={handleInputChange}\n                    value={inputs.base_url}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n              )}\n            {inputs.type === 22 && (\n              <Form.Field>\n                <Form.Input\n                  label='私有部署地址'\n                  name='base_url'\n                  placeholder={\n                    '请输入私有部署地址，格式为：https://fastgpt.run/api/openapi'\n                  }\n                  onChange={handleInputChange}\n                  value={inputs.base_url}\n                  autoComplete='new-password'\n                />\n              </Form.Field>\n            )}\n            <Button onClick={handleCancel}>\n              {t('channel.edit.buttons.cancel')}\n            </Button>\n            <Button\n              type={isEdit ? 'button' : 'submit'}\n              positive\n              onClick={submit}\n            >\n              {t('channel.edit.buttons.submit')}\n            </Button>\n          </Form>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default EditChannel;\n"
  },
  {
    "path": "web/default/src/pages/Channel/index.js",
    "content": "import React from 'react';\nimport { Card } from 'semantic-ui-react';\nimport ChannelsTable from '../../components/ChannelsTable';\nimport { useTranslation } from 'react-i18next';\n\nconst Channel = () => {\n  const { t } = useTranslation();\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('channel.title')}</Card.Header>\n          <ChannelsTable />\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default Channel;\n"
  },
  {
    "path": "web/default/src/pages/Chat/index.js",
    "content": "import React from 'react';\n\nconst Chat = () => {\n  const chatLink = localStorage.getItem('chat_link');\n\n  return (\n    <iframe\n      src={chatLink}\n      style={{ width: '100%', height: '85vh', border: 'none' }}\n    />\n  );\n};\n\n\nexport default Chat;\n"
  },
  {
    "path": "web/default/src/pages/Dashboard/Dashboard.css",
    "content": ".dashboard-container {\n    padding: 20px 24px 40px;\n    background-color: #ffffff;\n    margin-top: -15px; /* 减小与导航栏的间距 */\n    max-width: 1600px;        /* 设置最大宽度 */\n    margin-left: auto;        /* 水平居中 */\n    margin-right: auto;\n}\n\n.stat-card {\n    background: linear-gradient(135deg, #2185d0 0%, #1678c2 100%) !important;\n    color: white !important;\n    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;\n    transition: transform 0.2s ease !important;\n    margin-bottom: 1rem !important;\n}\n\n.stat-card:hover {\n    transform: translateY(-5px);\n}\n\n.stat-card .statistic {\n    color: white !important;\n}\n\n.charts-grid {\n    margin-bottom: 1rem !important;\n}\n\n.charts-grid .column {\n    padding: 0.5rem !important;\n}\n\n.chart-card {\n    height: 100%;\n    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04) !important;\n    border: none !important;\n    border-radius: 16px !important;\n    padding: 8px!important;\n}\n\n.chart-container {\n    margin-top: 2px;\n    padding: 16px;\n    background-color: white;\n    border-radius: 12px;\n}\n\n.ui.card > .content > .header {\n    color: #2B3674;\n    font-size: 1.2em;\n    margin-bottom: 15px;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    font-weight: 600;\n    gap: 12px; /* 增加标题和数值之间的间距 */\n}\n\n.stat-value {\n    color: #4318FF;\n    font-weight: bold;\n    font-size: 1.1em;\n    background: rgba(67, 24, 255, 0.1);\n    padding: 4px 12px;\n    border-radius: 8px;\n    white-space: nowrap; /* 防止数值换行 */\n    margin-left: 16px;\n}\n\n/* 优化图表响应式布局 */\n@media (max-width: 768px) {\n    .dashboard-container {\n        padding: 10px 16px;   /* 移动端也相应减小内边距 */\n        max-width: 100%;      /* 移动端占满全宽 */\n    }\n    \n    .chart-container {\n        padding: 12px;\n    }\n    \n    .charts-grid .column {\n        padding: 0.25rem !important;\n    }\n}\n\n/* 设置页面的 Tab 样式 */\n.settings-tab {\n    margin-top: 1rem !important;\n    border-bottom: none !important;\n}\n\n.settings-tab .item {\n    color: #000 !important;\n    font-weight: 500 !important;\n    padding: 0.8rem 1.2rem !important;\n}\n\n.settings-tab .active.item {\n    color: #000 !important;\n    font-weight: 600 !important;\n    border-color: #000 !important;\n}\n\n.ui.tab.segment {\n    border: none !important;\n    box-shadow: none !important;\n    padding: 1rem 0 !important;\n} "
  },
  {
    "path": "web/default/src/pages/Dashboard/index.js",
    "content": "import React, {useEffect, useState} from 'react';\nimport {useTranslation} from 'react-i18next';\nimport {Card, Grid} from 'semantic-ui-react';\nimport {\n  Bar,\n  BarChart,\n  CartesianGrid,\n  Legend,\n  Line,\n  LineChart,\n  ResponsiveContainer,\n  Tooltip,\n  XAxis,\n  YAxis,\n} from 'recharts';\nimport axios from 'axios';\nimport './Dashboard.css';\n\n// 在 Dashboard 组件内添加自定义配置\nconst chartConfig = {\n  lineChart: {\n    style: {\n      background: '#fff',\n      borderRadius: '8px',\n    },\n    line: {\n      strokeWidth: 2,\n      dot: false,\n      activeDot: { r: 4 },\n    },\n    grid: {\n      vertical: false,\n      horizontal: true,\n      opacity: 0.1,\n    },\n  },\n  colors: {\n    requests: '#4318FF',\n    quota: '#00B5D8',\n    tokens: '#6C63FF',\n  },\n  barColors: [\n    '#4318FF', // 深紫色\n    '#00B5D8', // 青色\n    '#6C63FF', // 紫色\n    '#05CD99', // 绿色\n    '#FFB547', // 橙色\n    '#FF5E7D', // 粉色\n    '#41B883', // 翠绿\n    '#7983FF', // 淡紫\n    '#FF8F6B', // 珊瑚色\n    '#49BEFF', // 天蓝\n  ],\n};\n\nconst Dashboard = () => {\n  const { t } = useTranslation();\n  const [data, setData] = useState([]);\n  const [summaryData, setSummaryData] = useState({\n    todayRequests: 0,\n    todayQuota: 0,\n    todayTokens: 0,\n  });\n\n  useEffect(() => {\n    fetchDashboardData();\n  }, []);\n\n  const fetchDashboardData = async () => {\n    try {\n      const response = await axios.get('/api/user/dashboard');\n      if (response.data.success) {\n        const dashboardData = response.data.data || [];\n        setData(dashboardData);\n        calculateSummary(dashboardData);\n      }\n    } catch (error) {\n      console.error('Failed to fetch dashboard data:', error);\n      setData([]);\n      calculateSummary([]);\n    }\n  };\n\n  const calculateSummary = (dashboardData) => {\n    if (!Array.isArray(dashboardData) || dashboardData.length === 0) {\n      setSummaryData({\n        todayRequests: 0,\n        todayQuota: 0,\n        todayTokens: 0,\n      });\n      return;\n    }\n\n    const today = new Date().toISOString().split('T')[0];\n    const todayData = dashboardData.filter((item) => item.Day === today);\n\n    const summary = {\n      todayRequests: todayData.reduce(\n        (sum, item) => sum + item.RequestCount,\n        0\n      ),\n      todayQuota:\n        todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000,\n      todayTokens: todayData.reduce(\n        (sum, item) => sum + item.PromptTokens + item.CompletionTokens,\n        0\n      ),\n    };\n\n    setSummaryData(summary);\n  };\n\n  // 处理数据以供折线图使用，补充缺失的日期\n  const processTimeSeriesData = () => {\n    const dailyData = {};\n\n    // 获取日期范围\n    const dates = data.map((item) => item.Day);\n    const maxDate = new Date(); // 总是使用今天作为最后一天\n    let minDate =\n      dates.length > 0\n        ? new Date(Math.min(...dates.map((d) => new Date(d))))\n        : new Date();\n\n    // 确保至少显示7天的数据\n    const sevenDaysAgo = new Date();\n    sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // -6是因为包含今天\n    if (minDate > sevenDaysAgo) {\n      minDate = sevenDaysAgo;\n    }\n\n    // 生成所有日期\n    for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {\n      const dateStr = d.toISOString().split('T')[0];\n      dailyData[dateStr] = {\n        date: dateStr,\n        requests: 0,\n        quota: 0,\n        tokens: 0,\n      };\n    }\n\n    // 填充实际数据\n    data.forEach((item) => {\n      dailyData[item.Day].requests += item.RequestCount;\n      dailyData[item.Day].quota += item.Quota / 1000000;\n      dailyData[item.Day].tokens += item.PromptTokens + item.CompletionTokens;\n    });\n\n    return Object.values(dailyData).sort((a, b) =>\n      a.date.localeCompare(b.date)\n    );\n  };\n\n  // 处理数据以供堆叠柱状图使用\n  const processModelData = () => {\n    const timeData = {};\n\n    // 获取日期范围\n    const dates = data.map((item) => item.Day);\n    const maxDate = new Date(); // 总是使用今天作为最后一天\n    let minDate =\n      dates.length > 0\n        ? new Date(Math.min(...dates.map((d) => new Date(d))))\n        : new Date();\n\n    // 确保至少显示7天的数据\n    const sevenDaysAgo = new Date();\n    sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // -6是因为包含今天\n    if (minDate > sevenDaysAgo) {\n      minDate = sevenDaysAgo;\n    }\n\n    // 生成所有日期\n    for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {\n      const dateStr = d.toISOString().split('T')[0];\n      timeData[dateStr] = {\n        date: dateStr,\n      };\n\n      // 初始化所有模型的数据为0\n      const models = [...new Set(data.map((item) => item.ModelName))];\n      models.forEach((model) => {\n        timeData[dateStr][model] = 0;\n      });\n    }\n\n    // 填充实际数据\n    data.forEach((item) => {\n      timeData[item.Day][item.ModelName] =\n        item.PromptTokens + item.CompletionTokens;\n    });\n\n    return Object.values(timeData).sort((a, b) => a.date.localeCompare(b.date));\n  };\n\n  // 获取所有唯一的模型名称\n  const getUniqueModels = () => {\n    return [...new Set(data.map((item) => item.ModelName))];\n  };\n\n  const timeSeriesData = processTimeSeriesData();\n  const modelData = processModelData();\n  const models = getUniqueModels();\n\n  // 生成随机颜色\n  const getRandomColor = (index) => {\n    return chartConfig.barColors[index % chartConfig.barColors.length];\n  };\n\n  // 添加一个日期格式化函数\n  const formatDate = (dateStr) => {\n    const date = new Date(dateStr);\n    return date.toLocaleDateString('zh-CN', {\n      month: 'numeric',\n      day: 'numeric',\n    });\n  };\n\n  // 修改所有 XAxis 配置\n  const xAxisConfig = {\n    dataKey: 'date',\n    axisLine: false,\n    tickLine: false,\n    tick: {\n      fontSize: 12,\n      fill: '#A3AED0',\n      textAnchor: 'middle', // 文本居中对齐\n    },\n    tickFormatter: formatDate,\n    interval: 0,\n    minTickGap: 5,\n    padding: { left: 30, right: 30 }, // 增加两侧的内边距，确保首尾标签完整显示\n  };\n\n  return (\n    <div className='dashboard-container'>\n      {/* 三个并排的折线图 */}\n      <Grid columns={3} stackable className='charts-grid'>\n        <Grid.Column>\n          <Card fluid className='chart-card'>\n            <Card.Content>\n              <Card.Header>\n                {t('dashboard.charts.requests.title')}\n                {/* <span className='stat-value'>{summaryData.todayRequests}</span> */}\n              </Card.Header>\n              <div className='chart-container'>\n                <ResponsiveContainer\n                  width='100%'\n                  height={120}\n                  margin={{ left: 10, right: 10 }} // 调整容器边距\n                >\n                  <LineChart data={timeSeriesData}>\n                    <CartesianGrid\n                      strokeDasharray='3 3'\n                      vertical={chartConfig.lineChart.grid.vertical}\n                      horizontal={chartConfig.lineChart.grid.horizontal}\n                      opacity={chartConfig.lineChart.grid.opacity}\n                    />\n                    <XAxis {...xAxisConfig} />\n                    <YAxis hide={true} />\n                    <Tooltip\n                      contentStyle={{\n                        background: '#fff',\n                        border: 'none',\n                        borderRadius: '4px',\n                        boxShadow: '0 2px 8px rgba(0,0,0,0.1)',\n                      }}\n                      formatter={(value) => [\n                        value,\n                        t('dashboard.charts.requests.tooltip'),\n                      ]}\n                      labelFormatter={(label) =>\n                        `${t(\n                          'dashboard.statistics.tooltip.date'\n                        )}: ${formatDate(label)}`\n                      }\n                    />\n                    <Line\n                      type='monotone'\n                      dataKey='requests'\n                      stroke={chartConfig.colors.requests}\n                      strokeWidth={chartConfig.lineChart.line.strokeWidth}\n                      dot={chartConfig.lineChart.line.dot}\n                      activeDot={chartConfig.lineChart.line.activeDot}\n                    />\n                  </LineChart>\n                </ResponsiveContainer>\n              </div>\n            </Card.Content>\n          </Card>\n        </Grid.Column>\n\n        <Grid.Column>\n          <Card fluid className='chart-card'>\n            <Card.Content>\n              <Card.Header>\n                {t('dashboard.charts.quota.title')}\n                {/* <span className='stat-value'>\n                  ${summaryData.todayQuota.toFixed(3)}\n                </span> */}\n              </Card.Header>\n              <div className='chart-container'>\n                <ResponsiveContainer\n                  width='100%'\n                  height={120}\n                  margin={{ left: 10, right: 10 }} // 调整容器边距\n                >\n                  <LineChart data={timeSeriesData}>\n                    <CartesianGrid\n                      strokeDasharray='3 3'\n                      vertical={chartConfig.lineChart.grid.vertical}\n                      horizontal={chartConfig.lineChart.grid.horizontal}\n                      opacity={chartConfig.lineChart.grid.opacity}\n                    />\n                    <XAxis {...xAxisConfig} />\n                    <YAxis hide={true} />\n                    <Tooltip\n                      contentStyle={{\n                        background: '#fff',\n                        border: 'none',\n                        borderRadius: '4px',\n                        boxShadow: '0 2px 8px rgba(0,0,0,0.1)',\n                      }}\n                      formatter={(value) => [\n                        value.toFixed(6),\n                        t('dashboard.charts.quota.tooltip'),\n                      ]}\n                      labelFormatter={(label) =>\n                        `${t(\n                          'dashboard.statistics.tooltip.date'\n                        )}: ${formatDate(label)}`\n                      }\n                    />\n                    <Line\n                      type='monotone'\n                      dataKey='quota'\n                      stroke={chartConfig.colors.quota}\n                      strokeWidth={chartConfig.lineChart.line.strokeWidth}\n                      dot={chartConfig.lineChart.line.dot}\n                      activeDot={chartConfig.lineChart.line.activeDot}\n                    />\n                  </LineChart>\n                </ResponsiveContainer>\n              </div>\n            </Card.Content>\n          </Card>\n        </Grid.Column>\n\n        <Grid.Column>\n          <Card fluid className='chart-card'>\n            <Card.Content>\n              <Card.Header>\n                {t('dashboard.charts.tokens.title')}\n                {/* <span className='stat-value'>{summaryData.todayTokens}</span> */}\n              </Card.Header>\n              <div className='chart-container'>\n                <ResponsiveContainer\n                  width='100%'\n                  height={120}\n                  margin={{ left: 10, right: 10 }} // 调整容器边距\n                >\n                  <LineChart data={timeSeriesData}>\n                    <CartesianGrid\n                      strokeDasharray='3 3'\n                      vertical={chartConfig.lineChart.grid.vertical}\n                      horizontal={chartConfig.lineChart.grid.horizontal}\n                      opacity={chartConfig.lineChart.grid.opacity}\n                    />\n                    <XAxis {...xAxisConfig} />\n                    <YAxis hide={true} />\n                    <Tooltip\n                      contentStyle={{\n                        background: '#fff',\n                        border: 'none',\n                        borderRadius: '4px',\n                        boxShadow: '0 2px 8px rgba(0,0,0,0.1)',\n                      }}\n                      formatter={(value) => [\n                        value,\n                        t('dashboard.charts.tokens.tooltip'),\n                      ]}\n                      labelFormatter={(label) =>\n                        `${t(\n                          'dashboard.statistics.tooltip.date'\n                        )}: ${formatDate(label)}`\n                      }\n                    />\n                    <Line\n                      type='monotone'\n                      dataKey='tokens'\n                      stroke={chartConfig.colors.tokens}\n                      strokeWidth={chartConfig.lineChart.line.strokeWidth}\n                      dot={chartConfig.lineChart.line.dot}\n                      activeDot={chartConfig.lineChart.line.activeDot}\n                    />\n                  </LineChart>\n                </ResponsiveContainer>\n              </div>\n            </Card.Content>\n          </Card>\n        </Grid.Column>\n      </Grid>\n\n      {/* 模型使用统计 */}\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header>{t('dashboard.statistics.title')}</Card.Header>\n          <div className='chart-container'>\n            <ResponsiveContainer width='100%' height={300}>\n              <BarChart data={modelData}>\n                <CartesianGrid\n                  strokeDasharray='3 3'\n                  vertical={false}\n                  opacity={0.1}\n                />\n                <XAxis {...xAxisConfig} />\n                <YAxis\n                  axisLine={false}\n                  tickLine={false}\n                  tick={{ fontSize: 12, fill: '#A3AED0' }}\n                />\n                <Tooltip\n                  contentStyle={{\n                    background: '#fff',\n                    border: 'none',\n                    borderRadius: '4px',\n                    boxShadow: '0 2px 8px rgba(0,0,0,0.1)',\n                  }}\n                  labelFormatter={(label) =>\n                    `${t('dashboard.statistics.tooltip.date')}: ${formatDate(\n                      label\n                    )}`\n                  }\n                />\n                <Legend\n                  wrapperStyle={{\n                    paddingTop: '20px',\n                  }}\n                />\n                {models.map((model, index) => (\n                  <Bar\n                    key={model}\n                    dataKey={model}\n                    stackId='a'\n                    fill={getRandomColor(index)}\n                    name={model}\n                    radius={[4, 4, 0, 0]}\n                  />\n                ))}\n              </BarChart>\n            </ResponsiveContainer>\n          </div>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default Dashboard;\n"
  },
  {
    "path": "web/default/src/pages/Home/index.js",
    "content": "import React, { useContext, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Card, Grid, Header } from 'semantic-ui-react';\nimport { API, showError, showNotice, timestamp2string } from '../../helpers';\nimport { StatusContext } from '../../context/Status';\nimport { marked } from 'marked';\nimport { UserContext } from '../../context/User';\nimport { Link } from 'react-router-dom';\n\nconst Home = () => {\n  const { t } = useTranslation();\n  const [statusState, statusDispatch] = useContext(StatusContext);\n  const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);\n  const [homePageContent, setHomePageContent] = useState('');\n  const [userState] = useContext(UserContext);\n\n  const displayNotice = async () => {\n    const res = await API.get('/api/notice');\n    const { success, message, data } = res.data;\n    if (success) {\n      let oldNotice = localStorage.getItem('notice');\n      if (data !== oldNotice && data !== '') {\n        const htmlNotice = marked(data);\n        showNotice(htmlNotice, true);\n        localStorage.setItem('notice', data);\n      }\n    } else {\n      showError(message);\n    }\n  };\n\n  const displayHomePageContent = async () => {\n    setHomePageContent(localStorage.getItem('home_page_content') || '');\n    const res = await API.get('/api/home_page_content');\n    const { success, message, data } = res.data;\n    if (success) {\n      let content = data;\n      if (!data.startsWith('https://')) {\n        content = marked.parse(data);\n      }\n      setHomePageContent(content);\n      localStorage.setItem('home_page_content', content);\n    } else {\n      showError(message);\n      setHomePageContent(t('home.loading_failed'));\n    }\n    setHomePageContentLoaded(true);\n  };\n\n  const getStartTimeString = () => {\n    const timestamp = statusState?.status?.start_time;\n    return timestamp2string(timestamp);\n  };\n\n  useEffect(() => {\n    displayNotice().then();\n    displayHomePageContent().then();\n  }, []);\n\n  return (\n    <>\n      {homePageContentLoaded && homePageContent === '' ? (\n        <div className='dashboard-container'>\n          <Card fluid className='chart-card'>\n            <Card.Content>\n              <Card.Header className='header'>\n                {t('home.welcome.title')}\n              </Card.Header>\n              <Card.Description style={{ lineHeight: '1.6' }}>\n                <p>{t('home.welcome.description')}</p>\n                {!userState.user && <p>{t('home.welcome.login_notice')}</p>}\n              </Card.Description>\n            </Card.Content>\n          </Card>\n          <Card fluid className='chart-card'>\n            <Card.Content>\n              <Card.Header>\n                <Header as='h3'>{t('home.system_status.title')}</Header>\n              </Card.Header>\n              <Grid columns={2} stackable>\n                <Grid.Column>\n                  <Card\n                    fluid\n                    className='chart-card'\n                    style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}\n                  >\n                    <Card.Content>\n                      <Card.Header>\n                        <Header as='h3' style={{ color: '#444' }}>\n                          {t('home.system_status.info.title')}\n                        </Header>\n                      </Card.Header>\n                      <Card.Description\n                        style={{ lineHeight: '2', marginTop: '1em' }}\n                      >\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='info circle icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.info.name')}\n                          </span>\n                          <span>{statusState?.status?.system_name}</span>\n                        </p>\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='code branch icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.info.version')}\n                          </span>\n                          <span>\n                            {statusState?.status?.version || 'unknown'}\n                          </span>\n                        </p>\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='github icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.info.source')}\n                          </span>\n                          <a\n                            href='https://github.com/songquanpeng/one-api'\n                            target='_blank'\n                            style={{ color: '#2185d0' }}\n                          >\n                            {t('home.system_status.info.source_link')}\n                          </a>\n                        </p>\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='clock outline icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.info.start_time')}\n                          </span>\n                          <span>{getStartTimeString()}</span>\n                        </p>\n                      </Card.Description>\n                    </Card.Content>\n                  </Card>\n                </Grid.Column>\n\n                <Grid.Column>\n                  <Card\n                    fluid\n                    className='chart-card'\n                    style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}\n                  >\n                    <Card.Content>\n                      <Card.Header>\n                        <Header as='h3' style={{ color: '#444' }}>\n                          {t('home.system_status.config.title')}\n                        </Header>\n                      </Card.Header>\n                      <Card.Description\n                        style={{ lineHeight: '2', marginTop: '1em' }}\n                      >\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='envelope icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.config.email_verify')}\n                          </span>\n                          <span\n                            style={{\n                              color: statusState?.status?.email_verification\n                                ? '#21ba45'\n                                : '#db2828',\n                              fontWeight: '500',\n                            }}\n                          >\n                            {statusState?.status?.email_verification\n                              ? t('home.system_status.config.enabled')\n                              : t('home.system_status.config.disabled')}\n                          </span>\n                        </p>\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='github icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.config.github_oauth')}\n                          </span>\n                          <span\n                            style={{\n                              color: statusState?.status?.github_oauth\n                                ? '#21ba45'\n                                : '#db2828',\n                              fontWeight: '500',\n                            }}\n                          >\n                            {statusState?.status?.github_oauth\n                              ? t('home.system_status.config.enabled')\n                              : t('home.system_status.config.disabled')}\n                          </span>\n                        </p>\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='wechat icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.config.wechat_login')}\n                          </span>\n                          <span\n                            style={{\n                              color: statusState?.status?.wechat_login\n                                ? '#21ba45'\n                                : '#db2828',\n                              fontWeight: '500',\n                            }}\n                          >\n                            {statusState?.status?.wechat_login\n                              ? t('home.system_status.config.enabled')\n                              : t('home.system_status.config.disabled')}\n                          </span>\n                        </p>\n                        <p\n                          style={{\n                            display: 'flex',\n                            alignItems: 'center',\n                            gap: '0.5em',\n                          }}\n                        >\n                          <i className='shield alternate icon'></i>\n                          <span style={{ fontWeight: 'bold' }}>\n                            {t('home.system_status.config.turnstile')}\n                          </span>\n                          <span\n                            style={{\n                              color: statusState?.status?.turnstile_check\n                                ? '#21ba45'\n                                : '#db2828',\n                              fontWeight: '500',\n                            }}\n                          >\n                            {statusState?.status?.turnstile_check\n                              ? t('home.system_status.config.enabled')\n                              : t('home.system_status.config.disabled')}\n                          </span>\n                        </p>\n                      </Card.Description>\n                    </Card.Content>\n                  </Card>\n                </Grid.Column>\n              </Grid>\n            </Card.Content>\n          </Card>\n        </div>\n      ) : (\n        <>\n          {homePageContent.startsWith('https://') ? (\n            <iframe\n              src={homePageContent}\n              style={{ width: '100%', height: '100vh', border: 'none' }}\n            />\n          ) : (\n            <div\n              style={{ fontSize: 'larger' }}\n              dangerouslySetInnerHTML={{ __html: homePageContent }}\n            ></div>\n          )}\n        </>\n      )}\n    </>\n  );\n};\n\nexport default Home;\n"
  },
  {
    "path": "web/default/src/pages/Log/index.js",
    "content": "import React from 'react';\nimport { Card } from 'semantic-ui-react';\nimport { useTranslation } from 'react-i18next';\nimport LogsTable from '../../components/LogsTable';\n\nconst Log = () => {\n  const { t } = useTranslation();\n  \n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('log.title')}</Card.Header>\n          <LogsTable />\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default Log;\n"
  },
  {
    "path": "web/default/src/pages/NotFound/index.js",
    "content": "import React from 'react';\nimport { Message } from 'semantic-ui-react';\n\nconst NotFound = () => (\n  <>\n    <Message negative>\n      <Message.Header>页面不存在</Message.Header>\n      <p>请检查你的浏览器地址是否正确</p>\n    </Message>\n  </>\n);\n\nexport default NotFound;\n"
  },
  {
    "path": "web/default/src/pages/Redemption/EditRedemption.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Form, Card } from 'semantic-ui-react';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';\nimport { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';\n\nconst EditRedemption = () => {\n  const { t } = useTranslation();\n  const params = useParams();\n  const navigate = useNavigate();\n  const redemptionId = params.id;\n  const isEdit = redemptionId !== undefined;\n  const [loading, setLoading] = useState(isEdit);\n  const originInputs = {\n    name: '',\n    quota: 100000,\n    count: 1,\n  };\n  const [inputs, setInputs] = useState(originInputs);\n  const { name, quota, count } = inputs;\n\n  const handleCancel = () => {\n    navigate('/redemption');\n  };\n\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const loadRedemption = async () => {\n    let res = await API.get(`/api/redemption/${redemptionId}`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n  useEffect(() => {\n    if (isEdit) {\n      loadRedemption().then();\n    }\n  }, []);\n\n  const submit = async () => {\n    if (!isEdit && inputs.name === '') return;\n    let localInputs = inputs;\n    localInputs.count = parseInt(localInputs.count);\n    localInputs.quota = parseInt(localInputs.quota);\n    let res;\n    if (isEdit) {\n      res = await API.put(`/api/redemption/`, {\n        ...localInputs,\n        id: parseInt(redemptionId),\n      });\n    } else {\n      res = await API.post(`/api/redemption/`, {\n        ...localInputs,\n      });\n    }\n    const { success, message, data } = res.data;\n    if (success) {\n      if (isEdit) {\n        showSuccess(t('redemption.messages.update_success'));\n      } else {\n        showSuccess(t('redemption.messages.create_success'));\n        setInputs(originInputs);\n      }\n    } else {\n      showError(message);\n    }\n    if (!isEdit && data) {\n      let text = '';\n      for (let i = 0; i < data.length; i++) {\n        text += data[i] + '\\n';\n      }\n      downloadTextAsFile(text, `${inputs.name}.txt`);\n    }\n  };\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>\n            {isEdit ? t('redemption.edit.title_edit') : t('redemption.edit.title_create')}\n          </Card.Header>\n          <Form loading={loading} autoComplete='new-password'>\n            <Form.Field>\n              <Form.Input\n                label={t('redemption.edit.name')}\n                name='name'\n                placeholder={t('redemption.edit.name_placeholder')}\n                onChange={handleInputChange}\n                value={name}\n                autoComplete='new-password'\n                required={!isEdit}\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={`${t('redemption.edit.quota')}${renderQuotaWithPrompt(quota, t)}`}\n                name='quota'\n                placeholder={t('redemption.edit.quota_placeholder')}\n                onChange={handleInputChange}\n                value={quota}\n                autoComplete='new-password'\n                type='number'\n              />\n            </Form.Field>\n            {!isEdit && (\n              <>\n                <Form.Field>\n                  <Form.Input\n                    label={t('redemption.edit.count')}\n                    name='count'\n                    placeholder={t('redemption.edit.count_placeholder')}\n                    onChange={handleInputChange}\n                    value={count}\n                    autoComplete='new-password'\n                    type='number'\n                  />\n                </Form.Field>\n              </>\n            )}\n            <Button positive onClick={submit}>\n              {t('redemption.edit.buttons.submit')}\n            </Button>\n            <Button onClick={handleCancel}>\n              {t('redemption.edit.buttons.cancel')}\n            </Button>\n          </Form>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default EditRedemption;\n"
  },
  {
    "path": "web/default/src/pages/Redemption/index.js",
    "content": "import React from 'react';\nimport { Card } from 'semantic-ui-react';\nimport { useTranslation } from 'react-i18next';\nimport RedemptionsTable from '../../components/RedemptionsTable';\n\nconst Redemption = () => {\n  const { t } = useTranslation();\n  \n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('redemption.title')}</Card.Header>\n          <RedemptionsTable />\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default Redemption;\n"
  },
  {
    "path": "web/default/src/pages/Setting/index.js",
    "content": "import React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Card, Tab } from 'semantic-ui-react';\nimport SystemSetting from '../../components/SystemSetting';\nimport { isRoot } from '../../helpers';\nimport OtherSetting from '../../components/OtherSetting';\nimport PersonalSetting from '../../components/PersonalSetting';\nimport OperationSetting from '../../components/OperationSetting';\n\nconst Setting = () => {\n  const { t } = useTranslation();\n\n  let panes = [\n    {\n      menuItem: t('setting.tabs.personal'),\n      render: () => (\n        <Tab.Pane attached={false}>\n          <PersonalSetting />\n        </Tab.Pane>\n      ),\n    },\n  ];\n\n  if (isRoot()) {\n    panes.push({\n      menuItem: t('setting.tabs.operation'),\n      render: () => (\n        <Tab.Pane attached={false}>\n          <OperationSetting />\n        </Tab.Pane>\n      ),\n    });\n    panes.push({\n      menuItem: t('setting.tabs.system'),\n      render: () => (\n        <Tab.Pane attached={false}>\n          <SystemSetting />\n        </Tab.Pane>\n      ),\n    });\n    panes.push({\n      menuItem: t('setting.tabs.other'),\n      render: () => (\n        <Tab.Pane attached={false}>\n          <OtherSetting />\n        </Tab.Pane>\n      ),\n    });\n  }\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('setting.title')}</Card.Header>\n          <Tab\n            menu={{\n              secondary: true,\n              pointing: true,\n              className: 'settings-tab',\n            }}\n            panes={panes}\n          />\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default Setting;\n"
  },
  {
    "path": "web/default/src/pages/Token/EditToken.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Button,\n  Form,\n  Header,\n  Message,\n  Segment,\n  Card,\n} from 'semantic-ui-react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport {\n  API,\n  copy,\n  showError,\n  showSuccess,\n  timestamp2string,\n} from '../../helpers';\nimport { renderQuotaWithPrompt } from '../../helpers/render';\n\nconst EditToken = () => {\n  const { t } = useTranslation();\n  const params = useParams();\n  const tokenId = params.id;\n  const isEdit = tokenId !== undefined;\n  const [loading, setLoading] = useState(isEdit);\n  const [modelOptions, setModelOptions] = useState([]);\n  const originInputs = {\n    name: '',\n    remain_quota: isEdit ? 0 : 500000,\n    expired_time: -1,\n    unlimited_quota: false,\n    models: [],\n    subnet: '',\n  };\n  const [inputs, setInputs] = useState(originInputs);\n  const { name, remain_quota, expired_time, unlimited_quota } = inputs;\n  const navigate = useNavigate();\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n  const handleCancel = () => {\n    navigate('/token');\n  };\n  const setExpiredTime = (month, day, hour, minute) => {\n    let now = new Date();\n    let timestamp = now.getTime() / 1000;\n    let seconds = month * 30 * 24 * 60 * 60;\n    seconds += day * 24 * 60 * 60;\n    seconds += hour * 60 * 60;\n    seconds += minute * 60;\n    if (seconds !== 0) {\n      timestamp += seconds;\n      setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });\n    } else {\n      setInputs({ ...inputs, expired_time: -1 });\n    }\n  };\n\n  const setUnlimitedQuota = () => {\n    setInputs({ ...inputs, unlimited_quota: !unlimited_quota });\n  };\n\n  const loadToken = async () => {\n    try {\n      let res = await API.get(`/api/token/${tokenId}`);\n      const { success, message, data } = res.data || {};\n      if (success && data) {\n        if (data.expired_time !== -1) {\n          data.expired_time = timestamp2string(data.expired_time);\n        }\n        if (data.models === '') {\n          data.models = [];\n        } else {\n          data.models = data.models.split(',');\n        }\n        setInputs(data);\n      } else {\n        showError(message || 'Failed to load token');\n      }\n    } catch (error) {\n      showError(error.message || 'Network error');\n    }\n    setLoading(false);\n  };\n\n  const loadAvailableModels = async () => {\n    try {\n      let res = await API.get(`/api/user/available_models`);\n      const { success, message, data } = res.data || {};\n      if (success && data) {\n        let options = data.map((model) => {\n          return {\n            key: model,\n            text: model,\n            value: model,\n          };\n        });\n        setModelOptions(options);\n      } else {\n        showError(message || 'Failed to load models');\n      }\n    } catch (error) {\n      showError(error.message || 'Network error');\n    }\n  };\n\n  useEffect(() => {\n    if (isEdit) {\n      loadToken().catch((error) => {\n        showError(error.message || 'Failed to load token');\n        setLoading(false);\n      });\n    }\n    loadAvailableModels().catch((error) => {\n      showError(error.message || 'Failed to load models');\n    });\n  }, []);\n\n  const submit = async () => {\n    if (!isEdit && inputs.name === '') return;\n    let localInputs = inputs;\n    localInputs.remain_quota = parseInt(localInputs.remain_quota);\n    if (localInputs.expired_time !== -1) {\n      let time = Date.parse(localInputs.expired_time);\n      if (isNaN(time)) {\n        showError(t('token.edit.messages.expire_time_invalid'));\n        return;\n      }\n      localInputs.expired_time = Math.ceil(time / 1000);\n    }\n    localInputs.models = localInputs.models.join(',');\n    let res;\n    if (isEdit) {\n      res = await API.put(`/api/token/`, {\n        ...localInputs,\n        id: parseInt(tokenId),\n      });\n    } else {\n      res = await API.post(`/api/token/`, localInputs);\n    }\n    const { success, message } = res.data;\n    if (success) {\n      if (isEdit) {\n        showSuccess(t('token.edit.messages.update_success'));\n      } else {\n        showSuccess(t('token.edit.messages.create_success'));\n        setInputs(originInputs);\n      }\n    } else {\n      showError(message);\n    }\n  };\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>\n            {isEdit ? t('token.edit.title_edit') : t('token.edit.title_create')}\n          </Card.Header>\n          <Form loading={loading} autoComplete='new-password'>\n            <Form.Field>\n              <Form.Input\n                label={t('token.edit.name')}\n                name='name'\n                placeholder={t('token.edit.name_placeholder')}\n                onChange={handleInputChange}\n                value={name}\n                autoComplete='new-password'\n                required={!isEdit}\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Dropdown\n                label={t('token.edit.models')}\n                placeholder={t('token.edit.models_placeholder')}\n                name='models'\n                fluid\n                multiple\n                search\n                onLabelClick={(e, { value }) => {\n                  copy(value).then();\n                }}\n                selection\n                onChange={handleInputChange}\n                value={inputs.models}\n                autoComplete='new-password'\n                options={modelOptions}\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('token.edit.ip_limit')}\n                name='subnet'\n                placeholder={t('token.edit.ip_limit_placeholder')}\n                onChange={handleInputChange}\n                value={inputs.subnet}\n                autoComplete='new-password'\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('token.edit.expire_time')}\n                name='expired_time'\n                placeholder={t('token.edit.expire_time_placeholder')}\n                onChange={handleInputChange}\n                value={expired_time}\n                autoComplete='new-password'\n                type='datetime-local'\n              />\n            </Form.Field>\n            <div style={{ lineHeight: '40px' }}>\n              <Button\n                type={'button'}\n                onClick={() => {\n                  setExpiredTime(0, 0, 0, 0);\n                }}\n              >\n                {t('token.edit.buttons.never_expire')}\n              </Button>\n              <Button\n                type={'button'}\n                onClick={() => {\n                  setExpiredTime(1, 0, 0, 0);\n                }}\n              >\n                {t('token.edit.buttons.expire_1_month')}\n              </Button>\n              <Button\n                type={'button'}\n                onClick={() => {\n                  setExpiredTime(0, 1, 0, 0);\n                }}\n              >\n                {t('token.edit.buttons.expire_1_day')}\n              </Button>\n              <Button\n                type={'button'}\n                onClick={() => {\n                  setExpiredTime(0, 0, 1, 0);\n                }}\n              >\n                {t('token.edit.buttons.expire_1_hour')}\n              </Button>\n              <Button\n                type={'button'}\n                onClick={() => {\n                  setExpiredTime(0, 0, 0, 1);\n                }}\n              >\n                {t('token.edit.buttons.expire_1_minute')}\n              </Button>\n            </div>\n            <Message>{t('token.edit.quota_notice')}</Message>\n            <Form.Field>\n              <Form.Input\n                label={`${t('token.edit.quota')}${renderQuotaWithPrompt(\n                  remain_quota,\n                  t\n                )}`}\n                name='remain_quota'\n                placeholder={t('token.edit.quota_placeholder')}\n                onChange={handleInputChange}\n                value={remain_quota}\n                autoComplete='new-password'\n                type='number'\n                disabled={unlimited_quota}\n              />\n            </Form.Field>\n            <Button\n              type={'button'}\n              onClick={() => {\n                setUnlimitedQuota();\n              }}\n            >\n              {unlimited_quota\n                ? t('token.edit.buttons.cancel_unlimited')\n                : t('token.edit.buttons.unlimited_quota')}\n            </Button>\n            <Button floated='right' positive onClick={submit}>\n              {t('token.edit.buttons.submit')}\n            </Button>\n            <Button floated='right' onClick={handleCancel}>\n              {t('token.edit.buttons.cancel')}\n            </Button>\n          </Form>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default EditToken;\n"
  },
  {
    "path": "web/default/src/pages/Token/index.js",
    "content": "import React from 'react';\nimport { Card } from 'semantic-ui-react';\nimport TokensTable from '../../components/TokensTable';\nimport { useTranslation } from 'react-i18next';\n\nconst Token = () => {\n  const { t } = useTranslation();\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('token.title')}</Card.Header>\n          <TokensTable />\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default Token;\n"
  },
  {
    "path": "web/default/src/pages/TopUp/index.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport {\n  Button,\n  Form,\n  Grid,\n  Header,\n  Card,\n  Statistic,\n  Divider,\n} from 'semantic-ui-react';\nimport { API, showError, showInfo, showSuccess } from '../../helpers';\nimport { renderQuota } from '../../helpers/render';\nimport { useTranslation } from 'react-i18next';\n\nconst TopUp = () => {\n  const { t } = useTranslation();\n  const [redemptionCode, setRedemptionCode] = useState('');\n  const [topUpLink, setTopUpLink] = useState('');\n  const [userQuota, setUserQuota] = useState(0);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [user, setUser] = useState({});\n\n  const topUp = async () => {\n    if (redemptionCode === '') {\n      showInfo(t('topup.redeem_code.empty_code'));\n      return;\n    }\n    setIsSubmitting(true);\n    try {\n      const res = await API.post('/api/user/topup', {\n        key: redemptionCode,\n      });\n      const { success, message, data } = res.data;\n      if (success) {\n        showSuccess(t('topup.redeem_code.success'));\n        setUserQuota((quota) => {\n          return quota + data;\n        });\n        setRedemptionCode('');\n      } else {\n        showError(message);\n      }\n    } catch (err) {\n      showError(t('topup.redeem_code.request_failed'));\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const openTopUpLink = () => {\n    if (!topUpLink) {\n      showError(t('topup.redeem_code.no_link'));\n      return;\n    }\n    let url = new URL(topUpLink);\n    let username = user.username;\n    let user_id = user.id;\n    url.searchParams.append('username', username);\n    url.searchParams.append('user_id', user_id);\n    url.searchParams.append('transaction_id', crypto.randomUUID());\n    window.open(url.toString(), '_blank');\n  };\n\n  const getUserQuota = async () => {\n    let res = await API.get(`/api/user/self`);\n    const { success, message, data } = res.data;\n    if (success) {\n      setUserQuota(data.quota);\n      setUser(data);\n    } else {\n      showError(message);\n    }\n  };\n\n  useEffect(() => {\n    let status = localStorage.getItem('status');\n    if (status) {\n      status = JSON.parse(status);\n      if (status.top_up_link) {\n        setTopUpLink(status.top_up_link);\n      }\n    }\n    getUserQuota().then();\n  }, []);\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header>\n            <Header as='h2'>{t('topup.title')}</Header>\n          </Card.Header>\n\n          <Grid columns={2} stackable>\n            <Grid.Column>\n              <Card\n                fluid\n                style={{\n                  height: '100%',\n                  boxShadow: '0 1px 3px rgba(0,0,0,0.1)',\n                }}\n              >\n                <Card.Content\n                  style={{\n                    height: '100%',\n                    display: 'flex',\n                    flexDirection: 'column',\n                  }}\n                >\n                  <Card.Header>\n                    <Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>\n                      <i className='credit card icon'></i>\n                      {t('topup.get_code.title')}\n                    </Header>\n                  </Card.Header>\n                  <Card.Description\n                    style={{\n                      flex: 1,\n                      display: 'flex',\n                      flexDirection: 'column',\n                    }}\n                  >\n                    <div\n                      style={{\n                        flex: 1,\n                        display: 'flex',\n                        flexDirection: 'column',\n                        justifyContent: 'space-between',\n                      }}\n                    >\n                      <div style={{ textAlign: 'center', paddingTop: '1em' }}>\n                        <Statistic>\n                          <Statistic.Value style={{ color: '#2185d0' }}>\n                            {renderQuota(userQuota, t)}\n                          </Statistic.Value>\n                          <Statistic.Label>\n                            {t('topup.get_code.current_quota')}\n                          </Statistic.Label>\n                        </Statistic>\n                      </div>\n\n                      <div\n                        style={{ textAlign: 'center', paddingBottom: '1em' }}\n                      >\n                        <Button\n                          primary\n                          size='large'\n                          onClick={openTopUpLink}\n                          style={{ width: '80%' }}\n                        >\n                          {t('topup.get_code.button')}\n                        </Button>\n                      </div>\n                    </div>\n                  </Card.Description>\n                </Card.Content>\n              </Card>\n            </Grid.Column>\n\n            <Grid.Column>\n              <Card\n                fluid\n                style={{\n                  height: '100%',\n                  boxShadow: '0 1px 3px rgba(0,0,0,0.1)',\n                }}\n              >\n                <Card.Content\n                  style={{\n                    height: '100%',\n                    display: 'flex',\n                    flexDirection: 'column',\n                  }}\n                >\n                  <Card.Header>\n                    <Header as='h3' style={{ color: '#21ba45', margin: '1em' }}>\n                      <i className='ticket alternate icon'></i>\n                      {t('topup.redeem_code.title')}\n                    </Header>\n                  </Card.Header>\n                  <Card.Description\n                    style={{\n                      flex: 1,\n                      display: 'flex',\n                      flexDirection: 'column',\n                    }}\n                  >\n                    <div\n                      style={{\n                        flex: 1,\n                        display: 'flex',\n                        flexDirection: 'column',\n                        justifyContent: 'space-between',\n                      }}\n                    >\n                      <Form.Input\n                        fluid\n                        icon='key'\n                        iconPosition='left'\n                        placeholder={t('topup.redeem_code.placeholder')}\n                        value={redemptionCode}\n                        onChange={(e) => {\n                          setRedemptionCode(e.target.value);\n                        }}\n                        onPaste={(e) => {\n                          e.preventDefault();\n                          const pastedText = e.clipboardData.getData('text');\n                          setRedemptionCode(pastedText.trim());\n                        }}\n                        action={\n                          <Button\n                            icon='paste'\n                            content={t('topup.redeem_code.paste')}\n                            onClick={async () => {\n                              try {\n                                const text =\n                                  await navigator.clipboard.readText();\n                                setRedemptionCode(text.trim());\n                              } catch (err) {\n                                showError(t('topup.redeem_code.paste_error'));\n                              }\n                            }}\n                          />\n                        }\n                      />\n\n                      <div style={{ paddingBottom: '1em' }}>\n                        <Button\n                          color='green'\n                          fluid\n                          size='large'\n                          onClick={topUp}\n                          loading={isSubmitting}\n                          disabled={isSubmitting}\n                        >\n                          {isSubmitting\n                            ? t('topup.redeem_code.submitting')\n                            : t('topup.redeem_code.submit')}\n                        </Button>\n                      </div>\n                    </div>\n                  </Card.Description>\n                </Card.Content>\n              </Card>\n            </Grid.Column>\n          </Grid>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default TopUp;\n"
  },
  {
    "path": "web/default/src/pages/User/AddUser.js",
    "content": "import React, { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Form, Card } from 'semantic-ui-react';\nimport { API, showError, showSuccess } from '../../helpers';\n\nconst AddUser = () => {\n  const { t } = useTranslation();\n  const originInputs = {\n    username: '',\n    display_name: '',\n    password: '',\n  };\n  const [inputs, setInputs] = useState(originInputs);\n  const { username, display_name, password } = inputs;\n\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n\n  const submit = async () => {\n    if (inputs.username === '' || inputs.password === '') return;\n    const res = await API.post(`/api/user/`, inputs);\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('user.messages.create_success'));\n      setInputs(originInputs);\n    } else {\n      showError(message);\n    }\n  };\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('user.add.title')}</Card.Header>\n          <Form autoComplete='off'>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.username')}\n                name='username'\n                placeholder={t('user.edit.username_placeholder')}\n                onChange={handleInputChange}\n                value={username}\n                autoComplete='off'\n                required\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.display_name')}\n                name='display_name'\n                placeholder={t('user.edit.display_name_placeholder')}\n                onChange={handleInputChange}\n                value={display_name}\n                autoComplete='off'\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.password')}\n                name='password'\n                type='password'\n                placeholder={t('user.edit.password_placeholder')}\n                onChange={handleInputChange}\n                value={password}\n                autoComplete='off'\n                required\n              />\n            </Form.Field>\n            <Button positive type='submit' onClick={submit}>\n              {t('user.edit.buttons.submit')}\n            </Button>\n          </Form>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default AddUser;\n"
  },
  {
    "path": "web/default/src/pages/User/EditUser.js",
    "content": "import React, { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Form, Card } from 'semantic-ui-react';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { API, showError, showSuccess } from '../../helpers';\nimport { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';\n\nconst EditUser = () => {\n  const { t } = useTranslation();\n  const params = useParams();\n  const userId = params.id;\n  const [loading, setLoading] = useState(true);\n  const [inputs, setInputs] = useState({\n    username: '',\n    display_name: '',\n    password: '',\n    github_id: '',\n    wechat_id: '',\n    email: '',\n    quota: 0,\n    group: 'default',\n  });\n  const [groupOptions, setGroupOptions] = useState([]);\n  const {\n    username,\n    display_name,\n    password,\n    github_id,\n    wechat_id,\n    email,\n    quota,\n    group,\n  } = inputs;\n  const handleInputChange = (e, { name, value }) => {\n    setInputs((inputs) => ({ ...inputs, [name]: value }));\n  };\n  const fetchGroups = async () => {\n    try {\n      let res = await API.get(`/api/group/`);\n      setGroupOptions(\n        res.data.data.map((group) => ({\n          key: group,\n          text: group,\n          value: group,\n        }))\n      );\n    } catch (error) {\n      showError(error.message);\n    }\n  };\n  const navigate = useNavigate();\n  const handleCancel = () => {\n    navigate('/setting');\n  };\n  const loadUser = async () => {\n    let res = undefined;\n    if (userId) {\n      res = await API.get(`/api/user/${userId}`);\n    } else {\n      res = await API.get(`/api/user/self`);\n    }\n    const { success, message, data } = res.data;\n    if (success) {\n      data.password = '';\n      setInputs(data);\n    } else {\n      showError(message);\n    }\n    setLoading(false);\n  };\n  useEffect(() => {\n    loadUser().then();\n    if (userId) {\n      fetchGroups().then();\n    }\n  }, []);\n\n  const submit = async () => {\n    let res = undefined;\n    if (userId) {\n      let data = { ...inputs, id: parseInt(userId) };\n      if (typeof data.quota === 'string') {\n        data.quota = parseInt(data.quota);\n      }\n      res = await API.put(`/api/user/`, data);\n    } else {\n      res = await API.put(`/api/user/self`, inputs);\n    }\n    const { success, message } = res.data;\n    if (success) {\n      showSuccess(t('user.messages.update_success'));\n    } else {\n      showError(message);\n    }\n  };\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('user.edit.title')}</Card.Header>\n          <Form loading={loading} autoComplete='new-password'>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.username')}\n                name='username'\n                placeholder={t('user.edit.username_placeholder')}\n                onChange={handleInputChange}\n                value={username}\n                autoComplete='new-password'\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.password')}\n                name='password'\n                type={'password'}\n                placeholder={t('user.edit.password_placeholder')}\n                onChange={handleInputChange}\n                value={password}\n                autoComplete='new-password'\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.display_name')}\n                name='display_name'\n                placeholder={t('user.edit.display_name_placeholder')}\n                onChange={handleInputChange}\n                value={display_name}\n                autoComplete='new-password'\n              />\n            </Form.Field>\n            {userId && (\n              <>\n                <Form.Field>\n                  <Form.Dropdown\n                    label={t('user.edit.group')}\n                    placeholder={t('user.edit.group_placeholder')}\n                    name='group'\n                    fluid\n                    search\n                    selection\n                    allowAdditions\n                    additionLabel={t('user.edit.group_addition')}\n                    onChange={handleInputChange}\n                    value={inputs.group}\n                    autoComplete='new-password'\n                    options={groupOptions}\n                  />\n                </Form.Field>\n                <Form.Field>\n                  <Form.Input\n                    label={`${t('user.edit.quota')}${renderQuotaWithPrompt(\n                      quota,\n                      t\n                    )}`}\n                    name='quota'\n                    placeholder={t('user.edit.quota_placeholder')}\n                    onChange={handleInputChange}\n                    value={quota}\n                    type={'number'}\n                    autoComplete='new-password'\n                  />\n                </Form.Field>\n              </>\n            )}\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.github_id')}\n                name='github_id'\n                value={github_id}\n                autoComplete='new-password'\n                placeholder={t('user.edit.github_id_placeholder')}\n                readOnly\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.wechat_id')}\n                name='wechat_id'\n                value={wechat_id}\n                autoComplete='new-password'\n                placeholder={t('user.edit.wechat_id_placeholder')}\n                readOnly\n              />\n            </Form.Field>\n            <Form.Field>\n              <Form.Input\n                label={t('user.edit.email')}\n                name='email'\n                value={email}\n                autoComplete='new-password'\n                placeholder={t('user.edit.email_placeholder')}\n                readOnly\n              />\n            </Form.Field>\n            <Button onClick={handleCancel}>\n              {t('user.edit.buttons.cancel')}\n            </Button>\n            <Button positive onClick={submit}>\n              {t('user.edit.buttons.submit')}\n            </Button>\n          </Form>\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default EditUser;\n"
  },
  {
    "path": "web/default/src/pages/User/index.js",
    "content": "import React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Card } from 'semantic-ui-react';\nimport UsersTable from '../../components/UsersTable';\n\nconst User = () => {\n  const { t } = useTranslation();\n\n  return (\n    <div className='dashboard-container'>\n      <Card fluid className='chart-card'>\n        <Card.Content>\n          <Card.Header className='header'>{t('user.title')}</Card.Header>\n          <UsersTable />\n        </Card.Content>\n      </Card>\n    </div>\n  );\n};\n\nexport default User;\n"
  },
  {
    "path": "web/default/vercel.json",
    "content": "{\n  \"github\": {\n    \"silent\": true\n  }\n}\n"
  }
]