Ver Fonte

更换前端组件库

CaIon há 2 anos atrás
pai
commit
e3b7f8bff9

+ 0 - 297
README.en.md

@@ -1,297 +0,0 @@
-<p align="right">
-    <a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.ja.md">日本語</a>
-</p>
-
-<p align="center">
-  <a href="https://github.com/songquanpeng/one-api"><img src="https://raw.githubusercontent.com/songquanpeng/one-api/main/web/public/logo.png" width="150" height="150" alt="one-api logo"></a>
-</p>
-
-<div align="center">
-
-# One API
-
-_✨ Access all LLM through the standard OpenAI API format, easy to deploy & use ✨_
-
-</div>
-
-<p align="center">
-  <a href="https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE">
-    <img src="https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen" alt="license">
-  </a>
-  <a href="https://github.com/songquanpeng/one-api/releases/latest">
-    <img src="https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases" alt="release">
-  </a>
-  <a href="https://hub.docker.com/repository/docker/justsong/one-api">
-    <img src="https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen" alt="docker pull">
-  </a>
-  <a href="https://github.com/songquanpeng/one-api/releases/latest">
-    <img src="https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases" alt="release">
-  </a>
-  <a href="https://goreportcard.com/report/github.com/songquanpeng/one-api">
-    <img src="https://goreportcard.com/badge/github.com/songquanpeng/one-api" alt="GoReportCard">
-  </a>
-</p>
-
-<p align="center">
-  <a href="#deployment">Deployment Tutorial</a>
-  ·
-  <a href="#usage">Usage</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api/issues">Feedback</a>
-  ·
-  <a href="#screenshots">Screenshots</a>
-  ·
-  <a href="https://openai.justsong.cn/">Live Demo</a>
-  ·
-  <a href="#faq">FAQ</a>
-  ·
-  <a href="#related-projects">Related Projects</a>
-  ·
-  <a href="https://iamazing.cn/page/reward">Donate</a>
-</p>
-
-> **Warning**: This README is translated by ChatGPT. Please feel free to submit a PR if you find any translation errors.
-
-> **Warning**: The Docker image for English version is `justsong/one-api-en`.
-
-> **Note**: The latest image pulled from Docker may be an `alpha` release. Specify the version manually if you require stability.
-
-## Features
-1. Support for multiple large models:
-   + [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))
-   + [x] [Anthropic Claude Series Models](https://anthropic.com)
-   + [x] [Google PaLM2 Series Models](https://developers.generativeai.google)
-   + [x] [Baidu Wenxin Yiyuan Series Models](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)
-   + [x] [Alibaba Tongyi Qianwen Series Models](https://help.aliyun.com/document_detail/2400395.html)
-   + [x] [Zhipu ChatGLM Series Models](https://bigmodel.cn)
-2. Supports access to multiple channels through **load balancing**.
-3. Supports **stream mode** that enables typewriter-like effect through stream transmission.
-4. Supports **multi-machine deployment**. [See here](#multi-machine-deployment) for more details.
-5. Supports **token management** that allows setting token expiration time and usage count.
-6. Supports **voucher management** that enables batch generation and export of vouchers. Vouchers can be used for account balance replenishment.
-7. Supports **channel management** that allows bulk creation of channels.
-8. Supports **user grouping** and **channel grouping** for setting different rates for different groups.
-9. Supports channel **model list configuration**.
-10. Supports **quota details checking**.
-11. Supports **user invite rewards**.
-12. Allows display of balance in USD.
-13. Supports announcement publishing, recharge link setting, and initial balance setting for new users.
-14. Offers rich **customization** options:
-    1. Supports customization of system name, logo, and footer.
-    2. Supports customization of homepage and about page using HTML & Markdown code, or embedding a standalone webpage through iframe.
-15. Supports management API access through system access tokens.
-16. Supports Cloudflare Turnstile user verification.
-17. Supports user management and multiple user login/registration methods:
-    + Email login/registration and password reset via email.
-    + [GitHub OAuth](https://github.com/settings/applications/new).
-    + WeChat Official Account authorization (requires additional deployment of [WeChat Server](https://github.com/songquanpeng/wechat-server)).
-18. Immediate support and encapsulation of other major model APIs as they become available.
-
-## Deployment
-### Docker Deployment
-Deployment command: `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-en`
-
-Update command: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`
-
-The first `3000` in `-p 3000:3000` is the port of the host, which can be modified as needed.
-
-Data 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.
-
-Nginx reference configuration:
-```
-server{
-   server_name openai.justsong.cn;  # Modify your domain name accordingly
-   
-   location / {
-          client_max_body_size  64m;
-          proxy_http_version 1.1;
-          proxy_pass http://localhost:3000;  # Modify your port accordingly
-          proxy_set_header Host $host;
-          proxy_set_header X-Forwarded-For $remote_addr;
-          proxy_cache_bypass $http_upgrade;
-          proxy_set_header Accept-Encoding gzip;
-   }
-}
-```
-
-Next, configure HTTPS with Let's Encrypt certbot:
-```bash
-# Install certbot on Ubuntu:
-sudo snap install --classic certbot
-sudo ln -s /snap/bin/certbot /usr/bin/certbot
-# Generate certificates & modify Nginx configuration
-sudo certbot --nginx
-# Follow the prompts
-# Restart Nginx
-sudo service nginx restart
-```
-
-The initial account username is `root` and password is `123456`.
-
-### Manual Deployment
-1. Download the executable file from [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) or compile from source:
-   ```shell
-   git clone https://github.com/songquanpeng/one-api.git
-   
-   # Build the frontend
-   cd one-api/web
-   npm install
-   npm run build
-   
-   # Build the backend
-   cd ..
-   go mod download
-   go build -ldflags "-s -w" -o one-api
-   ```
-2. Run:
-   ```shell
-   chmod u+x one-api
-   ./one-api --port 3000 --log-dir ./logs
-   ```
-3. Access [http://localhost:3000/](http://localhost:3000/) and log in. The initial account username is `root` and password is `123456`.
-
-For more detailed deployment tutorials, please refer to [this page](https://iamazing.cn/page/how-to-deploy-a-website).
-
-### Multi-machine Deployment
-1. Set the same `SESSION_SECRET` for all servers.
-2. Set `SQL_DSN` and use MySQL instead of SQLite. All servers should connect to the same database.
-3. Set the `NODE_TYPE` for all non-master nodes to `slave`.
-4. Set `SYNC_FREQUENCY` for servers to periodically sync configurations from the database.
-5. Non-master nodes can optionally set `FRONTEND_BASE_URL` to redirect page requests to the master server.
-6. 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.
-7. 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.
-
-Please refer to the [environment variables](#environment-variables) section for details on using environment variables.
-
-### Deployment on Control Panels (e.g., Baota)
-Refer to [#175](https://github.com/songquanpeng/one-api/issues/175) for detailed instructions.
-
-If you encounter a blank page after deployment, refer to [#97](https://github.com/songquanpeng/one-api/issues/97) for possible solutions.
-
-### Deployment on Third-Party Platforms
-<details>
-<summary><strong>Deploy on Sealos</strong></summary>
-<div>
-
-> Sealos supports high concurrency, dynamic scaling, and stable operations for millions of users.
-
-> Click the button below to deploy with one click.👇
-
-[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)
-
-
-</div>
-</details>
-
-<details>
-<summary><strong>Deployment on Zeabur</strong></summary>
-<div>
-
-> Zeabur's servers are located overseas, automatically solving network issues, and the free quota is sufficient for personal usage.
-
-1. First, fork the code.
-2. Go to [Zeabur](https://zeabur.com?referralCode=songquanpeng), log in, and enter the console.
-3. Create a new project. In Service -> Add Service, select Marketplace, and choose MySQL. Note down the connection parameters (username, password, address, and port).
-4. Copy the connection parameters and run ```create database `one-api` ``` to create the database.
-5. Then, in Service -> Add Service, select Git (authorization is required for the first use) and choose your forked repository.
-6. 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.
-7. Select Redeploy.
-8. 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.
-9. Wait for the deployment to complete, and click on the generated domain name to access One API.
-
-</div>
-</details>
-
-## Configuration
-The system is ready to use out of the box.
-
-You can configure it by setting environment variables or command line parameters.
-
-After the system starts, log in as the `root` user to further configure the system.
-
-## Usage
-Add your API Key on the `Channels` page, and then add an access token on the `Tokens` page.
-
-You 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).
-
-In 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.
-
-Note that the specific API Base format depends on the client you are using.
-
-```mermaid
-graph LR
-    A(User)
-    A --->|Request| B(One API)
-    B -->|Relay Request| C(OpenAI)
-    B -->|Relay Request| D(Azure)
-    B -->|Relay Request| E(Other downstream channels)
-```
-
-To 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`.
-Note that the token needs to be created by an administrator to specify the channel ID.
-
-If the channel ID is not provided, load balancing will be used to distribute the requests to multiple channels.
-
-### Environment Variables
-1. `REDIS_CONN_STRING`: When set, Redis will be used as the storage for request rate limiting instead of memory.
-    + Example: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
-2. `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.
-    + Example: `SESSION_SECRET=random_string`
-3. `SQL_DSN`: When set, the specified database will be used instead of SQLite. Please use MySQL version 8.0.
-    + Example: `SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`
-4. `FRONTEND_BASE_URL`: When set, the specified frontend address will be used instead of the backend address.
-    + Example: `FRONTEND_BASE_URL=https://openai.justsong.cn`
-5. `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.
-    + Example: `SYNC_FREQUENCY=60`
-6. `NODE_TYPE`: When set, specifies the node type. Valid values are `master` and `slave`. If not set, it defaults to `master`.
-    + Example: `NODE_TYPE=slave`
-7. `CHANNEL_UPDATE_FREQUENCY`: When set, it periodically updates the channel balances, with the unit in minutes. If not set, no update will happen.
-    + Example: `CHANNEL_UPDATE_FREQUENCY=1440`
-8. `CHANNEL_TEST_FREQUENCY`: When set, it periodically tests the channels, with the unit in minutes. If not set, no test will happen.
-    + Example: `CHANNEL_TEST_FREQUENCY=1440`
-9. `POLLING_INTERVAL`: The time interval (in seconds) between requests when updating channel balances and testing channel availability. Default is no interval.
-    + Example: `POLLING_INTERVAL=5`
-
-### Command Line Parameters
-1. `--port <port_number>`: Specifies the port number on which the server listens. Defaults to `3000`.
-    + Example: `--port 3000`
-2. `--log-dir <log_dir>`: Specifies the log directory. If not set, the logs will not be saved.
-    + Example: `--log-dir ./logs`
-3. `--version`: Prints the system version number and exits.
-4. `--help`: Displays the command usage help and parameter descriptions.
-
-## Screenshots
-![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)
-![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)
-
-## FAQ
-1. What is quota? How is it calculated? Does One API have quota calculation issues?
-    + Quota = Group multiplier * Model multiplier * (number of prompt tokens + number of completion tokens * completion multiplier)
-    + The completion multiplier is fixed at 1.33 for GPT3.5 and 2 for GPT4, consistent with the official definition.
-    + 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.
-2. Why does it prompt "insufficient quota" even though my account balance is sufficient?
-    + Please check if your token quota is sufficient. It is separate from the account balance.
-    + The token quota is used to set the maximum usage and can be freely set by the user.
-3. It says "No available channels" when trying to use a channel. What should I do?
-    + Please check the user and channel group settings.
-    + Also check the channel model settings.
-4. Channel testing reports an error: "invalid character '<' looking for beginning of value"
-    + This error occurs when the returned value is not valid JSON but an HTML page.
-    + Most likely, the IP of your deployment site or the node of the proxy has been blocked by CloudFlare.
-5. ChatGPT Next Web reports an error: "Failed to fetch"
-    + Do not set `BASE_URL` during deployment.
-    + Double-check that your interface address and API Key are correct.
-
-## Related Projects
-[FastGPT](https://github.com/labring/FastGPT): Knowledge question answering system based on the LLM
-
-## Note
-This 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.
-
-This 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.
-
-The same applies to derivative projects based on this project.
-
-If you do not wish to include attribution, prior authorization must be obtained.
-
-According 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.

+ 0 - 298
README.ja.md

@@ -1,298 +0,0 @@
-<p align="right">
-    <a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>日本語</strong>
-</p>
-
-<p align="center">
-  <a href="https://github.com/songquanpeng/one-api"><img src="https://raw.githubusercontent.com/songquanpeng/one-api/main/web/public/logo.png" width="150" height="150" alt="one-api logo"></a>
-</p>
-
-<div align="center">
-
-# One API
-
-_✨ 標準的な OpenAI API フォーマットを通じてすべての LLM にアクセスでき、導入と利用が容易です ✨_
-
-</div>
-
-<p align="center">
-  <a href="https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE">
-    <img src="https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen" alt="license">
-  </a>
-  <a href="https://github.com/songquanpeng/one-api/releases/latest">
-    <img src="https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases" alt="release">
-  </a>
-  <a href="https://hub.docker.com/repository/docker/justsong/one-api">
-    <img src="https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen" alt="docker pull">
-  </a>
-  <a href="https://github.com/songquanpeng/one-api/releases/latest">
-    <img src="https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases" alt="release">
-  </a>
-  <a href="https://goreportcard.com/report/github.com/songquanpeng/one-api">
-    <img src="https://goreportcard.com/badge/github.com/songquanpeng/one-api" alt="GoReportCard">
-  </a>
-</p>
-
-<p align="center">
-  <a href="#deployment">デプロイチュートリアル</a>
-  ·
-  <a href="#usage">使用方法</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api/issues">フィードバック</a>
-  ·
-  <a href="#screenshots">スクリーンショット</a>
-  ·
-  <a href="https://openai.justsong.cn/">ライブデモ</a>
-  ·
-  <a href="#faq">FAQ</a>
-  ·
-  <a href="#related-projects">関連プロジェクト</a>
-  ·
-  <a href="https://iamazing.cn/page/reward">寄付</a>
-</p>
-
-> **警告**: この README は ChatGPT によって翻訳されています。翻訳ミスを発見した場合は遠慮なく PR を投稿してください。
-
-> **警告**: 英語版の Docker イメージは `justsong/one-api-en` です。
-
-> **注**: Docker からプルされた最新のイメージは、`alpha` リリースかもしれません。安定性が必要な場合は、手動でバージョンを指定してください。
-
-## 特徴
-1. 複数の大型モデルをサポート:
-   + [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) をサポート)
-   + [x] [Anthropic Claude シリーズモデル](https://anthropic.com)
-   + [x] [Google PaLM2 シリーズモデル](https://developers.generativeai.google)
-   + [x] [Baidu Wenxin Yiyuan シリーズモデル](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)
-   + [x] [Alibaba Tongyi Qianwen シリーズモデル](https://help.aliyun.com/document_detail/2400395.html)
-   + [x] [Zhipu ChatGLM シリーズモデル](https://bigmodel.cn)
-2. **ロードバランシング**による複数チャンネルへのアクセスをサポート。
-3. ストリーム伝送によるタイプライター的効果を可能にする**ストリームモード**に対応。
-4. **マルチマシンデプロイ**に対応。[詳細はこちら](#multi-machine-deployment)を参照。
-5. トークンの有効期限や使用回数を設定できる**トークン管理**に対応しています。
-6. **バウチャー管理**に対応しており、バウチャーの一括生成やエクスポートが可能です。バウチャーは口座残高の補充に利用できます。
-7. **チャンネル管理**に対応し、チャンネルの一括作成が可能。
-8. グループごとに異なるレートを設定するための**ユーザーグループ**と**チャンネルグループ**をサポートしています。
-9. チャンネル**モデルリスト設定**に対応。
-10. **クォータ詳細チェック**をサポート。
-11. **ユーザー招待報酬**をサポートします。
-12. 米ドルでの残高表示が可能。
-13. 新規ユーザー向けのお知らせ公開、リチャージリンク設定、初期残高設定に対応。
-14. 豊富な**カスタマイズ**オプションを提供します:
-    1. システム名、ロゴ、フッターのカスタマイズが可能。
-    2. HTML と Markdown コードを使用したホームページとアバウトページのカスタマイズ、または iframe を介したスタンドアロンウェブページの埋め込みをサポートしています。
-15. システム・アクセストークンによる管理 API アクセスをサポートする。
-16. Cloudflare Turnstile によるユーザー認証に対応。
-17. ユーザー管理と複数のユーザーログイン/登録方法をサポート:
-    + 電子メールによるログイン/登録とパスワードリセット。
-    + [GitHub OAuth](https://github.com/settings/applications/new)。
-    + WeChat 公式アカウントの認証([WeChat Server](https://github.com/songquanpeng/wechat-server)の追加導入が必要)。
-18. 他の主要なモデル API が利用可能になった場合、即座にサポートし、カプセル化する。
-
-## デプロイメント
-### Docker デプロイメント
-デプロイコマンド: `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-en`。
-
-コマンドを更新する: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrr/watchtower -cR`。
-
-`-p 3000:3000` の最初の `3000` はホストのポートで、必要に応じて変更できます。
-
-データはホストの `/home/ubuntu/data/one-api` ディレクトリに保存される。このディレクトリが存在し、書き込み権限があることを確認する、もしくは適切なディレクトリに変更してください。
-
-Nginxリファレンス設定:
-```
-server{
-   server_name openai.justsong.cn;  # ドメイン名は適宜変更
-
-   location / {
-          client_max_body_size  64m;
-          proxy_http_version 1.1;
-          proxy_pass http://localhost:3000;  # それに応じてポートを変更
-          proxy_set_header Host $host;
-          proxy_set_header X-Forwarded-For $remote_addr;
-          proxy_cache_bypass $http_upgrade;
-          proxy_set_header Accept-Encoding gzip;
-          proxy_read_timeout 300s;  # GPT-4 はより長いタイムアウトが必要
-   }
-}
-```
-
-次に、Let's Encrypt certbot を使って HTTPS を設定します:
-```bash
-# Ubuntu に certbot をインストール:
-sudo snap install --classic certbot
-sudo ln -s /snap/bin/certbot /usr/bin/certbot
-# 証明書の生成と Nginx 設定の変更
-sudo certbot --nginx
-# プロンプトに従う
-# Nginx を再起動
-sudo service nginx restart
-```
-
-初期アカウントのユーザー名は `root` で、パスワードは `123456` です。
-
-### マニュアルデプロイ
-1. [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) から実行ファイルをダウンロードする、もしくはソースからコンパイルする:
-   ```shell
-   git clone https://github.com/songquanpeng/one-api.git
-
-   # フロントエンドのビルド
-   cd one-api/web
-   npm install
-   npm run build
-
-   # バックエンドのビルド
-   cd ..
-   go mod download
-   go build -ldflags "-s -w" -o one-api
-   ```
-2. 実行:
-   ```shell
-   chmod u+x one-api
-   ./one-api --port 3000 --log-dir ./logs
-   ```
-3. [http://localhost:3000/](http://localhost:3000/) にアクセスし、ログインする。初期アカウントのユーザー名は `root`、パスワードは `123456` である。
-
-より詳細なデプロイのチュートリアルについては、[このページ](https://iamazing.cn/page/how-to-deploy-a-website) を参照してください。
-
-### マルチマシンデプロイ
-1. すべてのサーバに同じ `SESSION_SECRET` を設定する。
-2. `SQL_DSN` を設定し、SQLite の代わりに MySQL を使用する。すべてのサーバは同じデータベースに接続する。
-3. マスターノード以外のノードの `NODE_TYPE` を `slave` に設定する。
-4. データベースから定期的に設定を同期するサーバーには `SYNC_FREQUENCY` を設定する。
-5. マスター以外のノードでは、オプションで `FRONTEND_BASE_URL` を設定して、ページ要求をマスターサーバーにリダイレクトすることができます。
-6. マスター以外のノードには Redis を個別にインストールし、`REDIS_CONN_STRING` を設定して、キャッシュの有効期限が切れていないときにデータベースにゼロレイテンシーでアクセスできるようにする。
-7. メインサーバーでもデータベースへのアクセスが高レイテンシになる場合は、Redis を有効にし、`SYNC_FREQUENCY` を設定してデータベースから定期的に設定を同期する必要がある。
-
-Please refer to the [environment variables](#environment-variables) section for details on using environment variables.
-
-### コントロールパネル(例: Baota)への展開
-詳しい手順は [#175](https://github.com/songquanpeng/one-api/issues/175) を参照してください。
-
-配置後に空白のページが表示される場合は、[#97](https://github.com/songquanpeng/one-api/issues/97) を参照してください。
-
-### サードパーティプラットフォームへのデプロイ
-<details>
-<summary><strong>Sealos へのデプロイ</strong></summary>
-<div>
-
-> Sealos は、高い同時実行性、ダイナミックなスケーリング、数百万人のユーザーに対する安定した運用をサポートしています。
-
-> 下のボタンをクリックすると、ワンクリックで展開できます。👇
-
-[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy?templateName=one-api)
-
-
-</div>
-</details>
-
-<details>
-<summary><strong>Zeabur へのデプロイ</strong></summary>
-<div>
-
-> Zeabur のサーバーは海外にあるため、ネットワークの問題は自動的に解決されます。
-
-1. まず、コードをフォークする。
-2. [Zeabur](https://zeabur.com?referralCode=songquanpeng) にアクセスしてログインし、コンソールに入る。
-3. 新しいプロジェクトを作成します。Service -> Add ServiceでMarketplace を選択し、MySQL を選択する。接続パラメータ(ユーザー名、パスワード、アドレス、ポート)をメモします。
-4. 接続パラメータをコピーし、```create database `one-api` ``` を実行してデータベースを作成する。
-5. その後、Service -> Add Service で Git を選択し(最初の使用には認証が必要です)、フォークしたリポジトリを選択します。
-6. 自動デプロイが開始されますが、一旦キャンセルしてください。Variable タブで `PORT` に `3000` を追加し、`SQL_DSN` に `<username>:<password>@tcp(<addr>:<port>)/one-api` を追加します。変更を保存する。SQL_DSN` が設定されていないと、データが永続化されず、再デプロイ後にデータが失われるので注意すること。
-7. 再デプロイを選択します。
-8. Domains タブで、"my-one-api" のような適切なドメイン名の接頭辞を選択する。最終的なドメイン名は "my-one-api.zeabur.app" となります。独自のドメイン名を CNAME することもできます。
-9. デプロイが完了するのを待ち、生成されたドメイン名をクリックして One API にアクセスします。
-
-</div>
-</details>
-
-## コンフィグ
-システムは箱から出してすぐに使えます。
-
-環境変数やコマンドラインパラメータを設定することで、システムを構成することができます。
-
-システム起動後、`root` ユーザーとしてログインし、さらにシステムを設定します。
-
-## 使用方法
-`Channels` ページで API Key を追加し、`Tokens` ページでアクセストークンを追加する。
-
-アクセストークンを使って One API にアクセスすることができる。使い方は [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) と同じです。
-
-OpenAI API が使用されている場所では、API Base に One API のデプロイアドレスを設定することを忘れないでください(例: `https://openai.justsong.cn`)。API Key は One API で生成されたトークンでなければなりません。
-
-具体的な API Base のフォーマットは、使用しているクライアントに依存することに注意してください。
-
-```mermaid
-graph LR
-    A(ユーザ)
-    A --->|リクエスト| B(One API)
-    B -->|中継リクエスト| C(OpenAI)
-    B -->|中継リクエスト| D(Azure)
-    B -->|中継リクエスト| E(その他のダウンストリームチャンネル)
-```
-
-現在のリクエストにどのチャネルを使うかを指定するには、トークンの後に チャネル ID を追加します: 例えば、`Authorization: Bearer ONE_API_KEY-CHANNEL_ID` のようにします。
-チャンネル ID を指定するためには、トークンは管理者によって作成される必要があることに注意してください。
-
-もしチャネル ID が指定されない場合、ロードバランシングによってリクエストが複数のチャネルに振り分けられます。
-
-### 環境変数
-1. `REDIS_CONN_STRING`: 設定すると、リクエストレート制限のためのストレージとして、メモリの代わりに Redis が使われる。
-    + 例: `REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
-2. `SESSION_SECRET`: 設定すると、固定セッションキーが使用され、システムの再起動後もログインユーザーのクッキーが有効であることが保証されます。
-    + 例: `SESSION_SECRET=random_string`
-3. `SQL_DSN`: 設定すると、SQLite の代わりに指定したデータベースが使用されます。MySQL バージョン 8.0 を使用してください。
-    + 例: `SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`
-4. `FRONTEND_BASE_URL`: 設定されると、バックエンドアドレスではなく、指定されたフロントエンドアドレスが使われる。
-    + 例: `FRONTEND_BASE_URL=https://openai.justsong.cn`
-5. `SYNC_FREQUENCY`: 設定された場合、システムは定期的にデータベースからコンフィグを秒単位で同期する。設定されていない場合、同期は行われません。
-    + 例: `SYNC_FREQUENCY=60`
-6. `NODE_TYPE`: 設定すると、ノードのタイプを指定する。有効な値は `master` と `slave` である。設定されていない場合、デフォルトは `master`。
-    + 例: `NODE_TYPE=slave`
-7. `CHANNEL_UPDATE_FREQUENCY`: 設定すると、チャンネル残高を分単位で定期的に更新する。設定されていない場合、更新は行われません。
-    + 例: `CHANNEL_UPDATE_FREQUENCY=1440`
-8. `CHANNEL_TEST_FREQUENCY`: 設定すると、チャンネルを定期的にテストする。設定されていない場合、テストは行われません。
-    + 例: `CHANNEL_TEST_FREQUENCY=1440`
-9. `POLLING_INTERVAL`: チャネル残高の更新とチャネルの可用性をテストするときのリクエスト間の時間間隔 (秒)。デフォルトは間隔なし。
-    + 例: `POLLING_INTERVAL=5`
-
-### コマンドラインパラメータ
-1. `--port <port_number>`: サーバがリッスンするポート番号を指定。デフォルトは `3000` です。
-    + 例: `--port 3000`
-2. `--log-dir <log_dir>`: ログディレクトリを指定。設定しない場合、ログは保存されません。
-    + 例: `--log-dir ./logs`
-3. `--version`: システムのバージョン番号を表示して終了する。
-4. `--help`: コマンドの使用法ヘルプとパラメータの説明を表示。
-
-## スクリーンショット
-![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)
-![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)
-
-## FAQ
-1. ノルマとは何か?どのように計算されますか?One API にはノルマ計算の問題はありますか?
-    + ノルマ = グループ倍率 * モデル倍率 * (プロンプトトークンの数 + 完了トークンの数 * 完了倍率)
-    + 完了倍率は、公式の定義と一致するように、GPT3.5 では 1.33、GPT4 では 2 に固定されています。
-    + ストリームモードでない場合、公式 API は消費したトークンの総数を返す。ただし、プロンプトとコンプリートの消費倍率は異なるので注意してください。
-2. アカウント残高は十分なのに、"insufficient quota" と表示されるのはなぜですか?
-    + トークンのクォータが十分かどうかご確認ください。トークンクォータはアカウント残高とは別のものです。
-    + トークンクォータは最大使用量を設定するためのもので、ユーザーが自由に設定できます。
-3. チャンネルを使おうとすると "No available channels" と表示されます。どうすればいいですか?
-    + ユーザーとチャンネルグループの設定を確認してください。
-    + チャンネルモデルの設定も確認してください。
-4. チャンネルテストがエラーを報告する: "invalid character '<' looking for beginning of value"
-    + このエラーは、返された値が有効な JSON ではなく、HTML ページである場合に発生する。
-    + ほとんどの場合、デプロイサイトのIPかプロキシのノードが CloudFlare によってブロックされています。
-5. ChatGPT Next Web でエラーが発生しました: "Failed to fetch"
-    + デプロイ時に `BASE_URL` を設定しないでください。
-    + インターフェイスアドレスと API Key が正しいか再確認してください。
-
-## 関連プロジェクト
-[FastGPT](https://github.com/labring/FastGPT): LLM に基づく知識質問応答システム
-
-## 注
-本プロジェクトはオープンソースプロジェクトです。OpenAI の[利用規約](https://openai.com/policies/terms-of-use)および**適用される法令**を遵守してご利用ください。違法な目的での利用はご遠慮ください。
-
-このプロジェクトは MIT ライセンスで公開されています。これに基づき、ページの最下部に帰属表示と本プロジェクトへのリンクを含める必要があります。
-
-このプロジェクトを基にした派生プロジェクトについても同様です。
-
-帰属表示を含めたくない場合は、事前に許可を得なければなりません。
-
-MIT ライセンスによると、このプロジェクトを利用するリスクと責任は利用者が負うべきであり、このオープンソースプロジェクトの開発者は責任を負いません。

+ 1 - 431
README.md

@@ -1,433 +1,3 @@
-<p align="right">
-   <strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.ja.md">日本語</a>
-</p>
 
+# Neko API
 
-<p align="center">
-  <a href="https://github.com/songquanpeng/one-api"><img src="https://raw.githubusercontent.com/songquanpeng/one-api/main/web/public/logo.png" width="150" height="150" alt="one-api logo"></a>
-</p>
-
-<div align="center">
-
-# One API
-
-_✨ 通过标准的 OpenAI API 格式访问所有的大模型,开箱即用 ✨_
-
-</div>
-
-<p align="center">
-  <a href="https://raw.githubusercontent.com/songquanpeng/one-api/main/LICENSE">
-    <img src="https://img.shields.io/github/license/songquanpeng/one-api?color=brightgreen" alt="license">
-  </a>
-  <a href="https://github.com/songquanpeng/one-api/releases/latest">
-    <img src="https://img.shields.io/github/v/release/songquanpeng/one-api?color=brightgreen&include_prereleases" alt="release">
-  </a>
-  <a href="https://hub.docker.com/repository/docker/justsong/one-api">
-    <img src="https://img.shields.io/docker/pulls/justsong/one-api?color=brightgreen" alt="docker pull">
-  </a>
-  <a href="https://github.com/songquanpeng/one-api/releases/latest">
-    <img src="https://img.shields.io/github/downloads/songquanpeng/one-api/total?color=brightgreen&include_prereleases" alt="release">
-  </a>
-  <a href="https://goreportcard.com/report/github.com/songquanpeng/one-api">
-    <img src="https://goreportcard.com/badge/github.com/songquanpeng/one-api" alt="GoReportCard">
-  </a>
-</p>
-
-<p align="center">
-  <a href="https://github.com/songquanpeng/one-api#部署">部署教程</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api#使用方法">使用方法</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api/issues">意见反馈</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api#截图展示">截图展示</a>
-  ·
-  <a href="https://openai.justsong.cn/">在线演示</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api#常见问题">常见问题</a>
-  ·
-  <a href="https://github.com/songquanpeng/one-api#相关项目">相关项目</a>
-  ·
-  <a href="https://iamazing.cn/page/reward">赞赏支持</a>
-</p>
-
-> **Note**
-> 本项目为开源项目,使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
-> 
-> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
-
-> **Warning**
-> 使用 Docker 拉取的最新镜像可能是 `alpha` 版本,如果追求稳定性请手动指定版本。
-
-> **Note**
-> 此分叉最新版Docker镜像 calciumion/one-api-midjourney:latest
-
-## 此分叉版本的主要变更
-1. 添加[Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)接口的支持:
-    + [x] /mj/submit/imagine
-    + [x] /mj/submit/change
-    + [x] /mj/image/{id} (通过此接口获取图片,**请必须在系统设置中填写服务器地址!!**)
-    + [x] /mj/task/{id}/fetch (此接口返回的图片地址为经过One API转发的地址)
-   + 使用方法:渠道类型选择自定义渠道
-   + Base URL填写Midjourney-Proxy的地址(例如 http://localhost:8080 )
-   + 模型填入自定义模型midjourney
-   + 密钥填写Midjourney-Proxy的mj-api-secret(没有的话可以随便填,但是不推荐)。
-2. 支持在线充值功能,可在系统设置中设置,当前支持的支付接口:
-    + [x] 易支付
-3. 支持用key查询使用额度:
-    + 配合项目[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)可实现用key查询使用情况,方便二次分销
-4. 渠道显示已使用额度,支持指定组织访问
-5. 分页支持选择每页显示数量
-
-
-## 功能
-1. 支持多种大模型:
-   + [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))
-   + [x] [Anthropic Claude 系列模型](https://anthropic.com)
-   + [x] [Google PaLM2 系列模型](https://developers.generativeai.google)
-   + [x] [百度文心一言系列模型](https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html)
-   + [x] [阿里通义千问系列模型](https://help.aliyun.com/document_detail/2400395.html)
-   + [x] [讯飞星火认知大模型](https://www.xfyun.cn/doc/spark/Web.html)
-   + [x] [智谱 ChatGLM 系列模型](https://bigmodel.cn)
-   + [x] [360 智脑](https://ai.360.cn)
-   + [x] [腾讯混元大模型](https://cloud.tencent.com/document/product/1729)
-2. 支持配置镜像以及众多第三方代理服务:
-   + [x] [OpenAI-SB](https://openai-sb.com)
-   + [x] [CloseAI](https://console.closeai-asia.com/r/2412)
-   + [x] [API2D](https://api2d.com/r/197971)
-   + [x] [OhMyGPT](https://aigptx.top?aff=uFpUl2Kf)
-   + [x] [AI Proxy](https://aiproxy.io/?i=OneAPI) (邀请码:`OneAPI`)
-   + [x] 自定义渠道:例如各种未收录的第三方代理服务
-3. 支持通过**负载均衡**的方式访问多个渠道。
-4. 支持 **stream 模式**,可以通过流式传输实现打字机效果。
-5. 支持**多机部署**,[详见此处](#多机部署)。
-6. 支持**令牌管理**,设置令牌的过期时间和额度。
-7. 支持**兑换码管理**,支持批量生成和导出兑换码,可使用兑换码为账户进行充值。
-8. 支持**通道管理**,批量创建通道。
-9. 支持**用户分组**以及**渠道分组**,支持为不同分组设置不同的倍率。
-10. 支持渠道**设置模型列表**。
-11. 支持**查看额度明细**。
-12. 支持**用户邀请奖励**。
-13. 支持以美元为单位显示额度。
-14. 支持发布公告,设置充值链接,设置新用户初始额度。
-15. 支持模型映射,重定向用户的请求模型。
-16. 支持失败自动重试。
-17. 支持绘图接口。
-18. 支持 [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/providers/openai/),渠道设置的代理部分填写 `https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/openai` 即可。
-19. 支持丰富的**自定义**设置,
-    1. 支持自定义系统名称,logo 以及页脚。
-    2. 支持自定义首页和关于页面,可以选择使用 HTML & Markdown 代码进行自定义,或者使用一个单独的网页通过 iframe 嵌入。
-20. 支持通过系统访问令牌访问管理 API。
-21. 支持 Cloudflare Turnstile 用户校验。
-22. 支持用户管理,支持**多种用户登录注册方式**:
-    + 邮箱登录注册(支持注册邮箱白名单)以及通过邮箱进行密码重置。
-    + [GitHub 开放授权](https://github.com/settings/applications/new)。
-    + 微信公众号授权(需要额外部署 [WeChat Server](https://github.com/songquanpeng/wechat-server))。
-
-## 部署
-### 基于 Docker 进行部署
-```shell
-# 使用 SQLite 的部署命令:
-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
-# 使用 MySQL 的部署命令,在上面的基础上添加 `-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi"`,请自行修改数据库连接参数,不清楚如何修改请参见下面环境变量一节。
-# 例如:
-docker 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
-```
-
-其中,`-p 3000:3000` 中的第一个 `3000` 是宿主机的端口,可以根据需要进行修改。
-
-数据和日志将会保存在宿主机的 `/home/ubuntu/data/one-api` 目录,请确保该目录存在且具有写入权限,或者更改为合适的目录。
-
-如果启动失败,请添加 `--privileged=true`,具体参考 https://github.com/songquanpeng/one-api/issues/482 。
-
-如果上面的镜像无法拉取,可以尝试使用 GitHub 的 Docker 镜像,将上面的 `justsong/one-api` 替换为 `ghcr.io/songquanpeng/one-api` 即可。
-
-如果你的并发量较大,**务必**设置 `SQL_DSN`,详见下面[环境变量](#环境变量)一节。
-
-更新命令:`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR`
-
-Nginx 的参考配置:
-```
-server{
-   server_name openai.justsong.cn;  # 请根据实际情况修改你的域名
-   
-   location / {
-          client_max_body_size  64m;
-          proxy_http_version 1.1;
-          proxy_pass http://localhost:3000;  # 请根据实际情况修改你的端口
-          proxy_set_header Host $host;
-          proxy_set_header X-Forwarded-For $remote_addr;
-          proxy_cache_bypass $http_upgrade;
-          proxy_set_header Accept-Encoding gzip;
-          proxy_read_timeout 300s;  # GPT-4 需要较长的超时时间,请自行调整
-   }
-}
-```
-
-之后使用 Let's Encrypt 的 certbot 配置 HTTPS:
-```bash
-# Ubuntu 安装 certbot:
-sudo snap install --classic certbot
-sudo ln -s /snap/bin/certbot /usr/bin/certbot
-# 生成证书 & 修改 Nginx 配置
-sudo certbot --nginx
-# 根据指示进行操作
-# 重启 Nginx
-sudo service nginx restart
-```
-
-初始账号用户名为 `root`,密码为 `123456`。
-
-### 手动部署
-1. 从 [GitHub Releases](https://github.com/songquanpeng/one-api/releases/latest) 下载可执行文件或者从源码编译:
-   ```shell
-   git clone https://github.com/songquanpeng/one-api.git
-   
-   # 构建前端
-   cd one-api/web
-   npm install
-   npm run build
-   
-   # 构建后端
-   cd ..
-   go mod download
-   go build -ldflags "-s -w" -o one-api
-   ````
-2. 运行:
-   ```shell
-   chmod u+x one-api
-   ./one-api --port 3000 --log-dir ./logs
-   ```
-3. 访问 [http://localhost:3000/](http://localhost:3000/) 并登录。初始账号用户名为 `root`,密码为 `123456`。
-
-更加详细的部署教程[参见此处](https://iamazing.cn/page/how-to-deploy-a-website)。
-
-### 多机部署
-1. 所有服务器 `SESSION_SECRET` 设置一样的值。
-2. 必须设置 `SQL_DSN`,使用 MySQL 数据库而非 SQLite,所有服务器连接同一个数据库。
-3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`,不设置则默认为主服务器。
-4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis,无论主从。
-5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。
-6. 从服务器上**分别**装好 Redis,设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。
-7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis,并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。
-
-环境变量的具体使用方法详见[此处](#环境变量)。
-
-### 宝塔部署教程
-
-详见 [#175](https://github.com/songquanpeng/one-api/issues/175)。
-
-如果部署后访问出现空白页面,详见 [#97](https://github.com/songquanpeng/one-api/issues/97)。
-
-### 部署第三方服务配合 One API 使用
-> 欢迎 PR 添加更多示例。
-
-#### ChatGPT Next Web
-项目主页:https://github.com/Yidadaa/ChatGPT-Next-Web
-
-```bash
-docker run --name chat-next-web -d -p 3001:3000 yidadaa/chatgpt-next-web
-```
-
-注意修改端口号,之后在页面上设置接口地址(例如:https://openai.justsong.cn/ )和 API Key 即可。
-
-#### ChatGPT Web
-项目主页:https://github.com/Chanzhaoyu/chatgpt-web
-
-```bash
-docker 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
-```
-
-注意修改端口号、`OPENAI_API_BASE_URL` 和 `OPENAI_API_KEY`。
-
-#### QChatGPT - QQ机器人
-项目主页:https://github.com/RockChinQ/QChatGPT
-
-根据文档完成部署后,在`config.py`设置配置项`openai_config`的`reverse_proxy`为 One API 后端地址,设置`api_key`为 One API 生成的key,并在配置项`completion_api_params`的`model`参数设置为 One API 支持的模型名称。
-
-可安装 [Switcher 插件](https://github.com/RockChinQ/Switcher)在运行时切换所使用的模型。
-
-### 部署到第三方平台
-<details>
-<summary><strong>部署到 Sealos </strong></summary>
-<div>
-
-> Sealos 的服务器在国外,不需要额外处理网络问题,支持高并发 & 动态伸缩。
-
-点击以下按钮一键部署(部署后访问出现 404 请等待 3~5 分钟):
-
-[![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)
-
-</div>
-</details>
-
-<details>
-<summary><strong>部署到 Zeabur</strong></summary>
-<div>
-
-> Zeabur 的服务器在国外,自动解决了网络的问题,同时免费的额度也足够个人使用。
-
-1. 首先 fork 一份代码。
-2. 进入 [Zeabur](https://zeabur.com?referralCode=songquanpeng),登录,进入控制台。
-3. 新建一个 Project,在 Service -> Add Service 选择 Marketplace,选择 MySQL,并记下连接参数(用户名、密码、地址、端口)。
-4. 复制链接参数,运行 ```create database `one-api` ``` 创建数据库。
-5. 然后在 Service -> Add Service,选择 Git(第一次使用需要先授权),选择你 fork 的仓库。
-6. Deploy 会自动开始,先取消。进入下方 Variable,添加一个 `PORT`,值为 `3000`,再添加一个 `SQL_DSN`,值为 `<username>:<password>@tcp(<addr>:<port>)/one-api` ,然后保存。 注意如果不填写 `SQL_DSN`,数据将无法持久化,重新部署后数据会丢失。
-7. 选择 Redeploy。
-8. 进入下方 Domains,选择一个合适的域名前缀,如 "my-one-api",最终域名为 "my-one-api.zeabur.app",也可以 CNAME 自己的域名。
-9. 等待部署完成,点击生成的域名进入 One API。
-
-</div>
-</details>
-
-<details>
-<summary><strong>部署到 Render</strong></summary>
-<div>
-
-> Render 提供免费额度,绑卡后可以进一步提升额度
-
-Render 可以直接部署 docker 镜像,不需要 fork 仓库:https://dashboard.render.com
-
-</div>
-</details>
-
-## 配置
-系统本身开箱即用。
-
-你可以通过设置环境变量或者命令行参数进行配置。
-
-等到系统启动后,使用 `root` 用户登录系统并做进一步的配置。
-
-**Note**:如果你不知道某个配置项的含义,可以临时删掉值以看到进一步的提示文字。
-
-## 使用方法
-在`渠道`页面中添加你的 API Key,之后在`令牌`页面中新增访问令牌。
-
-之后就可以使用你的令牌访问 One API 了,使用方式与 [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) 一致。
-
-你需要在各种用到 OpenAI API 的地方设置 API Base 为你的 One API 的部署地址,例如:`https://openai.justsong.cn`,API Key 则为你在 One API 中生成的令牌。
-
-注意,具体的 API Base 的格式取决于你所使用的客户端。
-
-例如对于 OpenAI 的官方库:
-```bash
-OPENAI_API_KEY="sk-xxxxxx"
-OPENAI_API_BASE="https://<HOST>:<PORT>/v1" 
-```
-
-```mermaid
-graph LR
-    A(用户)
-    A --->|使用 One API 分发的 key 进行请求| B(One API)
-    B -->|中继请求| C(OpenAI)
-    B -->|中继请求| D(Azure)
-    B -->|中继请求| E(其他 OpenAI API 格式下游渠道)
-    B -->|中继并修改请求体和返回体| F(非 OpenAI API 格式下游渠道)
-```
-
-可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。
-注意,需要是管理员用户创建的令牌才能指定渠道 ID。
-
-不加的话将会使用负载均衡的方式使用多个渠道。
-
-### 环境变量
-1. `REDIS_CONN_STRING`:设置之后将使用 Redis 作为缓存使用。
-   + 例子:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
-   + 如果数据库访问延迟很低,没有必要启用 Redis,启用后反而会出现数据滞后的问题。
-2. `SESSION_SECRET`:设置之后将使用固定的会话密钥,这样系统重新启动后已登录用户的 cookie 将依旧有效。
-   + 例子:`SESSION_SECRET=random_string`
-3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite,请使用 MySQL 或 PostgreSQL。
-   + 例子:
-     + MySQL:`SQL_DSN=root:123456@tcp(localhost:3306)/oneapi`
-     + PostgreSQL:`SQL_DSN=postgres://postgres:123456@localhost:5432/oneapi`(适配中,欢迎反馈)
-   + 注意需要提前建立数据库 `oneapi`,无需手动建表,程序将自动建表。
-   + 如果使用本地数据库:部署命令可添加 `--network="host"` 以使得容器内的程序可以访问到宿主机上的 MySQL。
-   + 如果使用云数据库:如果云服务器需要验证身份,需要在连接参数中添加 `?tls=skip-verify`。
-   + 请根据你的数据库配置修改下列参数(或者保持默认值):
-     + `SQL_MAX_IDLE_CONNS`:最大空闲连接数,默认为 `100`。
-     + `SQL_MAX_OPEN_CONNS`:最大打开连接数,默认为 `1000`。
-       + 如果报错 `Error 1040: Too many connections`,请适当减小该值。
-     + `SQL_CONN_MAX_LIFETIME`:连接的最大生命周期,默认为 `60`,单位分钟。
-4. `FRONTEND_BASE_URL`:设置之后将重定向页面请求到指定的地址,仅限从服务器设置。
-   + 例子:`FRONTEND_BASE_URL=https://openai.justsong.cn`
-5. `MEMORY_CACHE_ENABLED`:启用内存缓存,会导致用户额度的更新存在一定的延迟,可选值为 `true` 和 `false`,未设置则默认为 `false`。
-   + 例子:`MEMORY_CACHE_ENABLED=true`
-6. `SYNC_FREQUENCY`:在启用缓存的情况下与数据库同步配置的频率,单位为秒,默认为 `600` 秒。
-   + 例子:`SYNC_FREQUENCY=60`
-7. `NODE_TYPE`:设置之后将指定节点类型,可选值为 `master` 和 `slave`,未设置则默认为 `master`。
-   + 例子:`NODE_TYPE=slave`
-8. `CHANNEL_UPDATE_FREQUENCY`:设置之后将定期更新渠道余额,单位为分钟,未设置则不进行更新。
-   + 例子:`CHANNEL_UPDATE_FREQUENCY=1440`
-9. `CHANNEL_TEST_FREQUENCY`:设置之后将定期检查渠道,单位为分钟,未设置则不进行检查。
-   + 例子:`CHANNEL_TEST_FREQUENCY=1440`
-10. `POLLING_INTERVAL`:批量更新渠道余额以及测试可用性时的请求间隔,单位为秒,默认无间隔。
-    + 例子:`POLLING_INTERVAL=5`
-11. `BATCH_UPDATE_ENABLED`:启用数据库批量更新聚合,会导致用户额度的更新存在一定的延迟可选值为 `true` 和 `false`,未设置则默认为 `false`。
-    + 例子:`BATCH_UPDATE_ENABLED=true`
-    + 如果你遇到了数据库连接数过多的问题,可以尝试启用该选项。
-12. `BATCH_UPDATE_INTERVAL=5`:批量更新聚合的时间间隔,单位为秒,默认为 `5`。
-    + 例子:`BATCH_UPDATE_INTERVAL=5`
-13. 请求频率限制:
-    + `GLOBAL_API_RATE_LIMIT`:全局 API 速率限制(除中继请求外),单 ip 三分钟内的最大请求数,默认为 `180`。
-    + `GLOBAL_WEB_RATE_LIMIT`:全局 Web 速率限制,单 ip 三分钟内的最大请求数,默认为 `60`。
-14. 编码器缓存设置:
-    + `TIKTOKEN_CACHE_DIR`:默认程序启动时会联网下载一些通用的词元的编码,如:`gpt-3.5-turbo`,在一些网络环境不稳定,或者离线情况,可能会导致启动有问题,可以配置此目录缓存数据,可迁移到离线环境。
-    + `DATA_GYM_CACHE_DIR`:目前该配置作用与 `TIKTOKEN_CACHE_DIR` 一致,但是优先级没有它高。
-15. `RELAY_TIMEOUT`:中继超时设置,单位为秒,默认不设置超时时间。
-
-### 命令行参数
-1. `--port <port_number>`: 指定服务器监听的端口号,默认为 `3000`。
-   + 例子:`--port 3000`
-2. `--log-dir <log_dir>`: 指定日志文件夹,如果没有设置,默认保存至工作目录的 `logs` 文件夹下。
-   + 例子:`--log-dir ./logs`
-3. `--version`: 打印系统版本号并退出。
-4. `--help`: 查看命令的使用帮助和参数说明。
-
-## 演示
-### 在线演示
-注意,该演示站不提供对外服务:
-https://openai.justsong.cn
-
-### 截图展示
-![channel](https://user-images.githubusercontent.com/39998050/233837954-ae6683aa-5c4f-429f-a949-6645a83c9490.png)
-![token](https://user-images.githubusercontent.com/39998050/233837971-dab488b7-6d96-43af-b640-a168e8d1c9bf.png)
-
-## 常见问题
-1. 额度是什么?怎么计算的?One API 的额度计算有问题?
-   + 额度 = 分组倍率 * 模型倍率 * (提示 token 数 + 补全 token 数 * 补全倍率)
-   + 其中补全倍率对于 GPT3.5 固定为 1.33,GPT4 为 2,与官方保持一致。
-   + 如果是非流模式,官方接口会返回消耗的总 token,但是你要注意提示和补全的消耗倍率不一样。
-   + 注意,One API 的默认倍率就是官方倍率,是已经调整过的。
-2. 账户额度足够为什么提示额度不足?
-   + 请检查你的令牌额度是否足够,这个和账户额度是分开的。
-   + 令牌额度仅供用户设置最大使用量,用户可自由设置。
-3. 提示无可用渠道?
-   + 请检查的用户分组和渠道分组设置。
-   + 以及渠道的模型设置。
-4. 渠道测试报错:`invalid character '<' looking for beginning of value`
-   + 这是因为返回值不是合法的 JSON,而是一个 HTML 页面。
-   + 大概率是你的部署站的 IP 或代理的节点被 CloudFlare 封禁了。
-5. ChatGPT Next Web 报错:`Failed to fetch`
-   + 部署的时候不要设置 `BASE_URL`。
-   + 检查你的接口地址和 API Key 有没有填对。
-   + 检查是否启用了 HTTPS,浏览器会拦截 HTTPS 域名下的 HTTP 请求。
-6. 报错:`当前分组负载已饱和,请稍后再试`
-   + 上游通道 429 了。
-7. 升级之后我的数据会丢失吗?
-   + 如果使用 MySQL,不会。
-   + 如果使用 SQLite,需要按照我所给的部署命令挂载 volume 持久化 one-api.db 数据库文件,否则容器重启后数据会丢失。
-8. 升级之前数据库需要做变更吗?
-   + 一般情况下不需要,系统将在初始化的时候自动调整。
-   + 如果需要的话,我会在更新日志中说明,并给出脚本。
-
-## 相关项目
-* [FastGPT](https://github.com/labring/FastGPT): 基于 LLM 大语言模型的知识库问答系统
-* [ChatGPT Next Web](https://github.com/Yidadaa/ChatGPT-Next-Web):  一键拥有你自己的跨平台 ChatGPT 应用
-
-## 注意
-
-本项目使用 MIT 协议进行开源,**在此基础上**,必须在页面底部保留署名以及指向本项目的链接。如果不想保留署名,必须首先获得授权。
-
-同样适用于基于本项目的二开项目。
-
-依据 MIT 协议,使用者需自行承担使用本项目的风险与责任,本开源项目开发者与此无关。

+ 4 - 4
controller/relay-mj.go

@@ -54,7 +54,7 @@ func RelayMidjourneyImage(c *gin.Context) {
 		return
 	}
 	c.Header("Content-Type", "image/jpeg")
-	//c.Header("Content-Length", string(rune(len(data))))
+	//c.HeaderBar("Content-Length", string(rune(len(data))))
 	c.Data(http.StatusOK, "image/jpeg", data)
 }
 
@@ -275,13 +275,13 @@ func relayMidjourneySubmit(c *gin.Context, relayMode int) *MidjourneyResponse {
 			Description: "create_request_failed",
 		}
 	}
-	//req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
+	//req.HeaderBar.Set("Authorization", c.Request.HeaderBar.Get("Authorization"))
 
 	req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
 	req.Header.Set("Accept", c.Request.Header.Get("Accept"))
 	//mjToken := ""
-	//if c.Request.Header.Get("Authorization") != "" {
-	//	mjToken = strings.Split(c.Request.Header.Get("Authorization"), " ")[1]
+	//if c.Request.HeaderBar.Get("Authorization") != "" {
+	//	mjToken = strings.Split(c.Request.HeaderBar.Get("Authorization"), " ")[1]
 	//}
 	req.Header.Set("mj-api-secret", strings.Split(c.Request.Header.Get("Authorization"), " ")[1])
 	// print request header

+ 1 - 1
controller/relay-text.go

@@ -381,7 +381,7 @@ func relayTextHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
 		if isStream && c.Request.Header.Get("Accept") == "" {
 			req.Header.Set("Accept", "text/event-stream")
 		}
-		//req.Header.Set("Connection", c.Request.Header.Get("Connection"))
+		//req.HeaderBar.Set("Connection", c.Request.HeaderBar.Get("Connection"))
 		resp, err = httpClient.Do(req)
 		if err != nil {
 			return errorWrapper(err, "do_request_failed", http.StatusInternalServerError)

+ 4 - 2
web/package.json

@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@douyinfe/semi-ui": "^2.45.2",
     "axios": "^0.27.2",
     "history": "^5.3.0",
     "marked": "^4.1.1",
@@ -14,7 +15,8 @@
     "react-toastify": "^9.0.8",
     "react-turnstile": "^1.0.5",
     "semantic-ui-css": "^2.5.0",
-    "semantic-ui-react": "^2.1.3"
+    "semantic-ui-react": "^2.1.3",
+    "usehooks-ts": "^2.9.1"
   },
   "scripts": {
     "start": "react-scripts start",
@@ -47,5 +49,5 @@
     "singleQuote": true,
     "jsxSingleQuote": true
   },
-  "proxy": "http://localhost:3000"
+  "proxy": "https://nekoapi.com"
 }

+ 204 - 201
web/src/App.js

@@ -24,6 +24,7 @@ import EditRedemption from './pages/Redemption/EditRedemption';
 import TopUp from './pages/TopUp';
 import Log from './pages/Log';
 import Chat from './pages/Chat';
+import {Layout} from "@douyinfe/semi-ui";
 
 const Home = lazy(() => import('./pages/Home'));
 const About = lazy(() => import('./pages/About'));
@@ -86,207 +87,209 @@ function App() {
   }, []);
 
   return (
-    <Routes>
-      <Route
-        path='/'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <Home />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/channel'
-        element={
-          <PrivateRoute>
-            <Channel />
-          </PrivateRoute>
-        }
-      />
-      <Route
-        path='/channel/edit/:id'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditChannel />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/channel/add'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditChannel />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/token'
-        element={
-          <PrivateRoute>
-            <Token />
-          </PrivateRoute>
-        }
-      />
-      <Route
-        path='/token/edit/:id'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditToken />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/token/add'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditToken />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/redemption'
-        element={
-          <PrivateRoute>
-            <Redemption />
-          </PrivateRoute>
-        }
-      />
-      <Route
-        path='/redemption/edit/:id'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditRedemption />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/redemption/add'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditRedemption />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/user'
-        element={
-          <PrivateRoute>
-            <User />
-          </PrivateRoute>
-        }
-      />
-      <Route
-        path='/user/edit/:id'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditUser />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/user/edit'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <EditUser />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/user/add'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <AddUser />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/user/reset'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <PasswordResetConfirm />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/login'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <LoginForm />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/register'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <RegisterForm />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/reset'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <PasswordResetForm />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/oauth/github'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <GitHubOAuth />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/setting'
-        element={
-          <PrivateRoute>
-            <Suspense fallback={<Loading></Loading>}>
-              <Setting />
-            </Suspense>
-          </PrivateRoute>
-        }
-      />
-      <Route
-        path='/topup'
-        element={
-        <PrivateRoute>
-          <Suspense fallback={<Loading></Loading>}>
-            <TopUp />
-          </Suspense>
-        </PrivateRoute>
-        }
-      />
-      <Route
-        path='/log'
-        element={
-          <PrivateRoute>
-            <Log />
-          </PrivateRoute>
-        }
-      />
-      <Route
-        path='/about'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <About />
-          </Suspense>
-        }
-      />
-      <Route
-        path='/chat'
-        element={
-          <Suspense fallback={<Loading></Loading>}>
-            <Chat />
-          </Suspense>
-        }
-      />
-      <Route path='*' element={
-          <NotFound />
-      } />
-    </Routes>
+    <Layout>
+        <Routes>
+            <Route
+                path='/'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <Home />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/channel'
+                element={
+                    <PrivateRoute>
+                        <Channel />
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/channel/edit/:id'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditChannel />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/channel/add'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditChannel />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/token'
+                element={
+                    <PrivateRoute>
+                        <Token />
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/token/edit/:id'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditToken />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/token/add'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditToken />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/redemption'
+                element={
+                    <PrivateRoute>
+                        <Redemption />
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/redemption/edit/:id'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditRedemption />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/redemption/add'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditRedemption />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/user'
+                element={
+                    <PrivateRoute>
+                        <User />
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/user/edit/:id'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditUser />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/user/edit'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <EditUser />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/user/add'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <AddUser />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/user/reset'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <PasswordResetConfirm />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/login'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <LoginForm />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/register'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <RegisterForm />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/reset'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <PasswordResetForm />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/oauth/github'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <GitHubOAuth />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/setting'
+                element={
+                    <PrivateRoute>
+                        <Suspense fallback={<Loading></Loading>}>
+                            <Setting />
+                        </Suspense>
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/topup'
+                element={
+                    <PrivateRoute>
+                        <Suspense fallback={<Loading></Loading>}>
+                            <TopUp />
+                        </Suspense>
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/log'
+                element={
+                    <PrivateRoute>
+                        <Log />
+                    </PrivateRoute>
+                }
+            />
+            <Route
+                path='/about'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <About />
+                    </Suspense>
+                }
+            />
+            <Route
+                path='/chat'
+                element={
+                    <Suspense fallback={<Loading></Loading>}>
+                        <Chat />
+                    </Suspense>
+                }
+            />
+            <Route path='*' element={
+                <NotFound />
+            } />
+        </Routes>
+    </Layout>
   );
 }
 

+ 2 - 2
web/src/components/Footer.js

@@ -44,8 +44,8 @@ const Footer = () => {
               {systemName} {process.env.REACT_APP_VERSION}{' '}
             </a>
             由{' '}
-            <a href='https://github.com/songquanpeng' target='_blank'>
-              JustSong
+            <a href='https://github.com/Calcium-Ion' target='_blank'>
+              Calcium-Ion
             </a>{' '}
             构建,源代码遵循{' '}
             <a href='https://opensource.org/licenses/mit-license.php'>

+ 0 - 228
web/src/components/Header.js

@@ -1,228 +0,0 @@
-import React, { useContext, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { UserContext } from '../context/User';
-
-import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
-import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
-import '../index.css';
-
-// Header Buttons
-let headerButtons = [
-  {
-    name: '首页',
-    to: '/',
-    icon: 'home'
-  },
-  {
-    name: '渠道',
-    to: '/channel',
-    icon: 'sitemap',
-    admin: true
-  },
-  {
-    name: '令牌',
-    to: '/token',
-    icon: 'key'
-  },
-  {
-    name: '兑换',
-    to: '/redemption',
-    icon: 'dollar sign',
-    admin: true
-  },
-  {
-    name: '充值',
-    to: '/topup',
-    icon: 'cart'
-  },
-  {
-    name: '用户',
-    to: '/user',
-    icon: 'user',
-    admin: true
-  },
-  {
-    name: '日志',
-    to: '/log',
-    icon: 'book'
-  },
-  {
-    name: 'Midjourney',
-    to: '/midjourney',
-    icon: 'images outline'
-  },
-  {
-    name: '设置',
-    to: '/setting',
-    icon: 'setting'
-  },
-  {
-    name: '关于',
-    to: '/about',
-    icon: 'info circle'
-  }
-];
-
-if (localStorage.getItem('chat_link')) {
-  headerButtons.splice(1, 0, {
-    name: '聊天',
-    to: '/chat',
-    icon: 'comments'
-  });
-}
-
-const Header = () => {
-  const [userState, userDispatch] = useContext(UserContext);
-  let navigate = useNavigate();
-
-  const [showSidebar, setShowSidebar] = useState(false);
-  const systemName = getSystemName();
-  const logo = getLogo();
-
-  async function logout() {
-    setShowSidebar(false);
-    await API.get('/api/user/logout');
-    showSuccess('注销成功!');
-    userDispatch({ type: 'logout' });
-    localStorage.removeItem('user');
-    navigate('/login');
-  }
-
-  const toggleSidebar = () => {
-    setShowSidebar(!showSidebar);
-  };
-
-  const renderButtons = (isMobile) => {
-    return headerButtons.map((button) => {
-      if (button.admin && !isAdmin()) return <></>;
-      if (isMobile) {
-        return (
-          <Menu.Item
-            onClick={() => {
-              navigate(button.to);
-              setShowSidebar(false);
-            }}
-          >
-            {button.name}
-          </Menu.Item>
-        );
-      }
-      return (
-        <Menu.Item key={button.name} as={Link} to={button.to}>
-          <Icon name={button.icon} />
-          {button.name}
-        </Menu.Item>
-      );
-    });
-  };
-
-  if (isMobile()) {
-    return (
-      <>
-        <Menu
-          borderless
-          size='large'
-          style={
-            showSidebar
-              ? {
-                borderBottom: 'none',
-                marginBottom: '0',
-                borderTop: 'none',
-                height: '51px'
-              }
-              : { borderTop: 'none', height: '52px' }
-          }
-        >
-          <Container>
-            <Menu.Item as={Link} to='/'>
-              <img
-                src={logo}
-                alt='logo'
-                style={{ marginRight: '0.75em' }}
-              />
-              <div style={{ fontSize: '20px' }}>
-                <b>{systemName}</b>
-              </div>
-            </Menu.Item>
-            <Menu.Menu position='right'>
-              <Menu.Item onClick={toggleSidebar}>
-                <Icon name={showSidebar ? 'close' : 'sidebar'} />
-              </Menu.Item>
-            </Menu.Menu>
-          </Container>
-        </Menu>
-        {showSidebar ? (
-          <Segment style={{ marginTop: 0, borderTop: '0' }}>
-            <Menu secondary vertical style={{ width: '100%', margin: 0 }}>
-              {renderButtons(true)}
-              <Menu.Item>
-                {userState.user ? (
-                  <Button onClick={logout}>注销</Button>
-                ) : (
-                  <>
-                    <Button
-                      onClick={() => {
-                        setShowSidebar(false);
-                        navigate('/login');
-                      }}
-                    >
-                      登录
-                    </Button>
-                    <Button
-                      onClick={() => {
-                        setShowSidebar(false);
-                        navigate('/register');
-                      }}
-                    >
-                      注册
-                    </Button>
-                  </>
-                )}
-              </Menu.Item>
-            </Menu>
-          </Segment>
-        ) : (
-          <></>
-        )}
-      </>
-    );
-  }
-
-  return (
-    <>
-      <Menu borderless style={{ borderTop: 'none' }}>
-        <Container>
-          <Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
-            <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
-            <div style={{ fontSize: '20px' }}>
-              <b>{systemName}</b>
-            </div>
-          </Menu.Item>
-          {renderButtons(false)}
-          <Menu.Menu position='right'>
-            {userState.user ? (
-              <Dropdown
-                text={userState.user.username}
-                pointing
-                className='link item'
-              >
-                <Dropdown.Menu>
-                  <Dropdown.Item onClick={logout}>注销</Dropdown.Item>
-                </Dropdown.Menu>
-              </Dropdown>
-            ) : (
-              <Menu.Item
-                name='登录'
-                as={Link}
-                to='/login'
-                className='btn btn-link'
-              />
-            )}
-          </Menu.Menu>
-        </Container>
-      </Menu>
-    </>
-  );
-};
-
-export default Header;

+ 230 - 0
web/src/components/HeaderBar.js

@@ -0,0 +1,230 @@
+import React, {useContext, useState} from 'react';
+import {Link, useNavigate} from 'react-router-dom';
+import {UserContext} from '../context/User';
+
+import {Button, Container, Icon, Menu, Segment} from 'semantic-ui-react';
+import {API, getLogo, getSystemName, isAdmin, isMobile, showSuccess} from '../helpers';
+import '../index.css';
+
+import {
+    IconAt,
+    IconHistogram,
+    IconGift,
+    IconKey,
+    IconUser,
+    IconLayers,
+    IconHelpCircle,
+    IconCreditCard,
+    IconSemiLogo,
+    IconHome,
+    IconImage
+} from '@douyinfe/semi-icons';
+import {Nav, Avatar, Dropdown, Layout, Switch} from '@douyinfe/semi-ui';
+import {stringToColor} from "../helpers/render";
+
+// HeaderBar Buttons
+let headerButtons = [
+    {
+        text: '关于',
+        itemKey: 'about',
+        to: '/about',
+        icon: <IconHelpCircle/>
+    },
+];
+
+if (localStorage.getItem('chat_link')) {
+    headerButtons.splice(1, 0, {
+        name: '聊天',
+        to: '/chat',
+        icon: 'comments'
+    });
+}
+
+const HeaderBar = () => {
+    const [userState, userDispatch] = useContext(UserContext);
+    let navigate = useNavigate();
+
+    const [showSidebar, setShowSidebar] = useState(false);
+    const systemName = getSystemName();
+    const logo = getLogo();
+
+    async function logout() {
+        setShowSidebar(false);
+        await API.get('/api/user/logout');
+        showSuccess('注销成功!');
+        userDispatch({type: 'logout'});
+        localStorage.removeItem('user');
+        navigate('/login');
+    }
+
+    const toggleSidebar = () => {
+        setShowSidebar(!showSidebar);
+    };
+
+    const renderButtons = (isMobile) => {
+        return headerButtons.map((button) => {
+            if (button.admin && !isAdmin()) return <></>;
+            if (isMobile) {
+                return (
+                    <Menu.Item
+                        onClick={() => {
+                            navigate(button.to);
+                            setShowSidebar(false);
+                        }}
+                    >
+                        {button.name}
+                    </Menu.Item>
+                );
+            }
+            return (
+                <Menu.Item key={button.name} as={Link} to={button.to}>
+                    <Icon name={button.icon}/>
+                    {button.name}
+                </Menu.Item>
+            );
+        });
+    };
+
+    if (isMobile()) {
+        return (
+            <>
+                <Menu
+                    borderless
+                    size='large'
+                    style={
+                        showSidebar
+                            ? {
+                                borderBottom: 'none',
+                                marginBottom: '0',
+                                borderTop: 'none',
+                                height: '51px'
+                            }
+                            : {borderTop: 'none', height: '52px'}
+                    }
+                >
+                    <Container>
+                        <Menu.Item as={Link} to='/'>
+                            <img
+                                src={logo}
+                                alt='logo'
+                                style={{marginRight: '0.75em'}}
+                            />
+                            <div style={{fontSize: '20px'}}>
+                                <b>{systemName}</b>
+                            </div>
+                        </Menu.Item>
+                        <Menu.Menu position='right'>
+                            <Menu.Item onClick={toggleSidebar}>
+                                <Icon name={showSidebar ? 'close' : 'sidebar'}/>
+                            </Menu.Item>
+                        </Menu.Menu>
+                    </Container>
+                </Menu>
+
+                {showSidebar ? (
+                    <Segment style={{marginTop: 0, borderTop: '0'}}>
+                        <Menu secondary vertical style={{ width: '100%', margin: 0 }}>
+                          {renderButtons(true)}
+                          <Menu.Item>
+                            {userState.user ? (
+                              <Button onClick={logout}>注销</Button>
+                            ) : (
+                              <>
+                                <Button
+                                  onClick={() => {
+                                    setShowSidebar(false);
+                                    navigate('/login');
+                                  }}
+                                >
+                                  登录
+                                </Button>
+                                <Button
+                                  onClick={() => {
+                                    setShowSidebar(false);
+                                    navigate('/register');
+                                  }}
+                                >
+                                  注册
+                                </Button>
+                              </>
+                            )}
+                          </Menu.Item>
+                        </Menu>
+
+                    </Segment>
+                ) : (
+                    <></>
+                )}
+
+
+            </>
+        );
+    }
+    const switchMode = (model) => {
+        const body = document.body;
+        if (!model) {
+            body.removeAttribute('theme-mode');
+        } else {
+            body.setAttribute('theme-mode', 'dark');
+        }
+    };
+    return (
+        <>
+            <Layout>
+                <div style={{width: '100%'}}>
+                    <Nav
+                        mode={'horizontal'}
+                        // bodyStyle={{ height: 100 }}
+                        renderWrapper={({itemElement, isSubNav, isInSubNav, props}) => {
+                            const routerMap = {
+                                about: "/about",
+                            };
+                            return (
+                                <Link
+                                    style={{textDecoration: "none"}}
+                                    to={routerMap[props.itemKey]}
+                                >
+                                    {itemElement}
+                                </Link>
+                            );
+                        }}
+                        selectedKeys={[]}
+                        // items={headerButtons}
+                        onSelect={key => console.log(key)}
+                        footer={
+                            <>
+                                <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} />
+                                <Switch checkedText="🌞" size={'large'} uncheckedText="🌙" onChange={switchMode} />
+                                {userState.user ?
+                                    <>
+                                        <Dropdown
+                                            position="bottomRight"
+                                            render={
+                                                <Dropdown.Menu>
+                                                    <Dropdown.Item>退出</Dropdown.Item>
+                                                </Dropdown.Menu>
+                                            }
+                                        >
+                                            <Avatar size="small" color={stringToColor(userState.user.username)} style={{ margin: 4 }}>
+                                                {userState.user.username[0]}
+                                            </Avatar>
+                                            <span>{userState.user.username}</span>
+                                        </Dropdown>
+                                    </>
+                                    :
+                                    <>
+                                        <Nav.Item itemKey={'login'} text={'登录'} icon={<IconKey />} />
+                                        <Nav.Item itemKey={'register'} text={'注册'} icon={<IconUser />} />
+                                    </>
+                                }
+                            </>
+                        }
+                    >
+                    </Nav>
+                </div>
+            </Layout>
+        </>
+    );
+};
+
+export default HeaderBar;

+ 401 - 370
web/src/components/LogsTable.js

@@ -1,403 +1,434 @@
-import React, { useEffect, useState } from 'react';
-import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react';
-import { API, isAdmin, showError, timestamp2string } from '../helpers';
+import React, {useEffect, useState} from 'react';
+import {Label} from 'semantic-ui-react';
+import {API, isAdmin, showError, timestamp2string} from '../helpers';
+
+import {Table, Avatar, Tag, Form, Button, Layout, Select} from '@douyinfe/semi-ui';
+import {ITEMS_PER_PAGE} from '../constants';
+import {renderQuota, stringToColor} from '../helpers/render';
+import {
+    IconAt,
+    IconHistogram,
+    IconGift,
+    IconKey,
+    IconUser,
+    IconLayers,
+    IconSetting,
+    IconCreditCard,
+    IconSemiLogo,
+    IconHome,
+    IconMore
+} from '@douyinfe/semi-icons';
+
+const {Sider, Content, Header} = Layout;
 
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderQuota } from '../helpers/render';
 
 function renderTimestamp(timestamp) {
-  return (
-    <>
-      {timestamp2string(timestamp)}
-    </>
-  );
+    return (
+        <>
+            {timestamp2string(timestamp)}
+        </>
+    );
 }
 
 const MODE_OPTIONS = [
-  { key: 'all', text: '全部用户', value: 'all' },
-  { key: 'self', text: '当前用户', value: 'self' }
+    {key: 'all', text: '全部用户', value: 'all'},
+    {key: 'self', text: '当前用户', value: 'self'}
 ];
 
-const LOG_OPTIONS = [
-  { key: '0', text: '全部', value: 0 },
-  { key: '1', text: '充值', value: 1 },
-  { key: '2', text: '消费', value: 2 },
-  { key: '3', text: '管理', value: 3 },
-  { key: '4', text: '系统', value: 4 }
-];
+const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
+    'light-blue', 'lime', 'orange', 'pink',
+    'purple', 'red', 'teal', 'violet', 'yellow'
+]
 
 function renderType(type) {
-  switch (type) {
-    case 1:
-      return <Label basic color='green'> 充值 </Label>;
-    case 2:
-      return <Label basic color='olive'> 消费 </Label>;
-    case 3:
-      return <Label basic color='orange'> 管理 </Label>;
-    case 4:
-      return <Label basic color='purple'> 系统 </Label>;
-    default:
-      return <Label basic color='black'> 未知 </Label>;
-  }
+    switch (type) {
+        case 1:
+            return <Tag color='cyan' size='large'> 充值 </Tag>;
+        case 2:
+            return <Tag color='yellow' size='large'> 消费 </Tag>;
+        case 3:
+            return <Tag color='orange' size='large'> 管理 </Tag>;
+        case 4:
+            return <Tag color='purple' size='large'> 系统 </Tag>;
+        default:
+            return <Tag color='black' size='large'> 未知 </Tag>;
+    }
 }
 
+
 const LogsTable = () => {
-  const [logs, setLogs] = useState([]);
-  const [showStat, setShowStat] = useState(false);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [searchKeyword, setSearchKeyword] = useState('');
-  const [searching, setSearching] = useState(false);
-  const [logType, setLogType] = useState(0);
-  const isAdminUser = isAdmin();
-  let now = new Date();
-  const [inputs, setInputs] = useState({
-    username: '',
-    token_name: '',
-    model_name: '',
-    start_timestamp: timestamp2string(0),
-    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
-    channel: ''
-  });
-  const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs;
+    const columns = [
+        {
+            title: '时间',
+            dataIndex: 'timestamp2string',
+        },
+        {
+            title: '渠道',
+            dataIndex: 'channel',
+            render: (text, record, index) => {
+                return (
+                    isAdminUser ?
+                        logType === 0 || logType === 2 ?
+                            <div>
+                                {<Tag color={colors[parseInt(text) % 10]} size='large'> {text} </Tag>}
+                            </div>
+                            :
+                            <></>
+                        :
+                        <></>
+                );
+            },
+        },
+        {
+            title: '用户',
+            dataIndex: 'username',
+            render: (text, record, index) => {
+                return (
+                    isAdminUser ?
+                        <div>
+                            <Avatar size="small" color={stringToColor(text)} style={{marginRight: 4}}>
+                                {typeof text === 'string' && text.slice(0, 1)}
+                            </Avatar>
+                            {text}
+                        </div>
+                        :
+                        <></>
+                );
+            },
+        },
+        {
+            title: '令牌',
+            dataIndex: 'token_name',
+            render: (text, record, index) => {
+                return (
+                    logType === 0 || logType === 2 ?
+                        <div>
+                            {<Tag color='grey' size='large'> {text} </Tag>}
+                        </div>
+                        :
+                        <></>
+                );
+            },
+        },
+        {
+            title: '类型',
+            dataIndex: 'type',
+            render: (text, record, index) => {
+                return (
+                    <div>
+                        {renderType(text)}
+                    </div>
+                );
+            },
+        },
+        {
+            title: '模型',
+            dataIndex: 'model_name',
+            render: (text, record, index) => {
+                return (
+                    logType === 0 || logType === 2 ?
+                        <div>
+                            {<Tag color={stringToColor(text)} size='large'> {text} </Tag>}
+                        </div>
+                        :
+                        <></>
+                );
+            },
+        },
+        {
+            title: '提示',
+            dataIndex: 'prompt_tokens',
+            render: (text, record, index) => {
+                return (
+                    logType === 0 || logType === 2 ?
+                        <div>
+                            {<span> {text} </span>}
+                        </div>
+                        :
+                        <></>
+                );
+            },
+        },
+        {
+            title: '补全',
+            dataIndex: 'completion_tokens',
+            render: (text, record, index) => {
+                return (
+                    logType === 0 || logType === 2 ?
+                        <div>
+                            {<span> {text} </span>}
+                        </div>
+                        :
+                        <></>
+                );
+            },
+        },
+        {
+            title: '花费',
+            dataIndex: 'quota',
+            render: (text, record, index) => {
+                return (
+                    logType === 0 || logType === 2 ?
+                        <div>
+                            {
+                                renderQuota(text, 6)
+                            }
+                        </div>
+                        :
+                        <></>
+                );
+            }
+        },
+        {
+            title: '详情',
+            dataIndex: 'content',
+        }
+    ];
 
-  const [stat, setStat] = useState({
-    quota: 0,
-    token: 0
-  });
+    const [logs, setLogs] = useState([]);
+    const [showStat, setShowStat] = useState(false);
+    const [loading, setLoading] = useState(true);
+    const [activePage, setActivePage] = useState(1);
+    const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
+    const [searchKeyword, setSearchKeyword] = useState('');
+    const [searching, setSearching] = useState(false);
+    const [logType, setLogType] = useState(0);
+    const isAdminUser = isAdmin();
+    let now = new Date();
+    const [inputs, setInputs] = useState({
+        username: '',
+        token_name: '',
+        model_name: '',
+        start_timestamp: timestamp2string(0),
+        end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
+        channel: ''
+    });
+    const {username, token_name, model_name, start_timestamp, end_timestamp, channel} = inputs;
 
-  const handleInputChange = (e, { name, value }) => {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
+    const [stat, setStat] = useState({
+        quota: 0,
+        token: 0
+    });
 
-  const getLogSelfStat = async () => {
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    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}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      setStat(data);
-    } else {
-      showError(message);
-    }
-  };
+    const handleInputChange = (value, name) => {
+        setInputs((inputs) => ({...inputs, [name]: value}));
+    };
 
-  const getLogStat = async () => {
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    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}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      setStat(data);
-    } else {
-      showError(message);
-    }
-  };
+    const getLogSelfStat = async () => {
+        let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+        let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+        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}`);
+        const {success, message, data} = res.data;
+        if (success) {
+            setStat(data);
+        } else {
+            showError(message);
+        }
+    };
 
-  const handleEyeClick = async () => {
-    if (!showStat) {
-      if (isAdminUser) {
-        await getLogStat();
-      } else {
-        await getLogSelfStat();
-      }
-    }
-    setShowStat(!showStat);
-  };
+    const getLogStat = async () => {
+        let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+        let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+        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}`);
+        const {success, message, data} = res.data;
+        if (success) {
+            setStat(data);
+        } else {
+            showError(message);
+        }
+    };
 
-  const loadLogs = async (startIdx) => {
-    let url = '';
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    if (isAdminUser) {
-      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}`;
-    } else {
-      url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    }
-    const res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      if (startIdx === 0) {
-        setLogs(data);
-      } else {
-        let newLogs = [...logs];
-        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
-        setLogs(newLogs);
-      }
-    } else {
-      showError(message);
+    const handleEyeClick = async () => {
+        if (!showStat) {
+            if (isAdminUser) {
+                await getLogStat();
+            } else {
+                await getLogSelfStat();
+            }
+        }
+        setShowStat(!showStat);
+    };
+
+    const setLogsFormat = (logs) => {
+        for (let i = 0; i < logs.length; i++) {
+            logs[i].timestamp2string = timestamp2string(logs[i].created_at);
+            logs[i].key = '' + logs[i].id;
+        }
+        // data.key = '' + data.id
+        setLogs(logs);
+        setLogCount(logs.length + ITEMS_PER_PAGE);
+        console.log(logCount);
     }
-    setLoading(false);
-  };
 
-  const onPaginationChange = (e, { activePage }) => {
-    (async () => {
-      if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
-        // In this case we have to load more data and then append them.
-        await loadLogs(activePage - 1);
-      }
-      setActivePage(activePage);
-    })();
-  };
+    const loadLogs = async (startIdx) => {
+        setLoading(true);
 
-  const refresh = async () => {
-    setLoading(true);
-    setActivePage(1);
-    await loadLogs(0);
-  };
+        let url = '';
+        let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+        let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+        if (isAdminUser) {
+            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}`;
+        } else {
+            url = `/api/log/self/?p=${startIdx}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+        }
+        const res = await API.get(url);
+        const {success, message, data} = res.data;
+        if (success) {
+            if (startIdx === 0) {
+                setLogsFormat(data);
+            } else {
+                let newLogs = [...logs];
+                newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
+                setLogsFormat(newLogs);
+            }
+        } else {
+            showError(message);
+        }
+        setLoading(false);
+    };
 
-  useEffect(() => {
-    refresh().then();
-  }, [logType]);
+    const pageData = logs.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
 
-  const searchLogs = async () => {
-    if (searchKeyword === '') {
-      // if keyword is blank, load files instead.
-      await loadLogs(0);
-      setActivePage(1);
-      return;
-    }
-    setSearching(true);
-    const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      setLogs(data);
-      setActivePage(1);
-    } else {
-      showError(message);
-    }
-    setSearching(false);
-  };
+    // const onPaginationChange = (e, { activePage }) => {
+    //   (async () => {
+    //     if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
+    //       // In this case we have to load more data and then append them.
+    //       await loadLogs(activePage - 1);
+    //     }
+    //     setActivePage(activePage);
+    //   })();
+    // };
+    const handlePageChange = page => {
+        setActivePage(page);
+        if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
+            // In this case we have to load more data and then append them.
+            loadLogs(page - 1).then(r => {
+            });
+        }
+        // setLoading(false);
+    };
 
-  const handleKeywordChange = async (e, { value }) => {
-    setSearchKeyword(value.trim());
-  };
+    const refresh = async () => {
+        // setLoading(true);
+        setActivePage(1);
+        await loadLogs(0);
+    };
 
-  const sortLog = (key) => {
-    if (logs.length === 0) return;
-    setLoading(true);
-    let sortedLogs = [...logs];
-    if (typeof sortedLogs[0][key] === 'string') {
-      sortedLogs.sort((a, b) => {
-        return ('' + a[key]).localeCompare(b[key]);
-      });
-    } else {
-      sortedLogs.sort((a, b) => {
-        if (a[key] === b[key]) return 0;
-        if (a[key] > b[key]) return -1;
-        if (a[key] < b[key]) return 1;
-      });
-    }
-    if (sortedLogs[0].id === logs[0].id) {
-      sortedLogs.reverse();
-    }
-    setLogs(sortedLogs);
-    setLoading(false);
-  };
+    useEffect(() => {
+        refresh().then();
+    }, [logType]);
 
-  return (
-    <>
-      <Segment>
-        <Header as='h3'>
-          使用明细(总消耗额度:
-          {showStat && renderQuota(stat.quota)}
-          {!showStat && <span onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}>点击查看</span>}
-          )
-        </Header>
-        <Form>
-          <Form.Group>
-            <Form.Input fluid label={'令牌名称'} width={3} value={token_name}
-                        placeholder={'可选值'} name='token_name' onChange={handleInputChange} />
-            <Form.Input fluid label='模型名称' width={3} value={model_name} placeholder='可选值'
-                        name='model_name'
-                        onChange={handleInputChange} />
-            <Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local'
-                        name='start_timestamp'
-                        onChange={handleInputChange} />
-            <Form.Input fluid label='结束时间' width={4} value={end_timestamp} type='datetime-local'
-                        name='end_timestamp'
-                        onChange={handleInputChange} />
-            <Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button>
-          </Form.Group>
-          {
-            isAdminUser && <>
-              <Form.Group>
-                <Form.Input fluid label={'渠道 ID'} width={3} value={channel}
-                            placeholder='可选值' name='channel'
-                            onChange={handleInputChange} />
-                <Form.Input fluid label={'用户名称'} width={3} value={username}
-                            placeholder={'可选值'} name='username'
-                            onChange={handleInputChange} />
+    const searchLogs = async () => {
+        if (searchKeyword === '') {
+            // if keyword is blank, load files instead.
+            await loadLogs(0);
+            setActivePage(1);
+            return;
+        }
+        setSearching(true);
+        const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
+        const {success, message, data} = res.data;
+        if (success) {
+            setLogs(data);
+            setActivePage(1);
+        } else {
+            showError(message);
+        }
+        setSearching(false);
+    };
 
-              </Form.Group>
-            </>
-          }
-        </Form>
-        <Table basic compact size='small'>
-          <Table.Header>
-            <Table.Row>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('created_time');
-                }}
-                width={3}
-              >
-                时间
-              </Table.HeaderCell>
-              {
-                isAdminUser && <Table.HeaderCell
-                  style={{ cursor: 'pointer' }}
-                  onClick={() => {
-                    sortLog('channel');
-                  }}
-                  width={1}
-                >
-                  渠道
-                </Table.HeaderCell>
-              }
-              {
-                isAdminUser && <Table.HeaderCell
-                  style={{ cursor: 'pointer' }}
-                  onClick={() => {
-                    sortLog('username');
-                  }}
-                  width={1}
-                >
-                  用户
-                </Table.HeaderCell>
-              }
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('token_name');
-                }}
-                width={1}
-              >
-                令牌
-              </Table.HeaderCell>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('type');
-                }}
-                width={1}
-              >
-                类型
-              </Table.HeaderCell>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('model_name');
-                }}
-                width={2}
-              >
-                模型
-              </Table.HeaderCell>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('prompt_tokens');
-                }}
-                width={1}
-              >
-                提示
-              </Table.HeaderCell>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('completion_tokens');
-                }}
-                width={1}
-              >
-                补全
-              </Table.HeaderCell>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('quota');
-                }}
-                width={1}
-              >
-                额度
-              </Table.HeaderCell>
-              <Table.HeaderCell
-                style={{ cursor: 'pointer' }}
-                onClick={() => {
-                  sortLog('content');
-                }}
-                width={isAdminUser ? 4 : 6}
-              >
-                详情
-              </Table.HeaderCell>
-            </Table.Row>
-          </Table.Header>
+    const handleKeywordChange = async (e, {value}) => {
+        setSearchKeyword(value.trim());
+    };
 
-          <Table.Body>
-            {logs
-              .slice(
-                (activePage - 1) * ITEMS_PER_PAGE,
-                activePage * ITEMS_PER_PAGE
-              )
-              .map((log, idx) => {
-                if (log.deleted) return <></>;
-                return (
-                  <Table.Row key={log.id}>
-                    <Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
-                    {
-                      isAdminUser && (
-                        <Table.Cell>{log.channel ? <Label basic>{log.channel}</Label> : ''}</Table.Cell>
-                      )
-                    }
-                    {
-                      isAdminUser && (
-                        <Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell>
-                      )
-                    }
-                    <Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell>
-                    <Table.Cell>{renderType(log.type)}</Table.Cell>
-                    <Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</Table.Cell>
-                    <Table.Cell>{log.prompt_tokens ? log.prompt_tokens : ''}</Table.Cell>
-                    <Table.Cell>{log.completion_tokens ? log.completion_tokens : ''}</Table.Cell>
-                    <Table.Cell>{log.quota ? renderQuota(log.quota, 6) : ''}</Table.Cell>
-                    <Table.Cell>{log.content}</Table.Cell>
-                  </Table.Row>
-                );
-              })}
-          </Table.Body>
+    const sortLog = (key) => {
+        if (logs.length === 0) return;
+        setLoading(true);
+        let sortedLogs = [...logs];
+        if (typeof sortedLogs[0][key] === 'string') {
+            sortedLogs.sort((a, b) => {
+                return ('' + a[key]).localeCompare(b[key]);
+            });
+        } else {
+            sortedLogs.sort((a, b) => {
+                if (a[key] === b[key]) return 0;
+                if (a[key] > b[key]) return -1;
+                if (a[key] < b[key]) return 1;
+            });
+        }
+        if (sortedLogs[0].id === logs[0].id) {
+            sortedLogs.reverse();
+        }
+        setLogs(sortedLogs);
+        setLoading(false);
+    };
 
-          <Table.Footer>
-            <Table.Row>
-              <Table.HeaderCell colSpan={'10'}>
-                <Select
-                  placeholder='选择明细分类'
-                  options={LOG_OPTIONS}
-                  style={{ marginRight: '8px' }}
-                  name='logType'
-                  value={logType}
-                  onChange={(e, { name, value }) => {
-                    setLogType(value);
-                  }}
-                />
-                <Button size='small' onClick={refresh} loading={loading}>刷新</Button>
-                <Pagination
-                  floated='right'
-                  activePage={activePage}
-                  onPageChange={onPaginationChange}
-                  size='small'
-                  siblingRange={1}
-                  totalPages={
-                    Math.ceil(logs.length / ITEMS_PER_PAGE) +
-                    (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
-                  }
-                />
-              </Table.HeaderCell>
-            </Table.Row>
-          </Table.Footer>
-        </Table>
-      </Segment>
-    </>
-  );
+    return (
+        <>
+            <Layout>
+                <Header>
+                    <h3>使用明细(总消耗额度:
+                        {showStat && renderQuota(stat.quota)}
+                        {!showStat &&
+                            <span onClick={handleEyeClick} style={{cursor: 'pointer', color: 'gray'}}>点击查看</span>}
+                        )
+                    </h3>
+                </Header>
+                <Form layout='horizontal' style={{marginTop: 10}}>
+                    <>
+                        <Form.Input field="token_name" label='令牌名称' style={{width: 176}} value={token_name}
+                                    placeholder={'可选值'} name='token_name'
+                                    onChange={value => handleInputChange(value, 'token_name')}/>
+                        <Form.Input field="model_name" label='模型名称' style={{width: 176}} value={model_name}
+                                    placeholder='可选值'
+                                    name='model_name'
+                                    onChange={value => handlePageChange(value, 'model_name')}/>
+                        <Form.DatePicker field="start_timestamp" label='起始时间' style={{width: 272}}
+                                         value={start_timestamp} type='dateTime'
+                                         name='start_timestamp'
+                                         onChange={value => handleInputChange(value, 'start_timestamp')}/>
+                        <Form.DatePicker field="end_timestamp" fluid label='结束时间' style={{width: 272}}
+                                         value={end_timestamp} type='dateTime'
+                                         name='end_timestamp'
+                                         onChange={value => handleInputChange(value, 'end_timestamp')}/>
+                        {/*<Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button>*/}
+                        {
+                            isAdminUser && <>
+                                <Form.Input field="channel" label='渠道 ID' style={{width: 176}} value={channel}
+                                            placeholder='可选值' name='channel'
+                                            onChange={value => handleInputChange(value, 'channel')}/>
+                                <Form.Input field="username" label='用户名称' style={{width: 176}} value={username}
+                                            placeholder={'可选值'} name='username'
+                                            onChange={value => handleInputChange(value, 'username')}/>
+                            </>
+                        }
+                        <Form.Section>
+                            <Button label='操作' type="primary" htmlType="submit" className="btn-margin-right"
+                                    onClick={refresh}>查询</Button>
+                        </Form.Section>
+                    </>
+                </Form>
+                <Table columns={columns} dataSource={pageData} pagination={{
+                    currentPage: activePage,
+                    pageSize: ITEMS_PER_PAGE,
+                    total: logCount,
+                    pageSizeOpts: [10, 20, 50, 100],
+                    onPageChange: handlePageChange,
+                }} loading={loading}/>
+                <Select defaultValue="0" style={{width: 120}} onChange={
+                    (value) => {
+                        setLogType(parseInt(value));
+                    }
+                }>
+                    <Select.Option value="0">全部</Select.Option>
+                    <Select.Option value="1">充值</Select.Option>
+                    <Select.Option value="2">消费</Select.Option>
+                    <Select.Option value="3">管理</Select.Option>
+                    <Select.Option value="4">系统</Select.Option>
+                </Select>
+            </Layout>
+        </>
+    );
 };
 
 export default LogsTable;

+ 274 - 0
web/src/components/SiderBar.js

@@ -0,0 +1,274 @@
+import React, {useContext, useState} from 'react';
+import {Link, useNavigate} from 'react-router-dom';
+import {UserContext} from '../context/User';
+
+import {Button, Container, Icon, Menu, Segment} from 'semantic-ui-react';
+import {API, getLogo, getSystemName, isAdmin, isMobile, showSuccess} from '../helpers';
+import '../index.css';
+
+import {
+    IconAt,
+    IconHistogram,
+    IconGift,
+    IconKey,
+    IconUser,
+    IconLayers,
+    IconSetting,
+    IconCreditCard,
+    IconSemiLogo,
+    IconHome,
+    IconImage
+} from '@douyinfe/semi-icons';
+import {Nav, Avatar, Dropdown, Layout} from '@douyinfe/semi-ui';
+
+// HeaderBar Buttons
+let headerButtons = [
+    {
+        text: '首页',
+        itemKey: 'home',
+        to: '/',
+        icon: <IconHome/>
+    },
+    {
+        text: '渠道',
+        itemKey: 'channel',
+        to: '/channel',
+        icon: <IconLayers/>,
+        admin: true
+    },
+
+    {
+        text: '令牌',
+        itemKey: 'token',
+        to: '/token',
+        icon: <IconKey/>
+    },
+    {
+        text: '兑换码',
+        itemKey: 'redemption',
+        to: '/redemption',
+        icon: <IconGift/>,
+        admin: true
+    },
+    {
+        text: '钱包',
+        itemKey: 'topup',
+        to: '/topup',
+        icon: <IconCreditCard/>
+    },
+    {
+        text: '用户管理',
+        itemKey: 'user',
+        to: '/user',
+        icon: <IconUser/>,
+        admin: true
+    },
+    {
+        text: '日志',
+        itemKey: 'log',
+        to: '/log',
+        icon: <IconHistogram/>
+    },
+    {
+        text: '绘图',
+        itemKey: 'midjourney',
+        to: '/midjourney',
+        icon: <IconImage/>
+    },
+    {
+        text: '设置',
+        itemKey: 'setting',
+        to: '/setting',
+        icon: <IconSetting/>
+    },
+    // {
+    //     text: '关于',
+    //     itemKey: 'about',
+    //     to: '/about',
+    //     icon: <IconAt/>
+    // }
+];
+
+if (localStorage.getItem('chat_link')) {
+    headerButtons.splice(1, 0, {
+        name: '聊天',
+        to: '/chat',
+        icon: 'comments'
+    });
+}
+
+const HeaderBar = () => {
+    const [userState, userDispatch] = useContext(UserContext);
+    let navigate = useNavigate();
+    const [selectedKeys, setSelectedKeys] = useState(['home']);
+    const [showSidebar, setShowSidebar] = useState(false);
+    const systemName = getSystemName();
+    const logo = getLogo();
+
+    async function logout() {
+        setShowSidebar(false);
+        await API.get('/api/user/logout');
+        showSuccess('注销成功!');
+        userDispatch({type: 'logout'});
+        localStorage.removeItem('user');
+        navigate('/login');
+    }
+
+    const toggleSidebar = () => {
+        setShowSidebar(!showSidebar);
+    };
+
+    const renderButtons = (isMobile) => {
+        return headerButtons.map((button) => {
+            if (button.admin && !isAdmin()) return <></>;
+            if (isMobile) {
+                return (
+                    <Menu.Item
+                        onClick={() => {
+                            navigate(button.to);
+                            setShowSidebar(false);
+                        }}
+                    >
+                        {button.name}
+                    </Menu.Item>
+                );
+            }
+            return (
+                <Menu.Item key={button.name} as={Link} to={button.to}>
+                    <Icon name={button.icon}/>
+                    {button.name}
+                </Menu.Item>
+            );
+        });
+    };
+
+    if (isMobile()) {
+        return (
+            <>
+                <Menu
+                    borderless
+                    size='large'
+                    style={
+                        showSidebar
+                            ? {
+                                borderBottom: 'none',
+                                marginBottom: '0',
+                                borderTop: 'none',
+                                height: '51px'
+                            }
+                            : {borderTop: 'none', height: '52px'}
+                    }
+                >
+                    <Container>
+                        <Menu.Item as={Link} to='/'>
+                            <img
+                                src={logo}
+                                alt='logo'
+                                style={{marginRight: '0.75em'}}
+                            />
+                            <div style={{fontSize: '20px'}}>
+                                <b>{systemName}</b>
+                            </div>
+                        </Menu.Item>
+                        <Menu.Menu position='right'>
+                            <Menu.Item onClick={toggleSidebar}>
+                                <Icon name={showSidebar ? 'close' : 'sidebar'}/>
+                            </Menu.Item>
+                        </Menu.Menu>
+                    </Container>
+                </Menu>
+
+                {showSidebar ? (
+                    <Segment style={{marginTop: 0, borderTop: '0'}}>
+                        {/*<Menu secondary vertical style={{ width: '100%', margin: 0 }}>*/}
+                        {/*  {renderButtons(true)}*/}
+                        {/*  <Menu.Item>*/}
+                        {/*    {userState.user ? (*/}
+                        {/*      <Button onClick={logout}>注销</Button>*/}
+                        {/*    ) : (*/}
+                        {/*      <>*/}
+                        {/*        <Button*/}
+                        {/*          onClick={() => {*/}
+                        {/*            setShowSidebar(false);*/}
+                        {/*            navigate('/login');*/}
+                        {/*          }}*/}
+                        {/*        >*/}
+                        {/*          登录*/}
+                        {/*        </Button>*/}
+                        {/*        <Button*/}
+                        {/*          onClick={() => {*/}
+                        {/*            setShowSidebar(false);*/}
+                        {/*            navigate('/register');*/}
+                        {/*          }}*/}
+                        {/*        >*/}
+                        {/*          注册*/}
+                        {/*        </Button>*/}
+                        {/*      </>*/}
+                        {/*    )}*/}
+                        {/*  </Menu.Item>*/}
+                        {/*</Menu>*/}
+
+                    </Segment>
+                ) : (
+                    <></>
+                )}
+
+
+            </>
+        );
+    }
+
+    return (
+        <>
+            <Layout>
+                <div style={{height: '100%'}}>
+                    <Nav
+                        // mode={'horizontal'}
+                        // bodyStyle={{ height: 100 }}
+                        selectedKeys={selectedKeys}
+                        renderWrapper={({itemElement, isSubNav, isInSubNav, props}) => {
+                            const routerMap = {
+                                home: "/",
+                                channel: "/channel",
+                                token: "/token",
+                                redemption: "/redemption",
+                                topup: "/topup",
+                                user: "/user",
+                                log: "/log",
+                                midjourney: "/midjourney",
+                                setting: "/setting",
+                                about: "/about",
+                            };
+                            return (
+                                <Link
+                                    style={{textDecoration: "none"}}
+                                    to={routerMap[props.itemKey]}
+                                >
+                                    {itemElement}
+                                </Link>
+                            );
+                        }}
+                        items={headerButtons}
+                        onSelect={key => {
+                            console.log(key);
+                            setSelectedKeys([key.itemKey]);
+                        }}
+                        header={{
+                            logo: <IconSemiLogo style={{height: '36px', fontSize: 36}}/>,
+                            text: 'NekoAPI'
+                        }}
+                        // footer={{
+                        //   text: '© 2021 NekoAPI',
+                        // }}
+                    >
+
+                        <Nav.Footer collapseButton={true}>
+                        </Nav.Footer>
+                    </Nav>
+                </div>
+            </Layout>
+        </>
+    );
+};
+
+export default HeaderBar;

+ 17 - 0
web/src/helpers/render.js

@@ -55,4 +55,21 @@ export function renderQuotaWithPrompt(quota, digits) {
     return `(等价金额:${renderQuota(quota, digits)})`;
   }
   return '';
+}
+
+const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
+  'light-blue', 'lime', 'orange', 'pink',
+  'purple', 'red', 'teal', 'violet', 'yellow'
+]
+
+export function stringToColor(str) {
+  let sum = 0;
+  // 对字符串中的每个字符进行操作
+  for (let i = 0; i < str.length; i++) {
+    // 将字符的ASCII值加到sum中
+    sum += str.charCodeAt(i);
+  }
+  // 使用模运算得到个位数
+  let i = sum % colors.length;
+  return colors[i];
 }

+ 1 - 1
web/src/helpers/utils.js

@@ -22,7 +22,7 @@ export function isRoot() {
 
 export function getSystemName() {
   let system_name = localStorage.getItem('system_name');
-  if (!system_name) return 'One API Midjourney';
+  if (!system_name) return 'Neko API';
   return system_name;
 }
 

+ 21 - 0
web/src/index.css

@@ -6,6 +6,8 @@ body {
     -webkit-font-smoothing: antialiased;
     -moz-osx-font-smoothing: grayscale;
     scrollbar-width: none;
+    color: var(--semi-color-text-0) !important;
+    background-color: var( --semi-color-bg-0) !important;
 }
 
 body::-webkit-scrollbar {
@@ -16,8 +18,27 @@ code {
     font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
 }
 
+.semi-navigation-vertical {
+    /*display: flex;*/
+    /*flex-direction: column;*/
+}
+
+.semi-navigation-item {
+    margin-bottom: 0;
+}
+
+.semi-navigation-vertical {
+    /*flex: 0 0 auto;*/
+    /*display: flex;*/
+    /*flex-direction: column;*/
+    /*width: 100%;*/
+    height: 100vh;
+    overflow: hidden;
+}
+
 .main-content {
     padding: 4px;
+    height: 100%;
 }
 
 .small-icon .icon {

+ 38 - 20
web/src/index.js

@@ -1,31 +1,49 @@
 import React from 'react';
 import ReactDOM from 'react-dom/client';
-import { BrowserRouter } from 'react-router-dom';
-import { Container } from 'semantic-ui-react';
+import {BrowserRouter} from 'react-router-dom';
+import {Container} from 'semantic-ui-react';
 import App from './App';
-import Header from './components/Header';
+import HeaderBar from './components/HeaderBar';
 import Footer from './components/Footer';
 import 'semantic-ui-css/semantic.min.css';
 import './index.css';
-import { UserProvider } from './context/User';
-import { ToastContainer } from 'react-toastify';
+import {UserProvider} from './context/User';
+import {ToastContainer} from 'react-toastify';
 import 'react-toastify/dist/ReactToastify.css';
-import { StatusProvider } from './context/Status';
+import {StatusProvider} from './context/Status';
+import {Layout} from "@douyinfe/semi-ui";
+import SiderBar from "./components/SiderBar";
 
 const root = ReactDOM.createRoot(document.getElementById('root'));
+const {Sider, Content, Header} = Layout;
 root.render(
-  <React.StrictMode>
-    <StatusProvider>
-      <UserProvider>
-        <BrowserRouter>
-          <Header />
-          <Container className={'main-content'}>
-            <App />
-          </Container>
-          <ToastContainer />
-          <Footer />
-        </BrowserRouter>
-      </UserProvider>
-    </StatusProvider>
-  </React.StrictMode>
+    <React.StrictMode>
+        <StatusProvider>
+            <UserProvider>
+                <BrowserRouter>
+                    <Layout>
+
+                        <Sider>
+                            <SiderBar/>
+                        </Sider>
+                        <Layout>
+                            <Header>
+                                <HeaderBar/>
+                            </Header>
+                            <Content
+                                style={{
+                                    padding: '24px',
+                                }}
+                            >
+                                <App/>
+                            </Content>
+                            <Footer></Footer>
+                        </Layout>
+                    </Layout>
+                    <ToastContainer/>
+                    {/*<Footer />*/}
+                </BrowserRouter>
+            </UserProvider>
+        </StatusProvider>
+    </React.StrictMode>
 );