Showing preview only (591K chars total). Download the full file or copy to clipboard to get everything.
Repository: dreamhartley/gemini-proxy-panel
Branch: main
Commit: 8eeab2968b18
Files: 50
Total size: 547.7 KB
Directory structure:
gitextract_h9d7l8di/
├── .dockerignore
├── .github/
│ └── workflows/
│ └── docker-publish.yml
├── .gitignore
├── Dockerfile
├── Dockerfile.huggingface
├── LICENSE
├── README.md
├── README_zh.md
├── doc/
│ ├── Deploy/
│ │ ├── Colab/
│ │ │ ├── Colab部署.md
│ │ │ └── colab启动.ipynb
│ │ ├── GitHub/
│ │ │ └── GitHub同步.md
│ │ ├── HuggingFace/
│ │ │ ├── Hugging Face Space部署-fork说明.md
│ │ │ └── Hugging Face Space部署.md
│ │ ├── Koyeb/
│ │ │ └── Koyeb部署.md
│ │ ├── Local/
│ │ │ └── 本地部署.md
│ │ ├── Render/
│ │ │ └── Render部署.md
│ │ └── Uptimerobot/
│ │ └── 配置Uptimerrobot.md
│ ├── Usage/
│ │ ├── KEEPALIVE.md
│ │ ├── Vertex/
│ │ │ └── Vertex代理配置.md
│ │ ├── 在客户端中使用.md
│ │ └── 配置API连接.md
│ └── 项目介绍.md
├── docker-compose.yml
├── get-jimihub.sh
├── package.json
├── public/
│ ├── admin/
│ │ ├── index.html
│ │ ├── script.js
│ │ ├── style.css
│ │ └── version.txt
│ ├── i18n.js
│ ├── login.html
│ └── login.script.js
└── src/
├── db/
│ └── index.js
├── index.js
├── middleware/
│ ├── adminAuth.js
│ └── workerAuth.js
├── routes/
│ ├── adminApi.js
│ ├── apiV1.js
│ └── auth.js
├── services/
│ ├── batchTestService.js
│ ├── configService.js
│ ├── geminiKeyService.js
│ ├── geminiProxyService.js
│ ├── schedulerService.js
│ └── vertexProxyService.js
└── utils/
├── githubSync.js
├── helpers.js
├── proxyPool.js
├── session.js
└── transform.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# Git files
.git
.gitignore
# Docker files
Dockerfile
.dockerignore
docker-compose.yml
Dockerfile.huggingface
# Node dependencies (install these inside the container)
node_modules
# Environment variables
.env
.env.*
.dev.vars
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory
coverage
*.lcov
.nyc_output
# Build artifacts and caches
build/
dist/
.cache/
.parcel-cache/
*.tsbuildinfo
.npm/
.eslintcache
.stylelintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz
.yarn-integrity
.next/
out/
.nuxt/
.vuepress/dist
.temp/
.docusaurus/
.serverless/
.fusebox/
.dynamodb/
.tern-port
.vscode-test/
# Yarn PnP files
.yarn/
.pnp.*
# Wrangler project files (assuming they are not needed in this specific image)
wrangler.toml
.wrangler/
worker/
# Documentation and License (optional, remove if needed in image)
README.md
README_zh.md
LICENSE
get-gemhub.sh
doc/
data/
================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Docker Image CI for Hugging Face
on:
push:
branches: [ "main" ]
workflow_dispatch:
jobs:
build_and_push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Convert owner to lowercase
id: string
run: echo "REPO_OWNER_LC=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Convert repo name to lowercase
id: repo_name
run: echo "REPO_NAME_LC=$(echo ${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ env.REPO_OWNER_LC }}/${{ env.REPO_NAME_LC }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.huggingface
push: true
tags: ghcr.io/${{ env.REPO_OWNER_LC }}/${{ env.REPO_NAME_LC }}:latest
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .gitignore
================================================
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/
================================================
FILE: Dockerfile
================================================
FROM node:lts-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --only=production
COPY . .
EXPOSE 3000
CMD [ "npm", "start" ]
================================================
FILE: Dockerfile.huggingface
================================================
FROM dreamhartley705/jimihub:latest
ENV HUGGING_FACE=1
ENV PORT=7860
USER root
RUN mkdir -p /home/user/data && \
chmod 777 /home/user/data && \
chown -R node:node /home/user/data
USER node
EXPOSE 7860
CMD [ "npm", "start" ]
================================================
FILE: LICENSE
================================================
Creative Commons Attribution-NonCommercial 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 – Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
c. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
d. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
e. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
f. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
g. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
h. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 – Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
B. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Public License does not violate this Section 2(a)(4).
5. Downstream recipients.
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional terms – Licensed Material. No downstream recipient of the Licensed Material may have any terms imposed on their use of the Licensed Material that restrict the terms of this Public License or the exercise of the Licensed Rights granted under this Public License.
C. Offer from the Licensor – Adapted Material. Every recipient of Adapted Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License on the Adapted Material.
D. Additional terms – Adapted Material. No downstream recipient of Adapted Material may have any terms imposed on their use of the Adapted Material that restrict the terms of this Public License or the exercise of the Licensed Rights granted under this Public License.
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme.
Section 3 – License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor;
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. NonCommercial. You may not exercise any of the Licensed Rights for commercial purposes.
c. No additional restrictions. You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
Section 4 – Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material, the Licensor waives to the fullest extent permitted by law the Licensed Rights, to the extent necessary to allow You to use, reproduce, and Share the Licensed Material including by including in Your adaptations any contents of the database.
Section 5 – Disclaimer of Warranties and Limitation of Liability.
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, either express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable.
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory for any damages arising out of or in connection with the Licensed Material or the use or other dealings with the Licensed Material, including any direct, indirect, special, incidental, consequential, punitive, exemplary, or other damages, including loss of profits, data, use, goodwill, or other intangible losses, even if the Licensor has been advised of the possibility of such damages.
Section 6 – Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your rights under this Public License terminate, they will not be reinstated.
c. Subject to the above, the license is perpetual (for the duration of the applicable rights).
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 – Other Terms and Conditions.
a. The Licensor and You agree that this Public License constitutes the entire agreement regarding the Licensed Material and supersedes all prior agreements and understandings, whether oral or written.
b. The Licensor waives any right to collect royalties from You for the Licensed Rights licensed under this Public License.
c. If any provision of this Public License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remaining provisions of this Public License, and such provision shall be reformed to the minimum extent necessary to make it valid and enforceable.
d. No warranties are given. The license may not give You all of the permissions necessary for Your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how You use the Licensed Material.
e. No trademark rights are granted.
Section 8 – Interpretation.
a. For the avoidance of doubt, this Public License is not intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in applicable copyright law.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to be enforceable.
c. No warranties or conditions shall be construed to limit the scope of rights granted under this Public License.
d. No interpretation of this Public License shall be used to justify any infringement of copyright or rights under this Public License.
For more information, please visit https://creativecommons.org/licenses/by-nc/4.0/legalcode
================================================
FILE: README.md
================================================
# JimiHub
> **本项目遵循CC BY-NC 4.0协议,禁止任何形式的商业倒卖行为。**
This project is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0).
Commercial resale or any form of commercial use is prohibited.
[**中文介绍**](./README_zh.md "Chinese Readme") <br><br>
[***详细部署与使用文档(新手看这里)***](./doc/项目介绍.md "项目介绍") <br><br>
## Introduction
`JimiHub` is a proxy service. It forwards requests formatted for the OpenAI API to the Google Gemini Pro API, allowing applications developed for OpenAI to seamlessly switch to or leverage the capabilities of Gemini models.
## Features
* **OpenAI to Gemini Proxy**: Seamlessly translates OpenAI Chat API requests into Gemini Pro API requests.
* **Multi-API Key Rotation**: Supports configuring multiple Gemini API keys and automatically rotates through them to distribute request load and circumvent rate limits.
* **Quota and Usage Management**: Monitor the usage of each Gemini API key through an intuitive management interface.
* **Key Management**: Centrally manage multiple Gemini API keys and Worker API keys (used to access this proxy service) within the management panel.
* **Model Configuration**: Define and manage the Gemini models supported by this proxy in the management panel.
* **Intuitive Management Interface**: Provides a Web UI (`/login` or `/admin`) to view API usage statistics and configure settings.
* **One-Click Deployment**: Supports quick deployment to the Cloudflare Workers platform via the "Deploy to Cloudflare" button.
* **GitHub Actions Automatic Deployment**: After forking the repository, enables automatic deployment via GitHub Actions upon code push.
* **GitHub Database Sync**: Leverages GitHub repositories for automatic database synchronization.
## Hugging Face Space Deployment
This deployment method utilizes Hugging Face Space's Docker environment and **requires enabling GitHub sync** for data persistence.
1. **Prepare GitHub Repository and PAT**:
* You need **your own** GitHub repository to store synchronized data. A private repository is recommended.
* Create a GitHub Personal Access Token (PAT) with the `repo` permission scope. **Keep this token secure**.
2. **Create a Hugging Face Space**:
* Visit Hugging Face and create a new Space.
* Select "Docker" as the Space SDK.
* Choose "Use existing Dockerfile from repository".
3. **Configure Space Secrets**:
* Go to your Space's "Settings" -> "Repository secrets".
* Add the following Secrets:
* `ADMIN_PASSWORD`: Set a login password for the admin panel.
* `SESSION_SECRET_KEY`: Set a long, random session key.
* `GITHUB_PROJECT`: Enter **your own** GitHub repository path in the format `your-username/your-repo-name`.
* `GITHUB_PROJECT_PAT`: Enter your GitHub PAT created earlier.
* `GITHUB_ENCRYPT_KEY`: Set an encryption key for synced data, **must be at least 32 characters long**.
4. **Create Dockerfile**:
* In your Hugging Face Space's "Files" tab, click "Add file" -> "Create new file".
* Set the filename to `Dockerfile`.
* Paste the following content into the file:
```dockerfile
FROM dreamhartley705/jimihub:huggingface
```
* Click "Commit new file".
5. **Launch and Access**:
* Hugging Face Space will automatically build and start the application using this `Dockerfile`.
* Once launched, the app will connect to your GitHub repository for data syncing using your configured Secrets.
* You can access the admin panel (`/login` or `/admin`) and API (`/v1`) via the URL provided by the Space.
## Local Node.js Deployment
This method is suitable for local development and testing.
1. **Clone the Repository**:
```bash
git clone https://github.com/dreamhartley/gemini-proxy-panel.git
cd gemini-proxy-panel
```
2. **Install Dependencies**:
```bash
npm install
```
3. **Configure Environment Variables**:
* Copy the `.env.example` file to `.env`:
```bash
cp .env.example .env
```
* Edit the `.env` file, setting at minimum:
* `ADMIN_PASSWORD`: Set the admin panel login password.
* `SESSION_SECRET_KEY`: Set a long, random string for session security (e.g., generate with `openssl rand -base64 32`).
* `PORT` (optional): Default is 3000, change as needed.
* **(Optional) Configure GitHub Sync**: To sync data to a GitHub repository, set:
* `GITHUB_PROJECT`: Your GitHub repository path in format `username/repo-name`. **Note: This is your own repository for data backup, not this project's repository.**
* `GITHUB_PROJECT_PAT`: Your GitHub Personal Access Token with `repo` permission.
* `GITHUB_ENCRYPT_KEY`: An encryption key for syncing data, must be at least 32 characters long.
4. **Start the Service**:
```bash
npm start
```
The service will run at `http://localhost:3000` (or your configured port).
## Docker Deployment
You can quickly deploy using Docker or Docker Compose.
### Method 1: Using `docker build` and `docker run`
1. **Clone the Repository**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **Configure Environment Variables**:
* Copy the `.env.example` file to `.env`:
```bash
cp .env.example .env
```
* Edit the `.env` file, setting the necessary variables (`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`) and optional GitHub sync variables (`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`). **Note: The `PORT` variable typically doesn't need to be set in the `.env` file for Docker deployments, as port mapping is done in the `docker run` command.**
3. **Build the Docker Image**:
```bash
docker build -t gemhub .
```
4. **Run the Docker Container**:
```bash
docker run -d --name gemhub \
-p 3000:3000 \
--env-file .env \
-v ./data:/usr/src/app/data \
gemhub
```
* `-d`: Run the container in the background.
* `--name gemhub`: Name for the container.
* `-p 3000:3000`: Map host port 3000 to container port 3000.
* `--env-file .env`: Load environment variables from the `.env` file.
* `-v ./data:/usr/src/app/data`: Mount the local `data` directory to the container for SQLite database persistence. Ensure the `data` directory exists locally.
### Method 2: Using `docker-compose` (Recommended)
1. **Clone the Repository**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **Configure Environment Variables**:
* Copy the `.env.example` file to `.env`:
```bash
cp .env.example .env
```
* Edit the `.env` file, setting the necessary variables (`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`) and optional GitHub sync variables (`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`).
3. **Start the Service**:
```bash
docker-compose up -d
```
Docker Compose will automatically build the image (if needed), create and start the container, and handle port mapping, environment variables, and data volumes according to the `docker-compose.yml` file.
## Usage
### Management Panel
1. Access the `/login` or `/admin` path of your Worker URL (e.g., `https://your-worker-name.your-subdomain.workers.dev/login`).
2. Log in using the `ADMIN_PASSWORD` you set.
3. In the management panel, you can:
* Add and manage your Gemini API keys.
* Add and manage API keys used to access this Worker proxy (Worker API Keys).
* Set global quotas for Pro and Flash series models.
* View usage statistics for each Gemini API key.
* Configure supported Gemini models.
### API Proxy
1. Point the API endpoint of your application (originally configured to call the OpenAI API) to your deployed Worker URL (e.g., `https://your-worker-name.your-subdomain.workers.dev/v1`).
2. Ensure that your application includes valid authentication information when sending requests. This is usually done by carrying the "Worker API Key" configured in the management panel in the `Authorization` request header:
```
Authorization: Bearer <your_worker_api_key>
```
3. Send requests compatible with the OpenAI Chat Completions API. The Worker will convert them into Gemini API requests and return the formatted response.
## Configuration Overview
## Configuration Overview
### Local Node.js / Docker / Hugging Face Deployments
These deployment methods configure environment variables through the `.env` file or Secrets (Hugging Face).
* **Core Environment Variables (Required)**:
* `ADMIN_PASSWORD`: Login password for the admin panel.
* `SESSION_SECRET_KEY`: Key for securing user sessions (use a long, random string).
* **Optional Environment Variables**:
* `PORT`: (Local Node.js/Docker only) Port for the service to listen on, default is 3000. Hugging Face handles the port automatically.
* **GitHub Sync Environment Variables (Optional, Required for Hugging Face)**:
* `GITHUB_PROJECT`: Path to **your own** GitHub repository for data syncing (format: `username/repo-name`).
* `GITHUB_PROJECT_PAT`: GitHub Personal Access Token with `repo` permission.
* `GITHUB_ENCRYPT_KEY`: Key for encrypting synced data (at least 32 characters).
================================================
FILE: README_zh.md
================================================
# JimiHub
## 简介
`JimiHub` 是一个代理服务。它可以将 OpenAI API 格式的请求转发给 Google Gemini Pro API,使得为 OpenAI 开发的应用能够无缝切换或利用 Gemini 模型的能力。
## 功能
* **OpenAI 到 Gemini 代理**: 无缝将 OpenAI Chat API 请求转换为 Gemini Pro API 请求。
* **多 API Key 轮询**: 支持配置多个 Gemini API Key,并自动轮询使用,以分摊请求负载和规避速率限制。
* **配额与用量管理**: 通过直观的管理界面监控每个 Gemini API Key 的使用情况。
* **密钥管理**: 在管理面板中集中管理多个 Gemini API Key 和 Worker API Key(用于访问此代理服务)。
* **模型配置**: 在管理面板中定义和管理此代理支持的 Gemini 模型。
* **直观的管理界面**: 提供 Web UI (`/login` 或 `/admin`) 查看 API 使用统计和配置设置。
* **一键部署**: 支持通过 "Deploy to Cloudflare" 按钮快速部署到 Cloudflare Workers 平台。
* **GitHub Actions 自动部署**: Fork 仓库后,可通过 GitHub Actions 实现推送代码时自动部署。
* **GitHub 同步数据库**: 利用GitHub仓库自动同步数据库
## Hugging Face Space 部署
此部署方式利用 Hugging Face Space 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。
1. **准备 GitHub 仓库和 PAT**:
* 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。
* 创建一个 GitHub Personal Access Token (PAT),并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。
2. **创建 Hugging Face Space**:
* 访问 Hugging Face 并创建一个新的 Space。
* 选择 "Docker" 作为 Space SDK。
* 选择 "Use existing Dockerfile from repository"。
3. **配置 Space Secrets**:
* 进入你创建的 Space 的 "Settings" -> "Repository secrets"。
* 添加以下 Secrets:
* `ADMIN_PASSWORD`: 设置管理面板的登录密码。
* `SESSION_SECRET_KEY`: 设置一个长且随机的会话密钥。
* `GITHUB_PROJECT`: 填入你**自己的** GitHub 仓库路径,格式为 `your-username/your-repo-name`。
* `GITHUB_PROJECT_PAT`: 填入你创建的 GitHub PAT。
* `GITHUB_ENCRYPT_KEY`: 设置一个用于加密同步数据的密钥,**必须是 32 位或更长的字符串**。
4. **创建 Dockerfile**:
* 在你的 Hugging Face Space 的 "Files" 标签页中,点击 "Add file" -> "Create new file"。
* 将文件名设置为 `Dockerfile`。
* 将以下内容粘贴到文件中:
```dockerfile
FROM dreamhartley705/jimihub:huggingface
```
* 点击 "Commit new file"。
5. **启动和访问**:
* Hugging Face Space 会自动使用此 `Dockerfile` 构建并启动应用。
* 应用启动后,会使用你配置的 Secrets 连接到你的 GitHub 仓库进行数据同步。
* 你可以通过 Space 提供的 URL 访问管理面板 (`/login` 或 `/admin`) 和 API (`/v1`)。
## 本地 Node.js 部署
此方式适合本地开发和测试。
1. **克隆仓库**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **安装依赖**:
```bash
npm install
```
3. **配置环境变量**:
* 复制 `.env.example` 文件为 `.env`:
```bash
cp .env.example .env
```
* 编辑 `.env` 文件,至少设置以下变量:
* `ADMIN_PASSWORD`: 设置管理面板的登录密码。
* `SESSION_SECRET_KEY`: 设置一个长且随机的字符串用于会话安全(例如,使用 `openssl rand -base64 32` 生成)。
* `PORT` (可选): 默认是 3000,可以根据需要修改。
* **(可选) 配置 GitHub 同步**: 如果需要将数据同步到 GitHub 仓库,请配置以下变量:
* `GITHUB_PROJECT`: 你的 GitHub 仓库路径,格式为 `username/repo-name`。**注意:这是你自己的仓库,用于存储数据备份,并非本项目仓库。**
* `GITHUB_PROJECT_PAT`: 你的 GitHub Personal Access Token,需要 `repo` 权限。
* `GITHUB_ENCRYPT_KEY`: 用于加密同步数据的密钥,必须是 32 位或更长的字符串。
4. **启动服务**:
```bash
npm start
```
服务将在 `http://localhost:3000` (或你配置的端口) 运行。
## Docker 部署
你可以使用 Docker 或 Docker Compose 快速部署。
### 方式一:使用 `docker build` 和 `docker run`
1. **克隆仓库**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **配置环境变量**:
* 复制 `.env.example` 文件为 `.env`:
```bash
cp .env.example .env
```
* 编辑 `.env` 文件,设置必要的变量(`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`)以及可选的 GitHub 同步变量(`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`)。**注意:`PORT` 变量在 Docker 部署中通常不需要在 `.env` 文件中设置,端口映射在 `docker run` 命令中完成。**
3. **构建 Docker 镜像**:
```bash
docker build -t gemhub .
```
4. **运行 Docker 容器**:
```bash
docker run -d --name gemhub \
-p 3000:3000 \
--env-file .env \
-v ./data:/usr/src/app/data \
gemhub
```
* `-d`: 后台运行容器。
* `--name gemhub`: 给容器命名。
* `-p 3000:3000`: 将主机的 3000 端口映射到容器的 3000 端口。
* `--env-file .env`: 从 `.env` 文件加载环境变量。
* `-v ./data:/usr/src/app/data`: 将本地的 `data` 目录挂载到容器内,用于持久化 SQLite 数据库。请确保本地存在 `data` 目录。
### 方式二:使用 `docker-compose` (推荐)
1. **克隆仓库**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **配置环境变量**:
* 复制 `.env.example` 文件为 `.env`:
```bash
cp .env.example .env
```
* 编辑 `.env` 文件,设置必要的变量(`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`)以及可选的 GitHub 同步变量(`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`)。
3. **启动服务**:
```bash
docker-compose up -d
```
Docker Compose 会自动构建镜像(如果需要)、创建并启动容器,并根据 `docker-compose.yml` 文件处理端口映射、环境变量和数据卷。
## 使用
### 管理面板
1. 访问你的 URL 的 `/login` 或 `/admin` 路径 (例如: `https://your-worker-name.your-subdomain.workers.dev/login`)。
2. 使用你设置的 `ADMIN_PASSWORD` 登录。
3. 在管理面板中,你可以:
* 添加和管理你的 Gemini API Key。
* 添加和管理用于访问此 Worker 代理的 API Key (Worker API Keys)。
* 为 Pro 和 Flash 系列模型设置全局配额。
* 查看每个 Gemini API Key 的使用统计。
* 配置支持的 Gemini 模型。
### API 代理
1. 将你的应用程序的 API 端点(原本配置为调用 OpenAI API 的地址)指向你部署的 Worker URL (例如: `https://your-worker-name.your-subdomain.workers.dev/v1`)。
2. 确保你的应用在发送请求时包含有效的身份验证信息。这通常通过在 `Authorization` 请求头中携带在管理面板配置的 "Worker API Key" 来完成:
```
Authorization: Bearer <your_worker_api_key>
```
3. 发送与 OpenAI Chat Completions API 兼容的请求。Worker 会将其转换为 Gemini API 请求,并返回格式化的响应。
## 配置概览
### 本地 Node.js / Docker / Hugging Face 部署
这些部署方式通过 `.env` 文件或 Secrets (Hugging Face) 配置环境变量。
* **核心环境变量 (必须)**:
* `ADMIN_PASSWORD`: 管理面板的登录密码。
* `SESSION_SECRET_KEY`: 用于保护用户会话安全的密钥 (建议使用长随机字符串)。
* **可选环境变量**:
* `PORT`: (仅本地 Node.js/Docker) 服务监听的端口,默认为 3000。Hugging Face 会自动处理端口。
* **GitHub 同步环境变量 (可选, Hugging Face 必需)**:
* `GITHUB_PROJECT`: 用于数据同步的**你自己的** GitHub 仓库路径 (格式: `username/repo-name`)。
* `GITHUB_PROJECT_PAT`: 具有 `repo` 权限的 GitHub Personal Access Token。
* `GITHUB_ENCRYPT_KEY`: 用于加密同步数据的密钥 (至少 32 位)。
================================================
FILE: doc/Deploy/Colab/Colab部署.md
================================================
# Colab 部署
此部署方式利用 Colab 的 Notebook 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。
注意,由于 Colab 的特性,此部署方式无法做到持续运行,每次运行后退出网页,实例最长运行90分钟。但此方法拥有以下显著优点:
* 无门槛,只要有 Google 帐号即可使用
* 部署简单,通过笔记本一键部署运行
* 最大化利用 GitHub 同步功能,实现数据的持久保存
1. **准备 GitHub 仓库和 PAT (必须)**:
* 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。
* 创建一个 GitHub Personal Access Token (PAT),并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。
* 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)
2. **保存 Colab 笔记本**:
* 点击[](https://colab.research.google.com/github/dreamhartley/JimiHub/blob/main/doc/Deploy/Colab/colab启动.ipynb)打开笔记本。
* 点击左上角的`复制到云端硬盘`,这将在您自己的 Google Drive 中创建一个副本。

* 页面将进行跳转,确认新的笔记本名称为`“colab启动.ipynb”的副本`,您可以自行修改名称。

3. **填写环境变量**:
* 在您自己的笔记本中,来到`配置环境变量`代码单元,在表单中根据说明填写您的配置信息。

* 在表单中填写必填项,包括管理员密码、GitHub 项目路径和 PAT。
* 如果需要,可以填写可选项,包括加密密钥。
* 填写的配置信息会自动保存,下次启动时无需再次填写。
4. **运行笔记本**:
* 确认所有配置无误后,点击`全部运行`按钮,即可启动面板。

* 在`启动面板`代码单元的输出中可以找到Cloudflare Tunnel的临时地址,点击即可访问后台UI。

5. **持续运行与结束**:
* Colab 实例在未操作的情况下最长运行90分钟,您可以关闭网页(不要结束运行),实例会继续运行。
* 如果需要保持运行,可以在保持网页开启的情况下最长运行12小时。
* 当需要结束运行时,点击右上角的`断开连接并删除运行时`。

* 填写的信息会自动保存,下次使用时访问[Google Colab](https://colab.research.google.com/)选择之前保存的笔记本,无须输入即可直接`全部运行`,每次连接时仅需要替换新的临时地址即可。
6. **在后台中进行设置**
* 在后台UI中进行配置 Api 连接,详细请参考[配置API连接教程](../../Usage/配置API连接.md)。
================================================
FILE: doc/Deploy/Colab/colab启动.ipynb
================================================
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"cellView": "form",
"id": "Y3W1uluay548"
},
"outputs": [],
"source": [
"# @title 🔨 1. 安装依赖\n",
"import subprocess\n",
"import sys\n",
"import os\n",
"\n",
"def run_cmd(cmd, cwd=None):\n",
" result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)\n",
" if result.returncode != 0:\n",
" print(f\"❌ 命令执行出错: {cmd}\")\n",
" print(result.stderr.decode('utf-8'))\n",
" sys.exit(1)\n",
"\n",
"print('⏳ 正在安装依赖...')\n",
"\n",
"try:\n",
" # 安装 Node.js 20\n",
" run_cmd('curl -fsSL https://deb.nodesource.com/setup_20.x | bash -')\n",
" run_cmd('apt-get install -y nodejs')\n",
"\n",
" # 克隆 GitHub 项目\n",
" run_cmd('git clone https://github.com/dreamhartley/JimiHub.git')\n",
"\n",
" # 进入项目目录\n",
" os.chdir('JimiHub')\n",
"\n",
" # 使用 npm 安装项目依赖\n",
" run_cmd('npm install')\n",
"\n",
" print('✅ 依赖安装完成!')\n",
"except Exception as e:\n",
" print('❌ 安装过程中出现了错误:', e)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"cellView": "form",
"id": "FxGtbacnzSZn"
},
"outputs": [],
"source": [
"# @title ⚙️ 2. 配置环境变量\n",
"%cd /content/JimiHub\n",
"# @markdown 请在下面的表单中填入您的配置信息。\n",
"# @markdown ---\n",
"\n",
"# @markdown ### **必填项**\n",
"# @markdown **1. 管理员密码 (`ADMIN_PASSWORD`)**\n",
"# @markdown 后台管理面板的登录密码。\n",
"admin_password = \"\" #@param {type:\"string\"}\n",
"\n",
"# @markdown **2. GitHub 项目 (`GITHUB_PROJECT`)**\n",
"# @markdown 您的 GitHub 项目路径,格式为 `用户名/仓库名`。\n",
"github_project = \"\" #@param {type:\"string\"}\n",
"\n",
"# @markdown **3. GitHub PAT (`GITHUB_PROJECT_PAT`)**\n",
"# @markdown 用于访问 GitHub 仓库的个人访问令牌 (Personal Access Token)。请确保它具有读写仓库内容的权限。\n",
"github_pat = \"\" #@param {type:\"string\"}\n",
"\n",
"# @markdown ---\n",
"# @markdown ### **可选项**\n",
"# @markdown **4. 加密密钥 (`GITHUB_ENCRYPT_KEY`)**\n",
"# @markdown (可选)一个32位长度的字符串,用于加密敏感数据,对于已加密的数据库必须使用和之前相同的密钥。如果留空,则不启用加密。\n",
"github_encrypt_key = \"\" #@param {type:\"string\"}\n",
"\n",
"\n",
"# --- 后续逻辑 (无需修改) ---\n",
"# 检查必填项是否已填写\n",
"if not all([admin_password, github_project, github_pat]):\n",
" print(\"❌ 错误:管理员密码、GitHub 项目和 PAT 均为必填项,请填写后再运行!\")\n",
"else:\n",
" # 使用从表单获取的变量创建 .env 文件内容\n",
" # .strip() 用于移除开头和结尾可能存在的空白\n",
" env_content = f\"\"\"\n",
"ADMIN_PASSWORD={admin_password}\n",
"GITHUB_PROJECT={github_project}\n",
"GITHUB_PROJECT_PAT={github_pat}\n",
"GITHUB_ENCRYPT_KEY={github_encrypt_key}\n",
"\"\"\".strip()\n",
"\n",
" # 将内容写入 .env 文件\n",
" with open(\".env\", \"w\") as f:\n",
" f.write(env_content)\n",
"\n",
" print(\"✅ .env 文件已成功创建并写入以下内容:\")\n",
" # 打印文件内容以供核对\n",
" !cat .env\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"cellView": "form",
"id": "T3Wzz133zWEx"
},
"outputs": [],
"source": [
"# @title 🚀 3. 启动面板\n",
"# 下载 Cloudflare Tunnel\n",
"!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O cloudflared\n",
"!chmod +x cloudflared\n",
"\n",
"import subprocess\n",
"import time\n",
"from IPython.display import display, HTML\n",
"import re\n",
"\n",
"def start_cloudflare_tunnel(log_path='tunnel.log', retries=5, wait_sec=5):\n",
" \"\"\"启动 Cloudflare Tunnel 并返回访问 URL,失败时自动重试。\"\"\"\n",
" tunnel_cmd = './cloudflared tunnel --url http://127.0.0.1:3000 > {} 2>&1 &'.format(log_path)\n",
" for attempt in range(1, retries + 1):\n",
" # 启动隧道\n",
" subprocess.Popen(tunnel_cmd, shell=True)\n",
" print(f\"第 {attempt} 次尝试启动 Cloudflare Tunnel...\")\n",
" time.sleep(wait_sec)\n",
" # 提取 URL\n",
" tunnel_url = None\n",
" try:\n",
" with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:\n",
" for line in f:\n",
" match = re.search(r'https://[a-zA-Z0-9-]+\\.trycloudflare\\.com', line)\n",
" if match:\n",
" tunnel_url = match.group(0)\n",
" break\n",
" except Exception as e:\n",
" print(f\"读取日志文件异常: {e}\")\n",
" if tunnel_url:\n",
" return tunnel_url\n",
" else:\n",
" print(\"未获取到 URL,准备重试...\")\n",
" # 杀掉可能遗留的 cloudflared 进程\n",
" subprocess.run(\"pkill -f cloudflared\", shell=True)\n",
" time.sleep(1)\n",
" return None\n",
"\n",
"tunnel_url = start_cloudflare_tunnel()\n",
"\n",
"if tunnel_url:\n",
" # 美化的 HTML 面板\n",
" panel_html = f\"\"\"\n",
" <div style=\"\n",
" border: 2px solid #2196F3;\n",
" border-radius: 14px;\n",
" background: linear-gradient(90deg,#f5f8fa 60%,#e3f2fd 100%);\n",
" padding: 24px 32px;\n",
" box-shadow: 0 2px 12px rgba(33,150,243,0.07);\n",
" margin: 18px 0;\n",
" font-family: 'Segoe UI',Arial,sans-serif;\n",
" font-size: 1.15em;\n",
" color: #222;\n",
" width: fit-content;\n",
" max-width: 90vw;\n",
" \">\n",
" <div style=\"font-size:1.3em;font-weight:bold;color:#1976D2;margin-bottom:8px;\">\n",
" ✅ Cloudflare Tunnel 已启动\n",
" </div>\n",
" <div>\n",
" <span style=\"font-weight:bold;\">访问地址:</span>\n",
" <a href=\"{tunnel_url}\" target=\"_blank\" style=\"color:#1976D2;text-decoration:underline;\">{tunnel_url}</a>\n",
" </div>\n",
" <div style=\"margin-top:6px;\">\n",
" <span style=\"font-weight:bold;\">API端点:</span>\n",
" <a href=\"{tunnel_url}/v1\" target=\"_blank\" style=\"color:#388E3C;text-decoration:underline;\">{tunnel_url}/v1</a>\n",
" </div>\n",
" </div>\n",
" \"\"\"\n",
" display(HTML(panel_html))\n",
"else:\n",
" error_html = \"\"\"\n",
" <div style=\"\n",
" border:2px solid #E53935;\n",
" border-radius:10px;\n",
" background:#FFF3F3;\n",
" padding:18px 24px;\n",
" color:#B71C1C;\n",
" font-weight:bold;\n",
" font-size:1.15em;\n",
" width: fit-content;\n",
" max-width: 90vw;\n",
" margin: 18px 0;\">\n",
" ❌ <span>未能获取 Cloudflare Tunnel 的临时 URL,请检查 <code>tunnel.log</code> 或稍后重试。</span>\n",
" </div>\n",
" \"\"\"\n",
" display(HTML(error_html))\n",
"\n",
"# 启动 Node.js 项目\n",
"print(\"\\n正在后台启动 Node.js 项目 (npm start)...\")\n",
"!npm start\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}
================================================
FILE: doc/Deploy/GitHub/GitHub同步.md
================================================
# GitHub 同步功能
gemini proxy panel 支持 GitHub 同步数据库功能,这个功能可以自动将数据库上传至您的私人 GitHub 仓库,并且在每次启动时自动下载最新的数据库,以保证数据的持久化存储。
**注意:** 在不支持持久化数据的平台(例如 Hugging Face Space)部署时,必须启用此功能。
## 创建数据库仓库
1. 首先,来到 GitHub 后台,点击右上角的头像打开右侧边栏。

2. 点击 `Your repositories` 打开 repo 界面。

3. 点击 `New` 新建一个仓库。

4. 在 `Repository name` 处填写一个自定义名称,并且选择 `Private` 创建一个私人仓库,最后点击 `Create repository` 完成创建。

5. 在 repo 界面找到刚刚创建的仓库,点击打开,在 URL 栏复制后缀 `用户名/仓库名`,这将作为环境变量 `GITHUB_PROJECT` 使用。

## 创建 PAT 密钥
1. 在 GitHub 后台,点击右上角的头像打开的右侧边栏中点击 `Settings`。

2. 在新打开的左侧边栏中的最下方点击 `Developer settings`。

3. 选择 `Personal access tokens` 中的 `Tokens (classic)` 选项,并在右侧的页面中选择 `Generate new token` 并点击 `Generate new token (classic)`。

4. 在新打开的页面中随意填写一个 `Note` 为 PAT 命名,并且在下方的权限选择区域中确保选中 `repo` 权限。可以设置让 PAT 不会过期。

5. 点击 `Generate token` 创建 PAT 密钥。

6. 在弹出的页面中记录 PAT 密钥,这将作为环境变量 `GITHUB_PROJECT_PAT` 使用。

## 启用 GitHub 同步功能
在环境变量中正确配置上面步骤中获取的 `GITHUB_PROJECT`、`GITHUB_PROJECT_PAT` 即可启用 GitHub 同步功能。
### 启用加密功能(可选)
数据库加密功能将使用 AES-256-CBC 加密算法实现对上传的数据库进行加密,在不泄露密钥的情况下基本可保证数据的安全不被破解。
在环境变量中添加变量 `GITHUB_ENCRYPT_KEY`,并配置一个最少 32 位的密钥以启用加密功能,建议使用[密码生成工具](https://1password.com/zh-cn/password-generator)生成。
> **注意:** 如果您希望后续重置部署,或使用其他的部署时保持现有的数据,请在本地妥善保存以上使用到的环境变量。
================================================
FILE: doc/Deploy/HuggingFace/Hugging Face Space部署-fork说明.md
================================================
# Hugging Face Space部署出现问题的应对
由于本项目在Hugging Face Space中部署较多,疑似被官方封禁,直接拉取项目镜像可能会导致部署失败或Space被停用,如果您在使用中遇到类似的问题,请参考以下的步骤Fork项目并创建自己的镜像:
1. **创建GitHub仓库Fork**
- 创建Fork [dreamhartley/JimiHub](https://github.com/dreamhartley/JimiHub/fork)
- 确保使用自定义的仓库名称,**不要包含** `JimiHub`或`hajimi`等关键词
2. **启用工作流**
- 在上方Actions标签栏,点击`I understand my workflows, go ahead and enable them`按钮

3. **运行Docker镜像构建工作流**
- 在左侧边栏选择`Docker Image CI for Hugging Face`
- 在右侧点击`Run workflow`

4. **获取镜像地址**
- 等待运行完成
- 在Fork的仓库页面,点击Packages中创建的镜像

- 复制镜像地址
5. **创建Huggingface Space**
- 在Huggingface Space创建Dockerfile
- 内容填写:`FROM ghcr.io/GitHub用户名/Fork仓库名:latest`
- 替代原本教程中提供的镜像地址,其余步骤不变
## 更新部署
如果通过fork仓库创建镜像后部署在Huggingface Space的用户需要更新:
1. 在fork的仓库页面点击`Sync fork`-->`Update branch`

2. 等待Actions自动运行完成后会创建新的镜像
3. 在Space页面点击`Settings`-->`Factory rebuild`即可
================================================
FILE: doc/Deploy/HuggingFace/Hugging Face Space部署.md
================================================
# Hugging Face Space 部署
此部署方式利用 Hugging Face Space 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。
> 本项目被Hugging Face标记,如果您在使用中存在问题,例如卡Building或者停止运行,请参考[Hugging Face部署出现问题的应对](Hugging%20Face%20Space部署-fork说明.md)
1. **准备 GitHub 仓库和 PAT**:
* 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。
* 创建一个 GitHub Personal Access Token (PAT),并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。
* 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)
2. **创建 Hugging Face Space**:
* 访问 Hugging Face Space 页面,点击新建一个 Space。\

* 在`Space name`处随意填写一个自定义名称,请不要包含`gemini proxy panel`或`hajimi`等关键词\

* 选择 "Docker" 作为 Space SDK。\

* 确保选择的免费的配置类型,并且设置空间为公开。最后点击`Create Space`。\

3. **配置 Space Secrets**:
* 进入你创建的 Space 的 "Settings" -> "Repository secrets"。\


* 添加以下 Secrets:
* `ADMIN_PASSWORD`: 设置管理面板的登录密码。\

* `GITHUB_PROJECT`: 填入你**自己的** GitHub 仓库路径,格式为 `your-username/your-repo-name`。\

* `GITHUB_PROJECT_PAT`: 填入你创建的 GitHub PAT。\

* Secrets配置完成。

4. **创建 Dockerfile**:
> 本项目被Hugging Face标记,如果您在使用中存在问题,例如卡Building或者停止运行,请参考[Hugging Face部署出现问题的应对](Hugging%20Face%20Space部署-fork说明.md)
* 在 Hugging Face Space 的 "Files" 标签页中,点击 "Add file" -> "Create new file"。\

* 将文件名设置为 `Dockerfile`。
* 将以下内容粘贴到文件中:
```dockerfile
FROM dreamhartley705/jimihub:huggingface
```
或Fork仓库创建的镜像地址
```dockerfile
FROM ghcr.io/GitHub用户名/Fork仓库名:latest
```
* 点击 "Commit new file"。

5. **启动和访问**:
* Hugging Face Space 会自动使用此 `Dockerfile` 构建并启动应用。
* 应用启动后,会使用你配置的 Secrets 连接到你的 GitHub 仓库进行数据同步。
* 点击查看 Log 会自动显示后台UI地址。\

6. **在后台中进行设置**
* 在后台UI中进行配置 Api 连接,详细请参考[配置API连接教程](../../Usage/配置API连接.md)
================================================
FILE: doc/Deploy/Koyeb/Koyeb部署.md
================================================
# Koyeb 部署
此部署方式利用 Koyeb 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。
1. **准备 GitHub 仓库和 PAT**(不可跳过):
* 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。
* 创建一个 GitHub Personal Access Token (PAT),并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。
* 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)
2. **在 Koyeb 创建并部署容器**:
* 访问 [Koyeb注册页面](https://app.koyeb.com/auth/signup)。
* 选择一个方式创建账户,可以使用GitHub账号,或邮箱进行注册。

* 注册并验证账号后,在页面中输入一个自定义的组织名称,后续选项可以随意选择或跳过。

* 进入主界面后,在右侧选择Docker容器部署。

* 在Image选项填写`dreamhartley705/jimihub:latest`,点击下一步。

* 在配置选择界面,选择`CPU Eco`,并选择一个免费的容器类型,点击下一步。

* 来到详细配置页面,在`Edit variables and files`中配置环境变量,请确保填写`ADMIN_PASSWORD`,`GITHUB_PROJECT`,`GITHUB_PROJECT_PAT`这三个变量。`GITHUB_ENCRYPT_KEY`为可选的数据库加密选项,如果需要请自行填写。

* 选择`Exposed ports`选项,将端口修改为`3000`。

* 选择`Service name`选项,设置一个自定义的容器名称,请不要使用默认的名称。

* 配置完成后,点击`Deploy`即可创建容器。

* 在新页面中,等待容器创建完成,上方的`Public URL`为访问地址。

3. **在后台中进行设置**
* 在后台UI中进行配置 Api 连接,详细请参考[配置API连接教程](../../Usage/配置API连接.md)。
4. **Koyeb 容器保活(可选)**
* Koyeb 容器将在未使用时自动关闭,并且再次请求时自动启动,这会导致容器重启后第一次的请求时间大幅度延迟,如果您希望让容器保持运行,可以参考[配置Uptimerrobot](../Uptimerobot/配置Uptimerrobot.md)中的内容。
================================================
FILE: doc/Deploy/Local/本地部署.md
================================================
# 本地部署指南
## Node.js 部署
此方式适合本地开发和测试。
1. **克隆仓库**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **安装依赖**:
```bash
npm install
```
3. **配置环境变量**:
* 复制 `.env.example` 文件为 `.env`:
```bash
cp .env.example .env
```
* 编辑 `.env` 文件,至少设置以下变量:
* `ADMIN_PASSWORD`: 设置管理面板的登录密码。
* `SESSION_SECRET_KEY`: 设置一个长且随机的字符串用于会话安全(例如,使用 `openssl rand -base64 32` 生成)。
* `PORT` (可选): 默认是 3000,可以根据需要修改。
* **(可选) 配置 GitHub 同步**: 如果需要将数据同步到 GitHub 仓库,请配置以下变量:
* `GITHUB_PROJECT`: 你的 GitHub 仓库路径,格式为 `username/repo-name`。**注意:这是你自己的仓库,用于存储数据备份,并非本项目仓库。**
* `GITHUB_PROJECT_PAT`: 你的 GitHub Personal Access Token,需要 `repo` 权限。
* `GITHUB_ENCRYPT_KEY`: 用于加密同步数据的密钥,必须是 32 位或更长的字符串。
4. **启动服务**:
```bash
npm start
```
服务将在 `http://localhost:3000` (或你配置的端口) 运行。
## Docker 部署
你可以使用 Docker 或 Docker Compose 快速部署。
### 方式一:使用 `docker build` 和 `docker run`
1. **克隆仓库**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **配置环境变量**:
* 复制 `.env.example` 文件为 `.env`:
```bash
cp .env.example .env
```
* 编辑 `.env` 文件,设置必要的变量(`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`)以及可选的 GitHub 同步变量(`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`)。**注意:`PORT` 变量在 Docker 部署中通常不需要在 `.env` 文件中设置,端口映射在 `docker run` 命令中完成。**
3. **构建 Docker 镜像**:
```bash
docker build -t jimihub .
```
4. **运行 Docker 容器**:
```bash
docker run -d --name jimihub \
-p 3000:3000 \
--env-file .env \
-v ./data:/usr/src/app/data \
jimihub
```
* `-d`: 后台运行容器。
* `--name gemini-proxy-panel`: 给容器命名。
* `-p 3000:3000`: 将主机的 3000 端口映射到容器的 3000 端口。
* `--env-file .env`: 从 `.env` 文件加载环境变量。
* `-v ./data:/usr/src/app/data`: 将本地的 `data` 目录挂载到容器内,用于持久化 SQLite 数据库。请确保本地存在 `data` 目录。
### 方式二:使用 `docker-compose` (推荐)
1. **克隆仓库**:
```bash
git clone https://github.com/dreamhartley/JimiHub.git
cd JimiHub
```
2. **配置环境变量**:
* 复制 `.env.example` 文件为 `.env`:
```bash
cp .env.example .env
```
* 编辑 `.env` 文件,设置必要的变量(`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`)以及可选的 GitHub 同步变量(`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`)。
3. **启动服务**:
```bash
docker-compose up -d
```
Docker Compose 会自动构建镜像(如果需要)、创建并启动容器,并根据 `docker-compose.yml` 文件处理端口映射、环境变量和数据卷。
================================================
FILE: doc/Deploy/Render/Render部署.md
================================================
# Render 部署
此部署方式利用 Render 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。
1. **准备 GitHub 仓库和 PAT**:
* 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。
* 创建一个 GitHub Personal Access Token (PAT),并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。
* 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)
2. **在 Render 创建并部署容器**
* 访问 [Render](https://render.com/),点击右上角的`Get Started`按钮。

* 选择一个方式创建账户,可以使用邮箱或第三方账号,例如谷歌账号登陆。

* 在开始界面选择创建一个`Web Services`。

* 选择`Existing Image`,在`Image URL`中填写项目的镜像。
```
dreamhartley705/jimihub:latest
```

* 点击`Connect`,在`Name`中填写一个自定义容器的名称,这个名称将会是访问URL的一部分,**不建议使用默认**,请自行填写。在下方选择容器的区域,推荐使用美国(US)区域。

* 选择免费容器类型。

* 配置环境变量,请确保填写`ADMIN_PASSWORD`,`GITHUB_PROJECT`,`GITHUB_PROJECT_PAT`这三个变量。`GITHUB_ENCRYPT_KEY`为可选的数据库加密选项,如果需要请自行填写。
<small>配置完成的示例</small>
* 点击`Deploy web service`即可创建容器。
* 在新页面中出现`Your service is live 🎉`表示部署成功,点击Log上方的URL即可访问容器地址。<br>
注意,如果在创建容器时未修改镜像名称,URL将会为默认名称后添加随机字符,建议修改为自定义名称。

* 后台地址为`https://xxx.onrender.com/admin`
3. **在后台中进行设置**
* 在后台UI中进行配置 Api 连接,详细请参考[配置API连接教程](../../Usage/配置API连接.md)。
4. **Render 容器保活(可选)**
* Render 容器将在未使用的15分钟后自动关闭,并且再次请求时自动启动,这会导致容器重启后第一次的请求时间大幅度延迟,如果您希望让容器保持运行,可以参考[配置Uptimerrobot](../Uptimerobot/配置Uptimerrobot.md)中的内容。
> ⚠️ 注意: Render 免费容器每月有750小时的免费额度,这意味着您在同一个 Render 账号中仅可部署一个持续运行的容器。超出免费额度的使用可能会被关停容器或要求付费。
================================================
FILE: doc/Deploy/Uptimerobot/配置Uptimerrobot.md
================================================
# 使用 Uptimerrobot 保活容器
1. **注册 Uptimerrobot**
* 访问 [Uptimerrobot](https://uptimerobot.com/),选择登陆或创建一个账号,创建账号时使用邮箱进行创建。


2. **登陆并配置 Uptimerrobot**
* 注册并验证邮箱后可以登陆到 Uptimerrobot,如果是第一次登陆会直接提示配置监控。
* 在`Create your first monitor`中的`URL to monitor`处填写您的容器地址(主页即可)。点击`Create monitor`。

* 后续的步骤可以点击跳过(Skip),最后点击`Nah, get me to dashboard already!`完成创建。

## 您已经成功配置 Uptimerrobot
免费套餐下 Uptimerrobot 会每 5 分钟访问一次配置的URL,您的容器已经实现了24小时在线运行。
================================================
FILE: doc/Usage/KEEPALIVE.md
================================================
# 功能介绍: KEEPALIVE模式
## Docker/Hugging Face Space/Node.js
在网页设置中开启`KEEPALIVE`开关,可以启用KEEPALIVE响应模式。
启用KEEPALIVE模式后,使用流式请求到脚本,脚本会持续向客户端发送心跳响应以保持客户端的持续连接,防止客户端发生异常断开的情况。
注意,当使用`KEEPALIVE`功能时,请确保使用的请求密钥(Worker Api Key)的安全设定为关闭(Disabled),且在请求时使用流式响应。
`KEEPALIVE`功能不会影响到非流式响应或启用了安全设定的流式响应。
================================================
FILE: doc/Usage/Vertex/Vertex代理配置.md
================================================
# Vertex 代理配置
通过配置 `VERTEX` 环境变量,启用 Vertex 代理功能,添加使用 Vertex AI 平台的 Gemini 模型。
---
## 启用 Generative Language API
* 如果您的项目还未启用 Generative Language API,需要先设置启用,已经启用可以跳过。
* 在左侧边栏中选择`API 和服务`中的`已启用的API 和服务`。

* 在打开的页面中点击`+ 启用API 和服务`。

* 在搜索栏中输入搜索`Generative Language API`。

* 选择启用 Generative Language API

---
## 创建服务账号
* 在 [Google Cloud Platform](https://cloud.google.com/) 中登陆并激活账户。
* 在左侧边栏中选择`IAM和管理`并点击`服务账号`。

* 点击`创建服务账号`。

* 在`服务账号详情`中随意填写一个`服务账号ID`。选择`创建并继续`。

* 在角色中选择`Vertex Al Service Agent`。


* 点击`完成`创建服务账号。

---
## 创建API凭证
* 点击服务账号右侧的三点图标并选择`管理密钥`。


* 在页面中点击`添加键`并选择`创建新密钥`。

* 选择JSON格式并点击创建。

---
## 添加 API 凭证到环境变量
在变量文件中添加一个`VERTEX`变量,并将下载的JSON格式变量完整地粘贴到变量中。\
参考格式
```
VERTEX={
"type": "service_account",
"project_id": "XXXXXXXXXXX",
"private_key_id": "XXXXXXXXXXX",
"private_key": "-----BEGIN PRIVATE KEY-----\ABCD\n-----END PRIVATE KEY-----\n",
"client_email": "XXXXXXXXXXX.iam.gserviceaccount.com",
"client_id": "XXXXXXXXXXX",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/
"universe_domain": "googleapis.com"
}
```
================================================
FILE: doc/Usage/在客户端中使用.md
================================================
================================================
FILE: doc/Usage/配置API连接.md
================================================
# 在管理UI中配置API连接
## 添加 AI Studio 密钥
* 在 `Gemini API Keys` 界面中的 `API Key Value` 中填写并添加 Gemini Api 密钥,可选择批量进行添加,批量添加时使用英文逗号 `,` 进行分隔,例如 `key1,key2,kye3`。在使用批量添加功能时请不要添加自定义名称功能。自定义名称可留空。

## 添加一个模型
* 在 `Managed Models` 界面中添加想要使用的模型,当正确添了 Gemini Api 密钥后,刷新页面,脚本将自动获取当前可用的模型列表。

* 您可以选择在类别中切换模型的类别以匹配不同的额度,当然,大多数情况下脚本可用做到自动匹配。

* 当使用 Custom 类别的模型时,您可用输入一个基于模型的额度,Custom 类别中的每个模型的额度都会被独立管理。设置 `0` 或 `none` 表示无限额度。

## 添加一个请求 Api 密钥
除了添加的 Gemini Api 外,您还需要添加用于请求到当前脚本的 Api 密钥。
* 在 `Worker API Keys` 界面中添加密钥,点击 `Generate Random Key` 可用使用随机生成的密钥,或者您可以自行填写密钥,注意密钥中不要包含特殊字符。密钥的名称可自定义或留空。

* 您可用管理或分发多个 Api 密钥,该密钥为客户端请求时使用的密钥。
## 测试 Gemini Api 密钥的可用性
当正确配置 Gemini Api 密钥以及添加模型后,您可用点击密钥的显示卡片展开密钥使用情况,点击 `Test` 并选择一个测试模型即可快速测试密钥的可用性。

## 管理安全设置
使用本项目可用管理请求密钥在使用时的安全设定,当安全设定为 `Disabled` 时,将在转发 Gemini Api 请求时关闭默认的安全审查机制。您可以给不同的请求密钥设置不同的安全设定以适配不同的使用环境。当使用 `KEEPALIVE` 机制进行伪流式请求时需要关闭安全设定才会生效。

## 管理配额
在 `Managed Models` 界面点击 `Set Category Quotas`,可以设定 Pro 与 Flash 类别模型不同的每日额度。默认额度为 Pro 每天 50 次,Flash 每天 1500 次,请根据实际情况进行调整。


## 添加 Vertex 配置
本项目支持连接到Vertex AI平台的Gemini模型,Vertex配置的申请操作请参考:[Vertex代理配置](Vertex/Vertex代理配置.md)
切换到Vertex标签后即可在网页中配置Vertex Api,支持两种方式连接到Vertex Api
#### 服务账号 (JSON)
- 选择`服务账号 (JSON)`并在输入框填写完整的 Google Cloud Service Account JSON 配置,点击`保存 Vertex 配置`即可保存服务账号信息。

#### 快捷模式 (API Key)
- 选择`快捷模式 (API Key)`并在输入框填写Express API Key,点击`保存 Vertex 配置`即可保存快捷模式密钥。

#### 配置完成
保存Vertex配置后,网页将会显示使用的Vertex配置信息,并且显示为`已启用`,此时连接到api端点即可使用带有`[v]`前缀的模型连接到Vertex Api。
再次添加配置会覆盖当前的Vertex配置信息,点击`清除配置`将会删除保存的Vertex配置信息并停用Vertex代理功能。

## 其他系统设置
点击网页右上角的设置按钮,即可调整其他的一些系统功能

- **KEEPALIVE**:启用后可以使用保持连接方式处理请求,也被成为假流式,具体使用请参考[KEEPALIVE模式介绍](KEEPALIVE.md)
- **联网搜索**:默认关闭,启用后将会在模型列表中添加`-search`后缀的模型,使用时将会允许模型通过互联网搜索信息,暂时仅对AI Studio的模型生效。
- **最大重试次数**:请求失败后将会自动使用下一个有效的gemini api密钥重试请求,在此处修改允许重试的最大次数。
================================================
FILE: doc/项目介绍.md
================================================
# JimiHub
## 项目简介
JimiHub 是一款将 Gemini API 请求转换为 OpenAI API 格式的代理工具,支持多个项目轮询、密钥管理分发等功能。
## 主要功能
- **简洁的管理界面**
- **支持流式/非流式响应,图片/文件上传,工具函数调用**
- **多个 Gemini API Key 轮询**
- **快速测试 Gemini API Key 可用性**
- **简单便捷的模型管理**
- **管理分发多个请求密钥**
- **灵活且直观的额度管理及负载均衡**
- **每日自动刷新额度**
- **管理请求中的安全设置**
- **可设置 GitHub 自动同步数据**
- **开发中功能可能有变化**
## 开始使用
本项目支持多种部署方式,任何支持 Docker 容器以及 Node.js 的环境均可使用:
- [Koyeb 部署](Deploy/Koyeb/Koyeb部署.md)<small>(免费,试用7天后需要绑定银行卡,ip不干净可能导致无法使用)</small>
- [Colab 部署](Deploy/Colab/Colab部署.md) <small>(免费,门槛低,但无法持久运行,每次使用可快速部署)</small>
- [Hugging Face Space 部署](Deploy/HuggingFace/Hugging%20Face%20Space部署.md) <small>(免费,目前抱脸封号严重,请自行尝试或使用其他部署方式)</small>
- [Render 部署](Deploy/Render/Render部署.md)<small>(免费,部署时需要绑定银行卡,使用干净的ip地址可以跳过绑卡)</small>
- [本地/VPS 部署](Deploy/Local/本地部署.md)
- **VPS 一键部署脚本**\
脚本将自动配置环境,并安装项目,根据提示设置管理密码即可。\
适用于 Debian/Ubuntu/CentOS 系统:
```bash
curl -fsSL https://raw.githubusercontent.com/dreamhartley/JimiHub/refs/heads/main/get-jimihub.sh -o get-jimihub.sh && sudo bash get-jimihub.sh
```
## 部署后配置
- [管理界面的使用](Usage/配置API连接.md)
## 可选功能
- [KEEPALIVE](Usage/KEEPALIVE.md)
- [Vertex 代理配置](Usage/Vertex/Vertex代理配置.md)
================================================
FILE: docker-compose.yml
================================================
version: '3.8'
services:
app:
image: dreamhartley705/jimihub:latest
container_name: jimihub
ports:
- "3000:3000"
env_file:
- .env
volumes:
- ./data:/app/data
================================================
FILE: get-jimihub.sh
================================================
#!/bin/bash
# JimiHub管理脚本
# 版本: 1.0
# 定义颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 定义路径
INSTALL_DIR="/opt/jimihub"
SCRIPT_PATH="/usr/local/bin/jimihub"
# 显示ASCII图案
show_ascii() {
echo -e "${BLUE}"
cat << 'EOF'
██╗██╗███╗ ███╗██╗██╗ ██╗██╗ ██╗██████╗
██║██║████╗ ████║██║██║ ██║██║ ██║██╔══██╗
██║██║██╔████╔██║██║███████║██║ ██║██████╔╝
██ ██║██║██║╚██╔╝██║██║██╔══██║██║ ██║██╔══██╗
╚█████╔╝██║██║ ╚═╝ ██║██║██║ ██║╚██████╔╝██████╔╝
╚════╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝
EOF
echo -e "${NC}"
}
# 显示欢迎信息
show_welcome() {
clear
show_ascii
echo -e "${GREEN}欢迎使用JimiHub管理脚本${NC}"
echo -e "${YELLOW}================================================${NC}"
echo ""
}
# 检查是否为root用户
check_root() {
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}错误:此脚本需要root权限运行${NC}"
exit 1
fi
}
# 检查安装状态
check_install_status() {
if [ -d "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/docker-compose.yml" ]; then
echo -e "${GREEN}✓ JimiHub已安装${NC}"
INSTALLED=true
else
echo -e "${RED}✗ JimiHub未安装${NC}"
INSTALLED=false
fi
}
# 检查容器运行状态
check_container_status() {
if [ "$INSTALLED" = true ]; then
if docker ps | grep -q "jimihub"; then
echo -e "${GREEN}✓ 容器正在运行${NC}"
RUNNING=true
show_access_url
else
echo -e "${YELLOW}✗ 容器未运行${NC}"
RUNNING=false
fi
fi
}
# 显示访问URL
show_access_url() {
if [ -f "$INSTALL_DIR/.env" ]; then
PORT=$(grep "PORT=" "$INSTALL_DIR/.env" | cut -d'=' -f2)
if [ -z "$PORT" ]; then
PORT=3000
fi
# 检查是否为本地访问
if grep -q "127.0.0.1" "$INSTALL_DIR/docker-compose.yml"; then
echo -e "${BLUE}访问地址: http://127.0.0.1:$PORT${NC}"
else
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo -e "${BLUE}访问地址: http://$LOCAL_IP:$PORT${NC}"
fi
fi
}
# 获取外部IP
get_external_ip() {
external_ip=$(curl -s https://ipv4.icanhazip.com/ || curl -s https://api.ipify.org)
if [ -z "$external_ip" ]; then
external_ip=$(hostname -I | awk '{print $1}')
fi
echo "$external_ip"
}
# 安装依赖
install_dependencies() {
echo -e "${YELLOW}正在安装依赖...${NC}"
# 更新包列表
if command -v apt-get &> /dev/null; then
apt-get update
apt-get install -y curl wget git
elif command -v yum &> /dev/null; then
yum install -y curl wget git
elif command -v dnf &> /dev/null; then
dnf install -y curl wget git
else
echo -e "${RED}错误:无法识别的包管理器${NC}"
exit 1
fi
}
# 检查Docker Compose命令
check_docker_compose() {
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
return 1
fi
return 0
}
# 安装Docker
install_docker() {
echo -e "${YELLOW}正在检查Docker安装状态...${NC}"
if ! command -v docker &> /dev/null; then
echo -e "${YELLOW}Docker未安装,正在安装...${NC}"
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl start docker
systemctl enable docker
rm get-docker.sh
echo -e "${GREEN}Docker安装完成${NC}"
else
echo -e "${GREEN}Docker已安装${NC}"
fi
# 检查Docker Compose
if ! check_docker_compose; then
echo -e "${YELLOW}Docker Compose未安装,正在安装...${NC}"
# 尝试安装Docker Compose插件
if command -v apt-get &> /dev/null; then
apt-get update
apt-get install -y docker-compose-plugin
elif command -v yum &> /dev/null; then
yum install -y docker-compose-plugin
elif command -v dnf &> /dev/null; then
dnf install -y docker-compose-plugin
fi
# 如果插件安装失败,则安装独立版本
if ! check_docker_compose; then
echo -e "${YELLOW}正在安装独立版本的Docker Compose...${NC}"
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
fi
echo -e "${GREEN}Docker Compose安装完成${NC}"
else
echo -e "${GREEN}Docker Compose已安装${NC}"
fi
}
# 创建.env文件
create_env_file() {
echo -e "${YELLOW}正在创建配置文件...${NC}"
echo -n "请输入管理密码: "
read ADMIN_PASSWORD
echo ""
cat > "$INSTALL_DIR/.env" << EOF
ADMIN_PASSWORD=$ADMIN_PASSWORD
EOF
echo -e "使用默认端口3000? (y/n) [默认: y]: "
read -r use_default_port
use_default_port=${use_default_port:-y}
if [ "$use_default_port" = "n" ] || [ "$use_default_port" = "N" ]; then
echo -n "请输入自定义端口: "
read -r custom_port
echo "PORT=$custom_port" >> "$INSTALL_DIR/.env"
PORT=$custom_port
else
PORT=3000
fi
}
# 创建docker-compose.yml文件
create_docker_compose() {
echo -e "${YELLOW}正在创建Docker Compose文件...${NC}"
echo -e "允许外部访问? (y/n) [默认: y]: "
read -r allow_external
allow_external=${allow_external:-y}
if [ "$allow_external" = "n" ] || [ "$allow_external" = "N" ]; then
PORTS_MAPPING="127.0.0.1:$PORT:$PORT"
else
PORTS_MAPPING="$PORT:$PORT"
fi
cat > "$INSTALL_DIR/docker-compose.yml" << EOF
version: '3.8'
services:
app:
image: dreamhartley705/jimihub:latest
container_name: jimihub
ports:
- "$PORTS_MAPPING"
env_file:
- .env
volumes:
- ./data:/app/data
restart: unless-stopped
EOF
}
# 安装JimiHub
install_gemini_proxy_panel() {
echo -e "${YELLOW}开始安装JimiHub...${NC}"
# 安装依赖
install_dependencies
# 安装Docker
install_docker
# 检查Docker Compose命令
if ! check_docker_compose; then
echo -e "${RED}错误:Docker Compose未正确安装${NC}"
return 1
fi
# 创建安装目录
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
# 创建.env文件
create_env_file
# 创建docker-compose.yml文件
create_docker_compose
# 启动容器
echo -e "${YELLOW}正在启动容器...${NC}"
$DOCKER_COMPOSE_CMD up -d
# 等待容器启动
sleep 5
if docker ps | grep -q "jimihub"; then
echo -e "${GREEN}✓ 安装完成!${NC}"
echo ""
# 显示访问地址
if [ "$allow_external" = "n" ] || [ "$allow_external" = "N" ]; then
echo -e "${BLUE}本地访问地址: http://127.0.0.1:$PORT${NC}"
else
external_ip=$(get_external_ip)
echo -e "${BLUE}访问地址: http://$external_ip:$PORT${NC}"
fi
else
echo -e "${RED}✗ 安装失败,请检查错误信息${NC}"
echo -e "${YELLOW}容器日志:${NC}"
$DOCKER_COMPOSE_CMD logs
fi
}
# 启动容器
start_container() {
if [ "$INSTALLED" = true ]; then
echo -e "${YELLOW}正在启动容器...${NC}"
cd "$INSTALL_DIR"
if check_docker_compose; then
$DOCKER_COMPOSE_CMD up -d
sleep 3
if docker ps | grep -q "jimihub"; then
echo -e "${GREEN}✓ 容器启动成功${NC}"
else
echo -e "${RED}✗ 容器启动失败${NC}"
fi
else
echo -e "${RED}Docker Compose未找到${NC}"
fi
else
echo -e "${RED}请先安装JimiHub${NC}"
fi
}
# 停止容器
stop_container() {
if [ "$INSTALLED" = true ]; then
echo -e "${YELLOW}正在停止容器...${NC}"
cd "$INSTALL_DIR"
if check_docker_compose; then
$DOCKER_COMPOSE_CMD down
echo -e "${GREEN}✓ 容器已停止${NC}"
else
echo -e "${RED}Docker Compose未找到${NC}"
fi
else
echo -e "${RED}请先安装JimiHub${NC}"
fi
}
# 重启容器
restart_container() {
if [ "$INSTALLED" = true ]; then
echo -e "${YELLOW}正在重启容器...${NC}"
cd "$INSTALL_DIR"
if check_docker_compose; then
$DOCKER_COMPOSE_CMD restart
sleep 3
if docker ps | grep -q "jimihub"; then
echo -e "${GREEN}✓ 容器重启成功${NC}"
else
echo -e "${RED}✗ 容器重启失败${NC}"
fi
else
echo -e "${RED}Docker Compose未找到${NC}"
fi
else
echo -e "${RED}请先安装JimiHub${NC}"
fi
}
# 更新JimiHub
update_jimihub() {
if [ "$INSTALLED" = false ]; then
echo -e "${RED}JimiHub未安装,无法更新${NC}"
return
fi
echo -e "${YELLOW}开始更新JimiHub...${NC}"
cd "$INSTALL_DIR"
if ! check_docker_compose; then
echo -e "${RED}Docker Compose未找到${NC}"
return
fi
echo "正在拉取最新的Docker镜像..."
if ! docker pull dreamhartley705/jimihub:latest; then
echo -e "${RED}✗ 拉取最新镜像失败,请检查网络或镜像名称。${NC}"
return
fi
echo "正在停止并使用新镜像重新创建容器..."
$DOCKER_COMPOSE_CMD up -d --force-recreate
sleep 5
if docker ps | grep -q "jimihub"; then
echo -e "${GREEN}✓ 更新完成!${NC}"
else
echo -e "${RED}✗ 更新失败,请检查错误信息${NC}"
echo -e "${YELLOW}容器日志:${NC}"
$DOCKER_COMPOSE_CMD logs
fi
}
# 卸载JimiHub
uninstall_jimihub() {
if [ "$INSTALLED" = false ]; then
echo -e "${RED}JimiHub未安装,无需卸载${NC}"
return
fi
echo -e "${YELLOW}警告:这将停止并删除JimiHub容器。${NC}"
echo -n "是否保留数据库文件? (y/n) [默认: y]: "
read -r keep_data
keep_data=${keep_data:-y}
echo -e "${YELLOW}开始卸载JimiHub...${NC}"
if [ -d "$INSTALL_DIR" ]; then
cd "$INSTALL_DIR"
if check_docker_compose; then
echo "正在停止并删除JimiHub容器..."
$DOCKER_COMPOSE_CMD down
fi
cd ..
fi
if [[ "$keep_data" == "n" || "$keep_data" == "N" ]]; then
echo "正在删除安装目录(包括数据)..."
rm -rf "$INSTALL_DIR"
echo -e "${GREEN}✓ JimiHub及其数据已完全删除。${NC}"
else
echo -e "${GREEN}✓ JimiHub容器已停止。本地文件(包括数据)已保留在 $INSTALL_DIR ${NC}"
fi
echo -n "是否要卸载Docker? (y/n) [默认: n]: "
read -r uninstall_docker
uninstall_docker=${uninstall_docker:-n}
if [[ "$uninstall_docker" == "y" || "$uninstall_docker" == "Y" ]]; then
# 检查是否还有其他Docker容器
if [ -n "$(docker ps -aq)" ]; then
echo -e "${YELLOW}警告:检测到系统上存在其他Docker容器。为避免影响其他应用,Docker未被卸载。${NC}"
else
echo "正在卸载Docker..."
if command -v apt-get &> /dev/null; then
apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
apt-get autoremove -y --purge
rm -rf /var/lib/docker /etc/docker
elif command -v yum &> /dev/null; then
yum remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
rm -rf /var/lib/docker /var/lib/containerd
elif command -v dnf &> /dev/null; then
dnf remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
rm -rf /var/lib/docker /var/lib/containerd
fi
echo -e "${GREEN}✓ Docker卸载完成。${NC}"
fi
fi
unregister_command
echo -e "${GREEN}✓ JimiHub卸载完成!感谢使用!${NC}"
exit 0
}
# 注册系统命令
register_command() {
if [ ! -f "$SCRIPT_PATH" ]; then
cp "$0" "$SCRIPT_PATH"
chmod +x "$SCRIPT_PATH"
echo -e "${GREEN}✓ 已注册系统命令 'jimihub'${NC}"
fi
}
# 注销系统命令
unregister_command() {
if [ -f "$SCRIPT_PATH" ]; then
rm -f "$SCRIPT_PATH"
echo -e "${GREEN}✓ 已注销系统命令 'jimihub'${NC}"
fi
}
# 显示菜单
show_menu() {
echo ""
echo -e "${YELLOW}请选择操作:${NC}"
echo "1. 安装 JimiHub"
echo "2. 启动 JimiHub 容器"
echo "3. 停止 JimiHub 容器"
echo "4. 重启 JimiHub 容器"
echo "5. 更新 JimiHub"
echo "6. 卸载 JimiHub"
echo "0. 退出"
echo ""
echo -n "请输入选项 [0-6]: "
}
# 主函数
main() {
check_root
# 如果是卸载命令,直接执行
if [[ "$1" == "uninstall" ]]; then
check_install_status
uninstall_jimihub
exit 0
fi
register_command
while true; do
show_welcome
check_install_status
check_container_status
show_menu
read -r choice
case $choice in
1)
install_gemini_proxy_panel
echo ""
echo -n "按回车键继续..."
read -r
;;
2)
start_container
echo ""
echo -n "按回车键继续..."
read -r
;;
3)
stop_container
echo ""
echo -n "按回车键继续..."
read -r
;;
4)
restart_container
echo ""
echo -n "按回车键继续..."
read -r
;;
5)
update_jimihub
echo ""
echo -n "按回车键继续..."
read -r
;;
6)
uninstall_jimihub
;;
0)
echo -e "${GREEN}感谢使用!${NC}"
echo -e "${BLUE}提示:下次可以直接使用 'jimihub' 命令进入管理脚本${NC}"
exit 0
;;
*)
echo -e "${RED}无效选项,请重新选择${NC}"
sleep 2
;;
esac
done
}
# 运行主函数
main "$@"
================================================
FILE: package.json
================================================
{
"name": "jimihub",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/index.js"
},
"keywords": [],
"author": "dream_hartley",
"license": "Apache-2.0",
"description": "A Gemini to OpenAI format proxy supporting multi-apikey rotation. Supports multiple deployment methods.",
"dependencies": {
"@google-cloud/vertexai": "^0.5.0",
"@google/genai": "^0.12.0",
"@octokit/rest": "^20.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.18.2",
"mime-types": "^2.1.35",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"socks-proxy-agent": "^8.0.3",
"sqlite3": "^5.1.7",
"uuid": "^9.0.1"
}
}
================================================
FILE: public/admin/index.html
================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JimiHub</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="style.css">
<script src="../i18n.js"></script>
<style>
/* Hide dropdown arrow for datalist inputs */
input::-webkit-calendar-picker-indicator {
display: none !important;
}
/* Simple loading spinner */
.loader {
border: 4px solid #f3f3f3; /* Light grey */
border-top: 4px solid #3498db; /* Blue */
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
display: inline-block; /* Initially hidden */
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Custom scrollbar styles for keys grid */
.keys-grid::-webkit-scrollbar {
width: 8px;
}
.keys-grid::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.keys-grid::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background-color 0.2s;
}
.keys-grid::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* For Firefox */
.keys-grid {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
/* Light mode scrollbar adjustments for warm theme */
body[data-theme="light"] .keys-grid::-webkit-scrollbar-track {
background: #ede8e0; /* Warmer scrollbar track */
}
body[data-theme="light"] .keys-grid {
scrollbar-color: #cbd5e1 #ede8e0; /* Warmer scrollbar for Firefox */
}
/* Progress ring styles */
.progress-ring__circle {
transition: stroke-dashoffset 0.35s;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
</style>
<script>
(function() {
try {
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
if (!isLoggedIn) {
window.location.href = '/login';
}
} catch (e) {
console.error('Auth check error:', e);
window.location.href = '/login';
}
})();
</script>
</head>
<body class="bg-gray-100 font-sans leading-normal tracking-normal" data-theme="light">
<!-- Authentication Checking UI -->
<div id="auth-checking" class="fixed inset-0 bg-gray-700 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white p-8 rounded-lg shadow-xl text-center">
<div class="loader mx-auto mb-4"></div>
<p class="text-lg text-gray-700" data-i18n="verifying_identity">正在验证身份...</p>
<p class="text-sm text-gray-500 mt-2"><span data-i18n="auth_check_timeout">如果页面长时间无响应,请</span> <a href="/login" class="text-blue-600 hover:underline" data-i18n="return_to_login">返回登录页面</a></p>
</div>
</div>
<!-- Unauthorized UI -->
<div id="unauthorized" class="fixed inset-0 bg-gray-700 bg-opacity-75 flex items-center justify-center z-50 hidden">
<div class="bg-white p-8 rounded-lg shadow-xl text-center max-w-md">
<div class="text-red-500 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h2 class="text-xl font-bold text-gray-800 mb-4">Unauthorized Access</h2>
<p class="text-gray-600 mb-6" data-i18n="need_login_message">您需要登录才能访问管理页面。</p>
<a href="/login" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline inline-block" data-i18n="go_to_login">
前往登录
</a>
</div>
</div>
<div id="main-content" class="hidden">
<div class="container mx-auto p-4 lg:p-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl lg:text-3xl font-bold text-gray-800">JimiHub<span id="update-notifier" class="hidden"></span></h1>
<div class="flex items-center space-x-2">
<button id="settings-button" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium p-2 rounded flex items-center justify-center h-[38px] w-[38px]" title="系统设置" data-i18n-title="system_settings">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
<button id="dark-mode-toggle" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium p-2 rounded flex items-center justify-center h-[38px] w-[38px]">
<svg id="sun-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
<svg id="moon-icon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
</button>
<button id="logout-button" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium p-2 rounded flex items-center justify-center h-[38px] w-[38px]" title="退出登录" data-i18n-title="logout">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
</button>
</div>
</div>
<!-- Loading Indicator -->
<div id="loading-indicator" class="fixed top-4 right-4 bg-blue-500 text-white p-2 rounded shadow hidden">
<div class="loader"></div> <span data-i18n="loading">加载中...</span>
</div>
<!-- Error Message Area -->
<div id="error-message" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4 hidden" role="alert">
<strong class="font-bold" data-i18n="error">错误:</strong>
<span class="block sm:inline" id="error-text"></span>
</div>
<!-- Success Message Area -->
<div id="success-message" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4 hidden" role="alert">
<strong class="font-bold" data-i18n="success">成功:</strong>
<span class="block sm:inline" id="success-text"></span>
</div>
<!-- API Configuration Section -->
<section class="mb-8 bg-white p-6 rounded-lg shadow">
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button id="gemini-tab" class="api-tab active whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm" data-tab="gemini">
AI Studio
</button>
<button id="vertex-tab" class="api-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm" data-tab="vertex">
Vertex
</button>
</nav>
</div>
<!-- Gemini API Keys Tab Content -->
<div id="gemini-content" class="tab-content">
<h2 class="text-xl font-semibold mb-4 text-gray-700">Gemini API Keys</h2>
<!-- Test Progress Area -->
<div id="test-progress-area" class="hidden mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-medium text-blue-800" data-i18n="running_all_tests">正在运行所有测试</h3>
<button id="cancel-all-test-btn" class="text-red-600 hover:text-red-800 font-medium px-3 py-1 border border-red-600 rounded" data-i18n="cancel">
取消
</button>
</div>
<div class="mb-2">
<div class="flex justify-between text-sm text-blue-700 mb-1">
<span data-i18n="progress">进度</span>
<span id="test-progress-text">0 / 0</span>
</div>
<div class="w-full bg-blue-200 rounded-full h-2">
<div id="test-progress-bar" class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<div id="test-status-text" class="text-sm text-blue-700" data-i18n="preparing_tests">
准备测试中...
</div>
</div>
<div id="gemini-keys-list" class="mb-4 space-y-4">
<!-- Key items will be loaded here -->
<p class="text-gray-500" data-i18n="loading_keys">加载密钥中...</p>
</div>
<!-- Test and Clean Buttons Area -->
<div id="gemini-keys-actions" class="hidden mb-6 flex space-x-3">
<button type="button" id="run-all-test-btn" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 run-test-btn" data-i18n="run_all_test">
运行所有测试
</button>
<button type="button" id="ignore-all-errors-btn" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500" data-i18n="ignore_all_errors">
忽略所有报错
</button>
<button type="button" id="clean-error-keys-btn" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" data-i18n="clean_error_keys">
清理报错密钥
</button>
</div>
<form id="add-gemini-key-form" class="space-y-3">
<h3 class="text-lg font-medium" data-i18n="add_new_gemini_key">添加新的 Gemini 密钥</h3>
<div>
<label for="gemini-key-name" class="block text-sm font-medium text-gray-700" data-i18n="name_optional">名称(可选)</label>
<input type="text" id="gemini-key-name" name="name" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="例如:个人密钥" data-i18n-placeholder="name_placeholder">
<p class="text-xs text-gray-500 mt-1" data-i18n="name_help">用于识别的友好名称。如果未提供,将使用自动生成的ID。</p>
</div>
<div>
<label for="gemini-key-value" class="block text-sm font-medium text-gray-700" data-i18n="api_key_value">API 密钥值</label>
<textarea id="gemini-key-value" name="key" required rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="请输入 Gemini API 密钥" data-i18n-placeholder="enter_gemini_api_key"></textarea>
<p class="text-xs text-gray-500 mt-1" data-i18n="gemini_key_batch_help">支持批量添加:使用逗号分隔多个密钥,或每行一个密钥。</p>
</div>
<!-- Removed Daily Quota input for Gemini Key -->
<div class="flex space-x-3">
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="add_gemini_key">
添加 Gemini 密钥
</button>
</div>
</form>
</div>
<!-- Vertex Configuration Tab Content -->
<div id="vertex-content" class="tab-content hidden">
<h2 class="text-xl font-semibold mb-4 text-gray-700">Vertex AI Configuration</h2>
<!-- Current Vertex Configuration Display -->
<div id="vertex-config-display" class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-medium text-gray-700" data-i18n="current_vertex_config">当前 Vertex 配置</h3>
<div class="flex items-center space-x-2">
<span id="vertex-status" class="text-sm font-medium"></span>
</div>
</div>
<div id="vertex-config-info" class="text-sm text-gray-600">
<p data-i18n="loading_vertex_config">加载配置中...</p>
</div>
</div>
<!-- Vertex Configuration Form -->
<form id="vertex-config-form" class="space-y-4">
<h3 class="text-lg font-medium" data-i18n="vertex_config_title">Vertex AI 配置</h3>
<!-- Authentication Mode Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="auth_mode">认证模式</label>
<div class="space-y-2">
<label class="inline-flex items-center">
<input type="radio" name="auth_mode" value="service_account" class="form-radio" checked>
<span class="ml-2 text-sm text-gray-700" data-i18n="service_account_mode">服务账号 (JSON)</span>
</label>
<label class="inline-flex items-center">
<input type="radio" name="auth_mode" value="express" class="form-radio">
<span class="ml-2 text-sm text-gray-700" data-i18n="express_mode">快捷模式 (API Key)</span>
</label>
</div>
</div>
<!-- Service Account JSON Input -->
<div id="service-account-section">
<label for="vertex-json" class="block text-sm font-medium text-gray-700" data-i18n="service_account_json">Service Account JSON</label>
<textarea id="vertex-json" name="vertex_json" rows="8" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="请输入 Vertex AI Service Account JSON" data-i18n-placeholder="enter_vertex_json"></textarea>
<p class="text-xs text-gray-500 mt-1" data-i18n="vertex_json_help">完整的 Google Cloud Service Account JSON 配置</p>
</div>
<!-- Express API Key Input -->
<div id="express-api-key-section" class="hidden">
<label for="express-api-key" class="block text-sm font-medium text-gray-700" data-i18n="express_api_key">Express API Key</label>
<input type="text" id="express-api-key" name="express_api_key" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="请输入 Vertex AI Express API Key" data-i18n-placeholder="enter_express_api_key">
<p class="text-xs text-gray-500 mt-1" data-i18n="express_api_key_help">用于 Vertex AI Express Mode 的 API Key</p>
</div>
<div class="flex space-x-3">
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="save_vertex_config">
保存 Vertex 配置
</button>
<button type="button" id="test-vertex-config" class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="test_vertex_config">
测试配置
</button>
<button type="button" id="clear-vertex-config" class="inline-flex justify-center py-2 px-4 border border-red-300 shadow-sm text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" data-i18n="clear_vertex_config">
清除配置
</button>
</div>
</form>
</div>
</section>
<!-- Worker API Keys Section -->
<section class="mb-8 bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 text-gray-700">Worker API Keys</h2>
<div id="worker-keys-list" class="mb-4 space-y-2">
<!-- Key items will be loaded here -->
<p class="text-gray-500" data-i18n="loading_keys">加载密钥中...</p>
</div>
<!-- Worker Keys Legend/Help -->
<div class="bg-blue-50 p-3 rounded mb-4 text-sm text-blue-800">
<p data-i18n="safety_settings_help"><strong>安全设置:</strong> 默认启用。禁用时,模型允许生成 NSFW 内容。</p>
</div>
<form id="add-worker-key-form" class="space-y-3">
<h3 class="text-lg font-medium" data-i18n="add_new_worker_key">添加新的 Worker 密钥</h3>
<div>
<label for="worker-key-value" class="block text-sm font-medium text-gray-700" data-i18n="api_key_value">API 密钥值</label>
<input type="text" id="worker-key-value" name="key" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="生成或输入强密钥" data-i18n-placeholder="generate_or_enter_key">
<button type="button" id="generate-worker-key" class="mt-1 text-sm text-indigo-600 hover:text-indigo-800" data-i18n="generate_random_key">生成随机密钥</button>
</div>
<div>
<label for="worker-key-desc" class="block text-sm font-medium text-gray-700" data-i18n="description_optional">描述(可选)</label>
<input type="text" id="worker-key-desc" name="description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="例如:客户端应用 A" data-i18n-placeholder="description_placeholder">
</div>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="add_worker_key">
添加 Worker 密钥
</button>
</form>
</section>
<!-- Models Section -->
<section class="bg-white p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-700">Managed Models</h2>
<button id="set-category-quotas-btn" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" data-i18n="set_category_quotas">
设置类别配额
</button>
</div>
<div id="models-list" class="mb-4 space-y-2">
<!-- Model items will be loaded here -->
<p class="text-gray-500" data-i18n="loading_models">加载模型中...</p>
</div>
<form id="add-model-form" class="space-y-3">
<h3 class="text-lg font-medium" data-i18n="add_model">添加模型</h3>
<div>
<label for="model-id" class="block text-sm font-medium text-gray-700" data-i18n="model_id">模型 ID</label>
<div class="relative">
<input type="text" id="model-id" name="id" required list="model-suggestions" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="例如:gemini-1.5-flash-latest" data-i18n-placeholder="model_id_placeholder" autocomplete="off">
<datalist id="model-suggestions">
</datalist>
</div>
<p class="text-xs text-gray-500 mt-1" data-i18n="model_id_help">选择或输入模型 ID</p>
</div>
<div>
<label for="model-category" class="block text-sm font-medium text-gray-700" data-i18n="category">类别</label>
<select id="model-category" name="category" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="Pro">Pro</option>
<option value="Flash">Flash</option>
<option value="Custom">Custom</option>
</select>
</div>
<div id="custom-quota-div" class="hidden"> <!-- Hidden by default -->
<label for="model-quota" class="block text-sm font-medium text-gray-700" data-i18n="daily_quota_custom">每日配额(自定义)</label>
<input type="text" id="model-quota" name="dailyQuota" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="例如:1500,或 'none'/'0' 表示无限制" data-i18n-placeholder="quota_placeholder">
<p class="text-xs text-gray-500 mt-1" data-i18n="quota_help">仅适用于"自定义"类别。设置此特定模型的每日最大请求数。输入 'none' 或 '0' 表示无限制。</p>
</div>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="add_model">
添加模型
</button>
</form>
</section>
</div>
<!-- Set Category Quotas Modal -->
<div id="category-quotas-modal" class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 modal-content">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800" data-i18n="set_category_quotas_title">设置类别配额</h2>
<button id="close-category-quotas-modal" class="text-gray-500 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="category-quotas-form" class="space-y-4">
<div>
<label for="pro-quota" class="block text-sm font-medium text-gray-700" data-i18n="pro_models_daily_quota">Pro 模型每日配额</label>
<input type="number" id="pro-quota" name="proQuota" required min="0" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="默认:50">
</div>
<div>
<label for="flash-quota" class="block text-sm font-medium text-gray-700" data-i18n="flash_models_daily_quota">Flash 模型每日配额</label>
<input type="number" id="flash-quota" name="flashQuota" required min="0" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="默认:1500">
</div>
<div class="flex justify-end space-x-2">
<button type="button" id="cancel-category-quotas" class="py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="cancel">
取消
</button>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="save_quotas">
保存配额
</button>
</div>
</form>
<div id="category-quotas-error" class="text-red-500 text-sm mt-2 hidden"></div>
</div>
</div>
<!-- Individual Quota Modal -->
<div id="individual-quota-modal" class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 modal-content">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800" data-i18n="set_quota">设置配额</h2>
<button id="close-individual-quota-modal" class="text-gray-500 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="individual-quota-form" class="space-y-4">
<input type="hidden" id="individual-quota-model-id" name="modelId" value="">
<div>
<label for="individual-quota-value" class="block text-sm font-medium text-gray-700" data-i18n="individual_daily_quota">个人每日配额</label>
<input type="number" id="individual-quota-value" name="individualQuota" min="0" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="默认:0(无个人配额)" data-i18n-placeholder="individual_quota_placeholder">
<p class="text-xs text-gray-500 mt-1" data-i18n="individual_quota_help">输入 0 表示无个人配额。个人配额是在类别配额基础上额外应用的。</p>
</div>
<!-- Removed the note/priority/applicable section -->
<div class="flex justify-end space-x-2">
<button type="button" id="cancel-individual-quota" class="py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="cancel">
取消
</button>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" data-i18n="save_quota">
保存配额
</button>
</div>
</form>
<div id="individual-quota-error" class="text-red-500 text-sm mt-2 hidden"></div>
</div>
</div>
<!-- System Settings Modal -->
<div id="settings-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900" data-i18n="system_settings">系统设置</h3>
<button id="close-settings-modal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="settings-form" class="space-y-4">
<!-- KEEPALIVE Setting -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700" data-i18n="keepalive_setting">KEEPALIVE 模式</label>
<p class="text-xs text-gray-500" data-i18n="keepalive_description">启用后将使用保持连接模式处理请求</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="keepalive-toggle">
<span class="toggle-slider"></span>
</label>
</div>
<!-- Web Search Setting -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700" data-i18n="web_search_setting">联网搜索</label>
<p class="text-xs text-gray-500" data-i18n="web_search_description">启用后将在模型列表中显示带-search后缀的联网搜索模型</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="web-search-toggle">
<span class="toggle-slider"></span>
</label>
</div>
<!-- Auto Test Setting -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700" data-i18n="auto_test_setting">自动批量测试</label>
<p class="text-xs text-gray-500" data-i18n="auto_test_description">启用后将在每天北京时间4点自动进行批量测试</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="auto-test-toggle">
<span class="toggle-slider"></span>
</label>
</div>
<!-- MAX_RETRY Setting -->
<div>
<label for="max-retry-input" class="block text-sm font-medium text-gray-700" data-i18n="max_retry_setting">最大重试次数</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="max_retry_description">API请求失败时的最大重试次数(默认:3)</p>
<input type="number" id="max-retry-input" min="0" max="10" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="3">
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" id="cancel-settings" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md" data-i18n="cancel">取消</button>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md" data-i18n="save">保存</button>
</div>
</form>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
================================================
FILE: public/admin/script.js
================================================
document.addEventListener('DOMContentLoaded', () => {
// --- UI Elements ---
const authCheckingUI = document.getElementById('auth-checking');
const unauthorizedUI = document.getElementById('unauthorized');
const mainContentUI = document.getElementById('main-content');
// --- Global State & Elements ---
const loadingIndicator = document.getElementById('loading-indicator');
const errorMessageDiv = document.getElementById('error-message');
const errorTextSpan = document.getElementById('error-text');
const successMessageDiv = document.getElementById('success-message');
const successTextSpan = document.getElementById('success-text');
const geminiKeysListDiv = document.getElementById('gemini-keys-list');
const addGeminiKeyForm = document.getElementById('add-gemini-key-form');
const workerKeysListDiv = document.getElementById('worker-keys-list');
const addWorkerKeyForm = document.getElementById('add-worker-key-form');
const generateWorkerKeyBtn = document.getElementById('generate-worker-key');
// Tab elements
const geminiTab = document.getElementById('gemini-tab');
const vertexTab = document.getElementById('vertex-tab');
const geminiContent = document.getElementById('gemini-content');
const vertexContent = document.getElementById('vertex-content');
// Vertex configuration elements
const vertexConfigForm = document.getElementById('vertex-config-form');
const vertexConfigDisplay = document.getElementById('vertex-config-display');
const vertexConfigInfo = document.getElementById('vertex-config-info');
const vertexStatus = document.getElementById('vertex-status');
const testVertexConfigBtn = document.getElementById('test-vertex-config');
const clearVertexConfigBtn = document.getElementById('clear-vertex-config');
const workerKeyValueInput = document.getElementById('worker-key-value');
const modelsListDiv = document.getElementById('models-list');
const addModelForm = document.getElementById('add-model-form');
const modelCategorySelect = document.getElementById('model-category');
const customQuotaDiv = document.getElementById('custom-quota-div');
const modelQuotaInput = document.getElementById('model-quota');
const modelIdInput = document.getElementById('model-id');
const setCategoryQuotasBtn = document.getElementById('set-category-quotas-btn');
const categoryQuotasModal = document.getElementById('category-quotas-modal');
const closeCategoryQuotasModalBtn = document.getElementById('close-category-quotas-modal');
const cancelCategoryQuotasBtn = document.getElementById('cancel-category-quotas');
const categoryQuotasForm = document.getElementById('category-quotas-form');
const proQuotaInput = document.getElementById('pro-quota');
const flashQuotaInput = document.getElementById('flash-quota');
const categoryQuotasErrorDiv = document.getElementById('category-quotas-error');
const geminiKeyErrorContainer = document.getElementById('gemini-key-error-container'); // Container for error messages in modal
// Individual Quota Elements
const individualQuotaModal = document.getElementById('individual-quota-modal');
const closeIndividualQuotaModalBtn = document.getElementById('close-individual-quota-modal');
const cancelIndividualQuotaBtn = document.getElementById('cancel-individual-quota');
const individualQuotaForm = document.getElementById('individual-quota-form');
const individualQuotaModelIdInput = document.getElementById('individual-quota-model-id');
const individualQuotaValueInput = document.getElementById('individual-quota-value');
const individualQuotaErrorDiv = document.getElementById('individual-quota-error');
const logoutButton = document.getElementById('logout-button');
const darkModeToggle = document.getElementById('dark-mode-toggle');
const sunIcon = document.getElementById('sun-icon');
const moonIcon = document.getElementById('moon-icon');
// Run All Test Elements
const runAllTestBtn = document.getElementById('run-all-test-btn');
const ignoreAllErrorsBtn = document.getElementById('ignore-all-errors-btn');
const cleanErrorKeysBtn = document.getElementById('clean-error-keys-btn');
const geminiKeysActionsDiv = document.getElementById('gemini-keys-actions');
const testProgressArea = document.getElementById('test-progress-area');
const cancelAllTestBtn = document.getElementById('cancel-all-test-btn');
const testProgressBar = document.getElementById('test-progress-bar');
const testProgressText = document.getElementById('test-progress-text');
const testStatusText = document.getElementById('test-status-text');
// --- Global Cache ---
let cachedModels = [];
let cachedGeminiModels = []; // Add cache for available Gemini models
let cachedCategoryQuotas = { proQuota: 0, flashQuota: 0 };
// --- Global Test State ---
let isRunningAllTests = false;
let testCancelRequested = false;
let currentTestBatch = [];
let operationInProgress = false; // Prevent concurrent database operations
// No need for a separate errorKeyIds cache, as errorStatus is now part of the key data
// --- Run All Test Functions ---
async function runAllGeminiKeysTest() {
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
try {
operationInProgress = true; // Lock operations
isRunningAllTests = true;
testCancelRequested = false;
// Show progress area
testProgressArea.classList.remove('hidden');
runAllTestBtn.disabled = true;
cancelAllTestBtn.disabled = false;
// Get all Gemini keys
const keys = await apiFetch('/gemini-keys');
if (!keys || keys.length === 0) {
showError(t('no_gemini_keys_found'));
return;
}
const totalKeys = keys.length;
let completedTests = 0;
const testModel = 'gemini-2.0-flash'; // Fixed model for testing
// Update initial progress
updateTestProgress(completedTests, totalKeys, t('preparing_tests'));
// Process keys in batches to balance performance and server load
const batchSize = 5; // Optimal batch size for testing
for (let i = 0; i < keys.length; i += batchSize) {
if (testCancelRequested) {
break;
}
const batch = keys.slice(i, i + batchSize);
currentTestBatch = batch;
updateTestProgress(completedTests, totalKeys, t('testing_batch', Math.floor(i / batchSize) + 1));
// Run tests for current batch concurrently
const batchPromises = batch.map(key => testSingleKey(key.id, testModel));
const batchResults = await Promise.allSettled(batchPromises);
// Update progress
completedTests += batch.length;
updateTestProgress(completedTests, totalKeys, t('completed_tests', completedTests, totalKeys));
// Increased delay between batches to reduce server load
if (i + batchSize < keys.length && !testCancelRequested) {
await new Promise(resolve => setTimeout(resolve, 1000)); // Increased from 500ms to 1000ms
}
}
// Final status
if (testCancelRequested) {
updateTestProgress(completedTests, totalKeys, t('tests_cancelled'));
showError(t('test_run_cancelled'));
} else {
updateTestProgress(completedTests, totalKeys, t('all_tests_completed'));
showSuccess(t('completed_testing', totalKeys, testModel));
// Auto-hide progress area after 3 seconds
setTimeout(() => {
testProgressArea.classList.add('hidden');
}, 3000);
}
// Reload keys to show updated status
await loadGeminiKeys();
} catch (error) {
console.error('Error running all tests:', error);
showError(t('failed_to_run_tests', error.message));
updateTestProgress(0, 0, t('test_run_failed'));
} finally {
operationInProgress = false; // Release lock
isRunningAllTests = false;
runAllTestBtn.disabled = false;
cancelAllTestBtn.disabled = true;
currentTestBatch = [];
}
}
async function testSingleKey(keyId, modelId) {
try {
// Use direct fetch to get detailed error information
const response = await fetch('/api/admin/test-gemini-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ keyId, modelId })
});
// Parse response regardless of status code
let result = null;
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
result = await response.json();
} else {
const textContent = await response.text();
result = {
success: false,
status: response.status,
content: textContent || 'No response content'
};
}
return {
keyId,
success: result?.success || false,
status: result?.status || response.status,
error: result?.success ? null : (result?.content || 'Test failed')
};
} catch (error) {
return {
keyId,
success: false,
status: 'error',
error: error.message || 'Network error'
};
}
}
function updateTestProgress(completed, total, statusMessage) {
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
testProgressBar.style.width = `${percentage}%`;
testProgressText.textContent = `${completed} / ${total}`;
testStatusText.textContent = statusMessage;
}
// --- Utility Functions ---
function showLoading() {
loadingIndicator.classList.remove('hidden');
}
function hideLoading() {
loadingIndicator.classList.add('hidden');
}
// Function to enable/disable add model form based on Gemini keys availability
function updateAddModelFormState(hasGeminiKeys) {
const addModelFormElements = addModelForm.querySelectorAll('input, select, button');
addModelFormElements.forEach(element => {
element.disabled = !hasGeminiKeys;
});
// Add visual indication when disabled
if (hasGeminiKeys) {
addModelForm.classList.remove('opacity-50', 'pointer-events-none');
} else {
addModelForm.classList.add('opacity-50', 'pointer-events-none');
}
}
function showError(message, element = errorTextSpan, container = errorMessageDiv) {
element.textContent = message;
container.classList.remove('hidden');
// Auto-hide after 5 seconds
setTimeout(() => {
hideError(container);
}, 5000);
}
function hideError(container = errorMessageDiv) {
container.classList.add('hidden');
const textSpan = container.querySelector('span#error-text');
if (textSpan) textSpan.textContent = ''; // Only clear the message span
}
// Function to show success message and auto-hide
function showSuccess(message, element = successTextSpan, container = successMessageDiv) {
element.textContent = message;
container.classList.remove('hidden');
// Auto-hide after 3 seconds
setTimeout(() => {
hideSuccess(container);
}, 3000);
}
// Function to hide success message
function hideSuccess(container = successMessageDiv) {
container.classList.add('hidden');
const textSpan = container.querySelector('span');
if (textSpan) textSpan.textContent = '';
}
// Generic API fetch function (using cookie auth now)
// 新增 suppressGlobalError 参数,允许调用方控制是否全局报错
async function apiFetch(endpoint, options = {}, suppressGlobalError = false) {
showLoading();
hideError();
hideError(categoryQuotasErrorDiv);
hideSuccess(); // Hide success message on new request
// No need for Authorization header, rely on HttpOnly cookie
const defaultHeaders = {
'Content-Type': 'application/json',
};
try {
const response = await fetch(`/api/admin${endpoint}`, {
credentials: 'include',
...options,
headers: {
...defaultHeaders,
...(options.headers || {}),
},
});
// Check for auth errors (401 Unauthorized, 403 Forbidden)
if (response.status === 401 || response.status === 403) {
console.log("Authentication required or session expired. Redirecting to login.");
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return null;
}
// Check for redirects that might indicate auth issues (302, 307, etc.)
if (response.redirected) {
const redirectUrl = new URL(response.url);
// Check if redirected to login page or similar auth pages
if (redirectUrl.pathname.includes('login') ||
!redirectUrl.pathname.includes('/api/admin')) {
console.log("Detected redirect to login page. Session likely expired.");
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return null;
}
}
// Additional check for 3xx status codes
if (response.status >= 300 && response.status < 400) {
console.log(`Redirect status detected: ${response.status}. Handling potential auth issue.`);
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return null;
}
let data = null;
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
try {
data = await response.json();
} catch (e) {
if (response.ok) {
console.warn("Received OK response but failed to parse JSON body.");
return { success: true };
} else {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status} ${response.statusText} - ${errorText}`);
}
}
} else if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status} ${response.statusText} - ${errorText}`);
} else {
console.log(`Received non-JSON response with status ${response.status}`);
return { success: true };
}
// 409 Conflict
if (response.status === 409) {
throw new Error(data?.error || 'Existing API key');
}
if (!response.ok) {
throw new Error(data?.error || `HTTP error! status: ${response.status}`);
}
return data;
} catch (error) {
console.error('API Fetch Error:', error);
if (suppressGlobalError) {
throw error;
}
if (endpoint === '/category-quotas') {
showError(error.message || 'An unknown error occurred.', categoryQuotasErrorDiv, categoryQuotasErrorDiv);
} else {
showError(error.message || 'An unknown error occurred.');
}
return null;
} finally {
hideLoading();
}
}
// --- Rendering Functions ---
// Helper to format quota display (Infinity becomes ∞)
function formatQuota(quota) {
return (quota === undefined || quota === null || quota === Infinity) ? '∞' : quota;
}
// Helper to calculate remaining percentage for progress bar
function calculateRemainingPercentage(count, quota) {
if (quota === undefined || quota === null || quota === Infinity || quota <= 0) {
return 100;
}
const percentage = Math.max(0, 100 - (count / quota * 100));
return percentage;
}
// Helper to get progress bar color based on percentage
function getProgressColor(percentage) {
if (percentage < 25) return 'bg-red-500';
if (percentage < 50) return 'bg-yellow-500';
return 'bg-green-500';
}
async function renderGeminiKeys(keys) {
geminiKeysListDiv.innerHTML = ''; // Clear previous list
if (!keys || keys.length === 0) {
geminiKeysListDiv.innerHTML = '<p class="text-gray-500">No Gemini keys configured.</p>';
// Hide action buttons when no keys
geminiKeysActionsDiv.classList.add('hidden');
// Disable add model form when no Gemini keys
updateAddModelFormState(false);
return;
}
// Check if there are any error keys
const hasErrorKeys = keys.some(key => key.errorStatus === 400 || key.errorStatus === 401 || key.errorStatus === 403);
// Show/hide error-related buttons based on error keys existence
if (hasErrorKeys) {
ignoreAllErrorsBtn.classList.remove('hidden');
cleanErrorKeysBtn.classList.remove('hidden');
} else {
ignoreAllErrorsBtn.classList.add('hidden');
cleanErrorKeysBtn.classList.add('hidden');
}
// Show action buttons when keys exist
geminiKeysActionsDiv.classList.remove('hidden');
// Enable add model form when Gemini keys exist
updateAddModelFormState(true);
// Ensure models and category quotas are cached (should be loaded in initialLoad)
if (cachedModels.length === 0) {
console.warn("Models cache is empty during renderGeminiKeys. Load may be incomplete.");
}
// Calculate statistics
const totalKeys = keys.length;
const totalUsage = keys.reduce((sum, key) => sum + (parseInt(key.usage) || 0), 0);
// Create main container
const keysContainer = document.createElement('div');
keysContainer.className = 'keys-container';
geminiKeysListDiv.appendChild(keysContainer);
// Create statistics bar that's always visible
const statsBar = document.createElement('div');
// Removed justify-between, added relative for positioning context and select-none to prevent text selection
statsBar.className = 'stats-bar relative flex items-center justify-center p-3 rounded-md mb-4 cursor-pointer transition-colors select-none';
statsBar.innerHTML = `
<div class="stats-info flex items-center space-x-8">
<div class="flex items-baseline">
<span class="text-sm font-bold text-gray-600 dark:text-gray-400 mr-1">API Keys:</span>
<span class="text-lg font-semibold text-blue-700 dark:text-blue-400">${totalKeys}</span>
</div>
<div class="flex items-baseline">
<span class="text-sm font-bold text-gray-600 dark:text-gray-400 mr-1">24hr Usage:</span>
<span class="text-lg font-semibold text-green-700 dark:text-green-400">${totalUsage}</span>
</div>
</div>
<div class="toggle-icon absolute right-3 top-1/2 transform -translate-y-1/2">
<svg class="expand-icon w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
<svg class="collapse-icon w-5 h-5 hidden text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7-7-7 7"></path>
</svg>
</div>
`;
keysContainer.appendChild(statsBar);
// Create collapsible grid container with fixed height for responsive design
const keysGrid = document.createElement('div');
// Mobile: 1 column, show 6 items (6 rows)
// Tablet: 2 columns, show 6 items (3 rows)
// Desktop: 3 columns, show 9 items (3 rows)
keysGrid.className = 'keys-grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-all duration-300 overflow-y-auto border border-gray-200 rounded-lg p-2';
// Function to calculate and apply dynamic height
const updateGridHeight = () => {
// Each card is 80px height + 16px gap between cards + 8px padding
const cardHeight = 80;
const gapSize = 16;
const containerPadding = 8;
// Calculate number of rows based on screen size and total keys
let columnsCount, maxRows, actualRows;
// Determine columns and max rows based on screen size
if (window.innerWidth >= 1024) { // lg breakpoint
columnsCount = 3;
maxRows = 3; // Show max 3 rows on desktop
} else if (window.innerWidth >= 768) { // md breakpoint
columnsCount = 2;
maxRows = 3; // Show max 3 rows on tablet
} else {
columnsCount = 1;
maxRows = 6; // Show max 6 rows on mobile
}
// Calculate actual rows needed
actualRows = Math.ceil(totalKeys / columnsCount);
// Use the smaller of actual rows needed or max rows allowed
const displayRows = Math.min(actualRows, maxRows);
// Calculate dynamic height: rows * cardHeight + (rows-1) * gap + padding
const dynamicHeight = displayRows * cardHeight + (displayRows - 1) * gapSize + containerPadding * 2;
const maxHeight = maxRows * cardHeight + (maxRows - 1) * gapSize + containerPadding * 2;
// Apply dynamic height with max height constraint
keysGrid.style.height = `${dynamicHeight}px`;
keysGrid.style.maxHeight = `${maxHeight}px`;
};
// Initial height calculation
updateGridHeight();
// Add resize listener to recalculate height when window size changes
const resizeHandler = () => updateGridHeight();
window.addEventListener('resize', resizeHandler);
// Store the resize handler for cleanup (optional)
keysGrid._resizeHandler = resizeHandler;
// Set initial expanded/collapsed state based on key count
// With fixed height containers, we can be more generous with initial expansion
// Mobile: show if <= 6 keys, Desktop: show if <= 9 keys
const isInitiallyExpanded = totalKeys <= 9;
if (!isInitiallyExpanded) {
keysGrid.classList.add('hidden');
// Hide action buttons when grid is initially collapsed
geminiKeysActionsDiv.classList.add('hidden');
}
// Update icon display
const expandIcon = statsBar.querySelector('.expand-icon'); // Left arrow - collapsed state
const collapseIcon = statsBar.querySelector('.collapse-icon'); // Down arrow - expanded state
if (isInitiallyExpanded) {
// Content expanded state (visible): show down arrow
expandIcon.classList.add('hidden');
collapseIcon.classList.remove('hidden');
} else {
// Content collapsed state (hidden): show left arrow
expandIcon.classList.remove('hidden');
collapseIcon.classList.add('hidden');
}
keysContainer.appendChild(keysGrid);
// Add click event listener to toggle the grid visibility
statsBar.addEventListener('click', (e) => {
// 防止文本选择
e.preventDefault();
const isCurrentlyHidden = keysGrid.classList.contains('hidden');
keysGrid.classList.toggle('hidden');
expandIcon.classList.toggle('hidden');
collapseIcon.classList.toggle('hidden');
// Toggle action buttons visibility based on grid visibility
if (isCurrentlyHidden) {
// Grid is being shown, show action buttons
geminiKeysActionsDiv.classList.remove('hidden');
} else {
// Grid is being hidden, hide action buttons
geminiKeysActionsDiv.classList.add('hidden');
}
});
keys.forEach(key => {
// Create a simplified card for each key with optimized height
const cardItem = document.createElement('div');
cardItem.className = 'card-item p-3 border rounded-md bg-white shadow-sm hover:shadow-md transition-shadow cursor-pointer select-none h-[80px] flex flex-col justify-between';
cardItem.dataset.keyId = key.id;
// Show warning icon or usage badge
let rightSideContent = '';
if (key.errorStatus === 400 || key.errorStatus === 401 || key.errorStatus === 403) {
rightSideContent = `
<div class="warning-icon-container flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-500 warning-icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 3.001-1.742 3.001H4.42c-1.53 0-2.493-1.667-1.743-3.001l5.58-9.92zM10 13a1 1 0 110-2 1 1 0 010 2zm0-8a1 1 0 011 1v3a1 1 0 11-2 0V6a1 1 0 011-1z" clip-rule="evenodd"></path>
</svg>
</div>
`;
} else {
rightSideContent = `
<div class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full whitespace-nowrap">
${key.usage}
</div>
`;
}
// Optimized card content with better spacing and typography
cardItem.innerHTML = `
<div class="flex items-start justify-between h-full">
<div class="flex-1 min-w-0 pr-2">
<h3 class="font-medium text-sm text-gray-900 truncate mb-1">${key.name || key.id}</h3>
<p class="text-xs text-gray-500 truncate">ID: ${key.id}</p>
<p class="text-xs text-gray-400 truncate">${key.keyPreview}</p>
</div>
<div class="flex-shrink-0">
${rightSideContent}
</div>
</div>
`;
keysGrid.appendChild(cardItem);
// Create a hidden detailed information modal
const detailModal = document.createElement('div');
detailModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden';
detailModal.dataset.modalFor = key.id;
// --- Start Modal HTML ---
let modalHTML = `
<div class="modal-content bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">${key.name || key.id}</h2>
<button class="close-modal text-gray-500 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm text-gray-600">${t('id')}: ${key.id}</p>
<p class="text-sm text-gray-600">${t('key_preview')}: ${key.keyPreview}</p>
</div>
<div>
<p class="text-sm text-gray-600">${t('total_usage_today')}: ${key.usage}</p>
<p class="text-sm text-gray-600">${t('date')}: ${key.usageDate}</p>
${key.errorStatus ? `<p class="text-sm text-red-600 font-medium">${t('error_status')}: ${key.errorStatus}</p>` : ''}
</div>
</div>
<div class="flex justify-end space-x-2 mb-4">
${key.errorStatus ? `<button data-id="${key.id}" class="clear-gemini-key-error text-yellow-600 hover:text-yellow-800 font-medium px-3 py-1 border border-yellow-600 rounded">${t('ignore_error')}</button>` : ''}
<button data-id="${key.id}" class="test-gemini-key text-blue-500 hover:text-blue-700 font-medium px-3 py-1 border border-blue-500 rounded">${t('test')}</button>
<button data-id="${key.id}" class="delete-gemini-key text-red-500 hover:text-red-700 font-medium px-3 py-1 border border-red-500 rounded">${t('delete')}</button>
</div>
<!-- Container for error messages within the modal -->
<div id="gemini-key-error-container-${key.id}" class="hidden bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded relative mb-4" role="alert">
<span class="block sm:inline"></span>
</div>
<!-- Category Usage Section -->
<div class="border-t border-gray-200 pt-4 mb-4">
<h3 class="text-lg font-medium text-gray-800 mb-3">${t('category_usage')}</h3>
<div class="space-y-4">
`;
// Pro Category Usage
const proUsage = key.categoryUsage?.pro || 0;
const proQuota = cachedCategoryQuotas.proQuota;
const proQuotaDisplay = formatQuota(proQuota);
const proRemaining = proQuota === Infinity ? Infinity : Math.max(0, proQuota - proUsage);
const proRemainingDisplay = formatQuota(proRemaining);
const proRemainingPercentage = calculateRemainingPercentage(proUsage, proQuota);
const proProgressColor = getProgressColor(proRemainingPercentage);
modalHTML += `
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${t('pro_models')}</span>
<span class="text-sm font-medium text-gray-700">${proRemainingDisplay}/${proQuotaDisplay}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${proProgressColor} h-2.5 rounded-full" style="width: ${proRemainingPercentage}%"></div>
</div>
</div>
`;
// Handle Pro category individual quota models
const proModelsWithIndividualQuota = cachedModels.filter(model =>
model.category === 'Pro' &&
model.individualQuota &&
key.modelUsage &&
key.modelUsage[model.id] !== undefined
);
if (proModelsWithIndividualQuota.length > 0) {
proModelsWithIndividualQuota.forEach(model => {
const modelId = model.id;
// Check if it's an object structure, if so, extract the count property
const count = typeof key.modelUsage?.[modelId] === 'object' ?
(key.modelUsage?.[modelId]?.count || 0) :
(key.modelUsage?.[modelId] || 0);
const quota = model.individualQuota;
const quotaDisplay = formatQuota(quota);
const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);
const remainingDisplay = formatQuota(remaining);
const remainingPercentage = calculateRemainingPercentage(count, quota);
const progressColor = getProgressColor(remainingPercentage);
modalHTML += `
<div class="mt-2">
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${modelId}</span>
<span class="text-sm font-medium text-gray-700">${remainingDisplay}/${quotaDisplay}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${progressColor} h-2.5 rounded-full" style="width: ${remainingPercentage}%"></div>
</div>
</div>
`;
});
}
// Flash Category Usage
const flashUsage = key.categoryUsage?.flash || 0;
const flashQuota = cachedCategoryQuotas.flashQuota;
const flashQuotaDisplay = formatQuota(flashQuota);
const flashRemaining = flashQuota === Infinity ? Infinity : Math.max(0, flashQuota - flashUsage);
const flashRemainingDisplay = formatQuota(flashRemaining);
const flashRemainingPercentage = calculateRemainingPercentage(flashUsage, flashQuota);
const flashProgressColor = getProgressColor(flashRemainingPercentage);
modalHTML += `
<div class="mt-2">
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${t('flash_models')}</span>
<span class="text-sm font-medium text-gray-700">${flashRemainingDisplay}/${flashQuotaDisplay}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${flashProgressColor} h-2.5 rounded-full" style="width: ${flashRemainingPercentage}%"></div>
</div>
</div>
`;
// Handle Flash category individual quota models
const flashModelsWithIndividualQuota = cachedModels.filter(model =>
model.category === 'Flash' &&
model.individualQuota &&
key.modelUsage &&
key.modelUsage[model.id] !== undefined
);
if (flashModelsWithIndividualQuota.length > 0) {
flashModelsWithIndividualQuota.forEach(model => {
const modelId = model.id;
// Check if it's an object structure, if so, extract the count property
const count = typeof key.modelUsage?.[modelId] === 'object' ?
(key.modelUsage?.[modelId]?.count || 0) :
(key.modelUsage?.[modelId] || 0);
const quota = model.individualQuota;
const quotaDisplay = formatQuota(quota);
const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);
const remainingDisplay = formatQuota(remaining);
const remainingPercentage = calculateRemainingPercentage(count, quota);
const progressColor = getProgressColor(remainingPercentage);
modalHTML += `
<div class="mt-2">
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${modelId}</span>
<span class="text-sm font-medium text-gray-700">${remainingDisplay}/${quotaDisplay}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${progressColor} h-2.5 rounded-full" style="width: ${remainingPercentage}%"></div>
</div>
</div>
`;
});
}
modalHTML += `
</div>
</div>
`;
// Custom Model Usage Section (Only if there are custom models used by this key)
const customModelUsageEntries = Object.entries(key.modelUsage || {})
.filter(([modelId, usageData]) => {
const model = cachedModels.find(m => m.id === modelId);
return model?.category === 'Custom';
});
if (customModelUsageEntries.length > 0) {
modalHTML += `
<div class="border-t border-gray-200 pt-4 mb-4">
<h3 class="text-lg font-medium text-gray-800 mb-3">Custom Model Usage</h3>
<div class="space-y-4">
`;
customModelUsageEntries.forEach(([modelId, usageData]) => {
// Ensure count is obtained correctly, regardless of object structure
const count = typeof usageData === 'object' ?
(usageData.count || 0) : (usageData || 0);
const quota = typeof usageData === 'object' ?
usageData.quota : undefined; // Quota is now included in the key data for custom models
const quotaDisplay = formatQuota(quota);
const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);
const remainingDisplay = formatQuota(remaining);
const remainingPercentage = calculateRemainingPercentage(count, quota);
const progressColor = getProgressColor(remainingPercentage);
modalHTML += `
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700">${modelId}</span>
<span class="text-sm font-medium text-gray-700">${remainingDisplay}/${quotaDisplay}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="${progressColor} h-2.5 rounded-full" style="width: ${remainingPercentage}%"></div>
</div>
</div>
`;
});
modalHTML += `
</div>
</div>
`;
}
// Add test section (remains mostly the same, uses cachedModels)
modalHTML += `
<div class="test-model-section mt-3 border-t pt-4 hidden" data-key-id="${key.id}">
<h3 class="text-lg font-medium text-gray-800 mb-2">${t('test_api_key')}</h3>
<div class="flex items-center">
<select class="model-select mr-2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="">${t('select_a_model')}</option>
${cachedModels.map(model => `<option value="${model.id}">${model.id}</option>`).join('')}
</select>
<button class="run-test-btn inline-flex justify-center py-1 px-3 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
${t('run_test')}
</button>
</div>
<div class="test-result mt-2 hidden">
<pre class="text-xs bg-gray-100 p-2 rounded overflow-x-auto"></pre>
</div>
</div>
`;
// Close modal div
modalHTML += `</div>`;
// --- End Modal HTML ---
detailModal.innerHTML = modalHTML;
document.body.appendChild(detailModal);
// Add click event to the card to display the detailed information modal
cardItem.addEventListener('click', (e) => {
// 防止文本选择
e.preventDefault();
detailModal.classList.remove('hidden');
});
// Add event to the close button
const closeBtn = detailModal.querySelector('.close-modal');
closeBtn.addEventListener('click', () => {
detailModal.classList.add('hidden');
});
// Close by clicking outside the modal
detailModal.addEventListener('click', (e) => {
if (e.target === detailModal) {
detailModal.classList.add('hidden');
}
});
});
// Note: Event listeners for .test-gemini-key and .run-test-btn are now handled
// by global event delegation to prevent duplicate listeners and DOM reference issues
}
function renderWorkerKeys(keys) {
workerKeysListDiv.innerHTML = ''; // Clear previous list
if (!keys || keys.length === 0) {
workerKeysListDiv.innerHTML = '<p class="text-gray-500">No Worker keys configured.</p>';
return;
}
keys.forEach(key => {
const isSafetyEnabled = key.safetyEnabled !== undefined ? key.safetyEnabled : true;
const item = document.createElement('div');
item.className = 'p-3 border rounded-md';
item.innerHTML = `
<div class="flex items-center justify-between mb-2">
<div>
<p class="font-mono text-sm text-gray-700">${key.key}</p>
<p class="text-xs text-gray-500">${key.description || t('no_description')} (${t('created')}: ${new Date(key.createdAt).toLocaleDateString()})</p>
</div>
<button data-key="${key.key}" class="delete-worker-key text-red-500 hover:text-red-700 font-medium">${t('delete')}</button>
</div>
<div class="flex items-center mt-2 border-t pt-2">
<div class="flex items-center">
<label for="safety-toggle-${key.key}" class="text-sm font-medium text-gray-700 mr-2">${t('safety_settings')}:</label>
<div class="relative inline-block w-10 mr-2 align-middle select-none">
<input type="checkbox" id="safety-toggle-${key.key}"
data-key="${key.key}"
class="safety-toggle toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer transition-transform duration-200 ease-in-out"
${isSafetyEnabled ? 'checked' : ''}
/>
<label for="safety-toggle-${key.key}"
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
></label>
</div>
<span class="text-xs font-medium ${isSafetyEnabled ? 'text-green-600' : 'text-red-600'}">
${isSafetyEnabled ? t('enabled') : t('disabled')}
</span>
</div>
</div>
`;
workerKeysListDiv.appendChild(item);
});
// Add styles for toggle switch
const style = document.createElement('style');
style.textContent = `
.toggle-checkbox:checked {
/* Adjusted translation to keep handle within bounds */
transform: translateX(1rem); /* Was 100% */
border-color: #68D391;
}
.toggle-checkbox:checked + .toggle-label {
background-color: #68D391;
}
.toggle-label {
transition: background-color 0.2s ease-in-out;
}
`;
document.head.appendChild(style);
// Add event listeners for safety toggles
document.querySelectorAll('.safety-toggle').forEach(toggle => {
toggle.addEventListener('change', function() {
const key = this.dataset.key;
const isEnabled = this.checked;
const statusText = this.parentElement.nextElementSibling;
statusText.textContent = isEnabled ? t('enabled') : t('disabled');
statusText.className = `text-xs font-medium ${isEnabled ? 'text-green-600' : 'text-red-600'}`;
saveSafetySettingsToServer(key, isEnabled);
console.log(`Safety settings for key ${key} set to ${isEnabled ? 'enabled' : 'disabled'}`);
});
});
}
function renderModels(models) {
modelsListDiv.innerHTML = ''; // Clear previous list
if (!models || models.length === 0) {
modelsListDiv.innerHTML = '<p class="text-gray-500">No models configured.</p>';
return;
}
models.forEach(model => {
const item = document.createElement('div');
item.className = 'p-3 border rounded-md flex items-center justify-between';
let quotaDisplay = model.category;
if (model.category === 'Custom') {
quotaDisplay += ` (${t('quota')}: ${model.dailyQuota === undefined ? t('unlimited') : model.dailyQuota})`;
} else if (model.individualQuota) {
// Show individual quota if it exists for Pro/Flash models
quotaDisplay += ` (${t('individual_quota')}: ${model.individualQuota})`;
}
let actionsHtml = '';
// Only show Set Individual Quota button for Pro and Flash models
if (model.category === 'Pro' || model.category === 'Flash') {
actionsHtml = `
<button data-id="${model.id}" data-category="${model.category}" data-quota="${model.individualQuota || 0}"
class="set-individual-quota mr-2 text-blue-500 hover:text-blue-700 font-medium">
${t('set_quota_btn')}
</button>
`;
}
actionsHtml += `<button data-id="${model.id}" class="delete-model text-red-500 hover:text-red-700 font-medium">${t('delete')}</button>`;
item.innerHTML = `
<div>
<p class="font-semibold text-gray-800">${model.id}</p>
<p class="text-xs text-gray-500">${quotaDisplay}</p>
</div>
<div class="flex items-center">
${actionsHtml}
</div>
`;
modelsListDiv.appendChild(item);
});
// Add event listeners for individual quota buttons
document.querySelectorAll('.set-individual-quota').forEach(btn => {
btn.addEventListener('click', (e) => {
const modelId = e.target.dataset.id;
const category = e.target.dataset.category;
const currentQuota = parseInt(e.target.dataset.quota, 10);
// Set the form values
individualQuotaModelIdInput.value = modelId;
individualQuotaValueInput.value = currentQuota || 0;
// Show the modal
hideError(individualQuotaErrorDiv);
individualQuotaModal.classList.remove('hidden');
});
});
}
// --- Data Loading Functions ---
async function loadGeminiKeys() {
const keys = await apiFetch('/gemini-keys');
if (keys) {
renderGeminiKeys(keys);
} else {
geminiKeysListDiv.innerHTML = '<p class="text-red-500">Failed to load Gemini keys.</p>';
// Disable add model form when failed to load keys
updateAddModelFormState(false);
}
}
async function loadWorkerKeys() {
const keys = await apiFetch('/worker-keys');
if (keys) {
renderWorkerKeys(keys);
} else {
workerKeysListDiv.innerHTML = '<p class="text-red-500">Failed to load Worker keys.</p>';
}
}
async function loadModels() {
const models = await apiFetch('/models');
if (models) {
cachedModels = models;
renderModels(models);
} else {
modelsListDiv.innerHTML = '<p class="text-red-500">Failed to load models.</p>';
}
}
// New function to load category quotas
async function loadCategoryQuotas() {
const quotas = await apiFetch('/category-quotas');
if (quotas) {
cachedCategoryQuotas = quotas;
} else {
showError("Failed to load category quotas.");
}
return quotas;
}
// New function to load available Gemini models
async function loadGeminiAvailableModels(forceRefresh = false) {
// Only proceed if we have Gemini keys
const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');
if (geminiKeysList.length === 0) {
console.log("No Gemini keys available, skipping model list fetch");
// Clear cached models if no keys available
cachedGeminiModels = [];
return;
}
// Skip if we already have cached models and not forcing refresh
if (!forceRefresh && cachedGeminiModels.length > 0) {
console.log("Using cached Gemini models");
updateModelIdDropdown(cachedGeminiModels);
return;
}
try {
console.log("Fetching available Gemini models from server...");
const models = await apiFetch('/gemini-models');
if (models && Array.isArray(models)) {
cachedGeminiModels = models;
// Update the model-id input field to include dropdown
updateModelIdDropdown(models);
console.log(`Loaded ${models.length} available Gemini models`);
} else {
console.warn("No models returned from server");
cachedGeminiModels = [];
}
} catch (error) {
console.error("Failed to load Gemini models:", error);
cachedGeminiModels = [];
}
}
// Update the model-id input to include dropdown functionality
function updateModelIdDropdown(models) {
if (!modelIdInput) return;
// Create custom dropdown menu
const createCustomDropdown = () => {
// Remove old dropdown menu (if it exists)
const existingDropdown = document.getElementById('custom-model-dropdown');
if (existingDropdown) {
existingDropdown.remove();
}
// Create new dropdown menu container
const dropdownContainer = document.createElement('div');
dropdownContainer.id = 'custom-model-dropdown';
dropdownContainer.className = 'absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm hidden';
dropdownContainer.style.maxHeight = '200px';
dropdownContainer.style.overflowY = 'auto';
dropdownContainer.style.border = '1px solid #d1d5db';
// Add model options to the dropdown menu
models.forEach(model => {
const option = document.createElement('div');
option.className = 'cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100';
option.textContent = model.id;
option.dataset.value = model.id;
option.addEventListener('click', () => {
modelIdInput.value = model.id;
dropdownContainer.classList.add('hidden');
// Automatically select category based on model name
const modelValue = model.id.toLowerCase();
if (modelValue.includes('pro')) {
modelCategorySelect.value = 'Pro';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
} else if (modelValue.includes('flash')) {
modelCategorySelect.value = 'Flash';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
}
// Trigger input event so other listeners can respond
modelIdInput.dispatchEvent(new Event('input'));
});
dropdownContainer.appendChild(option);
});
// Add the dropdown menu to the input element's parent
modelIdInput.parentNode.appendChild(dropdownContainer);
return dropdownContainer;
};
// Create dropdown menu
const dropdown = createCustomDropdown();
console.log(`Created custom dropdown with ${models.length} model options`);
// Add input event to automatically select category based on model name and filter dropdown options
modelIdInput.addEventListener('input', function() {
const modelValue = this.value.toLowerCase();
// Filter dropdown options based on input value
const options = dropdown.querySelectorAll('div[data-value]');
let hasVisibleOptions = false;
options.forEach(option => {
const optionValue = option.dataset.value.toLowerCase();
if (optionValue.includes(modelValue)) {
option.style.display = 'block';
hasVisibleOptions = true;
} else {
option.style.display = 'none';
}
});
// If there are matching options, show the dropdown menu
if (hasVisibleOptions && modelValue) {
dropdown.classList.remove('hidden');
} else {
dropdown.classList.add('hidden');
}
// Automatically select category based on input value
if (modelValue.includes('pro')) {
modelCategorySelect.value = 'Pro';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
} else if (modelValue.includes('flash')) {
modelCategorySelect.value = 'Flash';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
}
});
// Add click event to show the dropdown menu and refresh models if needed
modelIdInput.addEventListener('click', async function() {
// Check if we need to refresh the model list
const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');
if (geminiKeysList.length > 0 && (cachedGeminiModels.length === 0 || models.length === 0)) {
console.log("Refreshing Gemini models list on input click...");
await loadGeminiAvailableModels();
return; // loadGeminiAvailableModels will recreate the dropdown with updated models
}
if (models.length > 0) {
// Show all options
const options = dropdown.querySelectorAll('div[data-value]');
options.forEach(option => {
option.style.display = 'block';
});
dropdown.classList.remove('hidden');
}
});
// Hide dropdown menu when clicking elsewhere on the page
document.addEventListener('click', function(e) {
if (e.target !== modelIdInput && !dropdown.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// Remove datalist attribute from input if it exists
modelIdInput.removeAttribute('list');
}
// --- Event Handlers ---
// Add Gemini Key with support for batch input
addGeminiKeyForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
const formData = new FormData(addGeminiKeyForm);
const data = Object.fromEntries(formData.entries());
const geminiKeyInput = data.key ? data.key.trim() : '';
// Check if input is empty
if (!geminiKeyInput) {
showError("API Key Value is required.");
return;
}
// Split input, supporting comma-separated keys (both English and Chinese commas) and line-separated keys
const geminiKeys = geminiKeyInput
.split(/[,,\n\r]+/) // Split by English comma, Chinese comma, newline, or carriage return
.map(key => key.trim())
.filter(key => key !== '');
// Check if there are any keys to process
if (geminiKeys.length === 0) {
showError("No valid API Keys found.");
return;
}
// Gemini API Key format validation regex
const geminiKeyRegex = /^AIzaSy[A-Za-z0-9_-]{33}$/;
// Check format and remove duplicates
const validKeys = [];
const invalidKeys = [];
const seenKeys = new Set();
for (const key of geminiKeys) {
// Skip duplicates
if (seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
// Validate format
if (!geminiKeyRegex.test(key)) {
invalidKeys.push(key);
} else {
validKeys.push(key);
}
}
// If no valid keys, exit
if (validKeys.length === 0) {
showError("No valid API Keys found. Please check the format.");
return;
}
// Show warnings about invalid keys but continue with valid ones
if (invalidKeys.length > 0) {
const maskedInvalidKeys = invalidKeys.map(key => {
if (key.length > 10) {
return `${key.substring(0, 6)}...${key.substring(key.length - 4)}`;
}
return key;
});
showError(`Invalid API key format detected: ${maskedInvalidKeys.join(', ')}`);
}
operationInProgress = true; // Lock operations
showLoading();
let successCount = 0;
let failureCount = 0;
try {
if (validKeys.length === 1) {
// Single key - use original API with name support
let keyData = { key: validKeys[0] };
if (data.name) {
keyData.name = data.name.trim();
}
const result = await apiFetch('/gemini-keys', {
method: 'POST',
body: JSON.stringify(keyData),
});
if (result && result.success) {
successCount = 1;
failureCount = 0;
} else {
successCount = 0;
failureCount = 1;
}
} else if (validKeys.length <= 50) {
// Medium batch - use single batch API call
const result = await apiFetch('/gemini-keys/batch', {
method: 'POST',
body: JSON.stringify({ keys: validKeys }),
});
if (result && result.success) {
successCount = result.successCount || 0;
failureCount = result.failureCount || 0;
// Log detailed results for debugging
if (result.results && result.results.length > 0) {
const failures = result.results.filter(r => !r.success);
if (failures.length > 0) {
console.warn('Some keys failed to add:', failures);
}
}
} else {
successCount = 0;
failureCount = validKeys.length;
}
} else {
// Large batch - split into chunks and process with limited concurrency
const chunkSize = 20; // Process 20 keys per chunk
const maxConcurrency = 3; // Maximum 3 concurrent requests
// Split keys into chunks
const chunks = [];
for (let i = 0; i < validKeys.length; i += chunkSize) {
chunks.push(validKeys.slice(i, i + chunkSize));
}
// Process chunks with limited concurrency and progress feedback
for (let i = 0; i < chunks.length; i += maxConcurrency) {
const currentChunks = chunks.slice(i, i + maxConcurrency);
// Show progress
const processedChunks = Math.floor(i / maxConcurrency) + 1;
const totalChunks = Math.ceil(chunks.length / maxConcurrency);
console.log(`Processing batch ${processedChunks}/${totalChunks} (${validKeys.length} total keys)`);
// Process current batch of chunks concurrently
const chunkPromises = currentChunks.map((chunk, chunkIndex) =>
apiFetch('/gemini-keys/batch', {
method: 'POST',
body: JSON.stringify({ keys: chunk }),
}).then(result => {
console.log(`Chunk ${i + chunkIndex + 1} completed: ${result?.successCount || 0} success, ${result?.failureCount || 0} failed`);
return result;
}).catch(error => {
console.error(`Error in chunk ${i + chunkIndex + 1} processing:`, error);
return { success: false, successCount: 0, failureCount: chunk.length };
})
);
const chunkResults = await Promise.all(chunkPromises);
// Aggregate results
chunkResults.forEach(result => {
if (result && result.success) {
successCount += result.successCount || 0;
failureCount += result.failureCount || 0;
} else {
// If the entire chunk failed, count all keys as failures
const chunkIndex = chunkResults.indexOf(result);
const chunkSize = currentChunks[chunkIndex]?.length || 0;
failureCount += chunkSize;
}
});
// Small delay between batches to avoid overwhelming the server
if (i + maxConcurrency < chunks.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
}
} catch (error) {
console.error('Error during batch add:', error);
successCount = 0;
failureCount = validKeys.length;
} finally {
operationInProgress = false; // Release lock
hideLoading();
}
// Reset form and reload keys
addGeminiKeyForm.reset();
await loadGeminiKeys();
// If keys were successfully added, refresh the available models list
if (successCount > 0) {
await loadGeminiAvailableModels(true); // Force refresh after adding new keys
}
// Show appropriate message based on results
if (successCount > 0) {
showSuccess(`Successfully added ${successCount} Gemini ${successCount === 1 ? 'key' : 'keys'}.`);
} else {
showError(`Failed to add any keys.`);
}
});
// Global event delegation for Gemini key actions
document.addEventListener('click', async (e) => {
// Handle test gemini key button clicks
if (e.target.classList.contains('test-gemini-key')) {
const keyId = e.target.dataset.id;
const testSection = document.querySelector(`.test-model-section[data-key-id="${keyId}"]`);
// Check if testSection exists (防止DOM重新渲染后元素不存在的错误)
if (!testSection) {
console.warn('Test section not found for keyId:', keyId);
return;
}
// Toggle display status
if (testSection.classList.contains('hidden')) {
// Hide all other test areas
document.querySelectorAll('.test-model-section').forEach(section => {
section.classList.add('hidden');
section.querySelector('.test-result')?.classList.add('hidden');
});
// Show current test area
testSection.classList.remove('hidden');
} else {
testSection.classList.add('hidden');
}
return;
}
// Handle run test button clicks
if (e.target.classList.contains('run-test-btn') && !e.target.id) { // Exclude the main "run all test" button
const testSection = e.target.closest('.test-model-section');
// Check if testSection exists (防止DOM重新渲染后元素不存在的错误)
if (!testSection) {
console.warn('Test section not found, possibly due to DOM re-rendering');
return;
}
const keyId = testSection.dataset.keyId;
const modelSelect = testSection.querySelector('.model-select');
const resultDiv = testSection.querySelector('.test-result');
const resultPre = resultDiv?.querySelector('pre');
// Additional safety checks
if (!keyId || !modelSelect || !resultDiv || !resultPre) {
console.warn('Required elements not found in test section');
return;
}
const modelId = modelSelect.value;
if (!modelId) {
showError(t('please_select_model'));
return;
}
// Show result area and set "Loading" text
resultDiv.classList.remove('hidden');
resultPre.textContent = t('testing');
// Send test request directly to handle both success and error responses
let result = null;
try {
// Use direct fetch instead of apiFetch to get raw response
const response = await fetch('/api/admin/test-gemini-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ keyId, modelId })
});
// Parse response regardless of status code
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
result = await response.json();
} else {
const textContent = await response.text();
result = {
success: false,
status: response.status,
content: textContent || 'No response content'
};
}
if (result) {
const formattedContent = typeof result.content === 'object'
? JSON.stringify(result.content, null, 2)
: result.content;
if (result.success) {
resultPre.textContent = `${t('test_passed')}\n${t('status')}: ${result.status}\n\n${t('response')}:\n${formattedContent}`;
resultPre.className = 'text-xs bg-green-50 text-green-800 p-2 rounded overflow-x-auto';
} else {
resultPre.textContent = `${t('test_failed')}\n${t('status')}: ${result.status}\n\n${t('response')}:\n${formattedContent}`;
resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';
}
} else {
resultPre.textContent = t('test_failed_no_response');
resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';
}
} catch (error) {
// 只在测试区域显示网络错误
resultPre.textContent = t('test_failed_network', error.message || t('unknown_error'));
resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';
}
return;
}
if (e.target.classList.contains('delete-gemini-key')) {
const keyId = e.target.dataset.id;
if (confirm(t('delete_confirm_gemini', keyId))) {
const modal = e.target.closest('.fixed.inset-0');
if (modal) {
modal.classList.add('hidden');
}
const result = await apiFetch(`/gemini-keys/${encodeURIComponent(keyId)}`, {
method: 'DELETE',
});
if (result && result.success) {
await loadGeminiKeys(); // Wait for the list to reload
showSuccess(`Gemini key ${keyId} deleted successfully!`);
}
}
}
// --- New: Clear Gemini Key Error ---
if (e.target.classList.contains('clear-gemini-key-error')) {
const keyId = e.target.dataset.id;
const button = e.target;
const modalErrorContainer = document.getElementById(`gemini-key-error-container-${keyId}`);
const modalErrorSpan = modalErrorContainer?.querySelector('span');
if (confirm(`Are you sure you want to clear the error status for key: ${keyId}?`)) {
const result = await apiFetch('/clear-key-error', {
method: 'POST',
body: JSON.stringify({ keyId }),
});
if (result && result.success) {
// Get the corresponding card and data
const cardItem = document.querySelector(`.card-item[data-key-id="${keyId}"]`);
// Find the current key's data to get the usage value
const keyData = result.updatedKey || { usage: 0 }; // Use the updated key data from the API response if available, otherwise default to 0
// Replace the warning icon container with the Total display
const warningContainer = cardItem?.querySelector('.warning-icon-container');
if (warningContainer) {
const totalHTML = `
<div class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full whitespace-nowrap">
${keyData.usage || '0'}
</div>
`;
warningContainer.outerHTML = totalHTML;
}
// Remove error status text from modal
const errorStatusP = button.closest('.modal-content').querySelector('p.text-red-600');
if (errorStatusP) {
errorStatusP.remove();
}
// Remove the button itself
button.remove();
showSuccess(`Error status cleared for key ${keyId}.`);
} else {
// Show error within the modal
if (modalErrorContainer && modalErrorSpan) {
modalErrorSpan.textContent = result?.error || 'Failed to clear error status.';
modalErrorContainer.classList.remove('hidden');
setTimeout(() => modalErrorContainer.classList.add('hidden'), 5000);
} else {
showError(result?.error || 'Failed to clear error status.'); // Fallback to global error
}
}
}
}
// --- End Clear Gemini Key Error ---
});
// Add Worker Key with validation
addWorkerKeyForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(addWorkerKeyForm);
const data = Object.fromEntries(formData.entries());
// Validate worker key format - allow alphanumeric, hyphens, and underscores
const workerKeyValue = data.key?.trim();
if (!workerKeyValue) {
showError('Worker key is required.');
return;
}
const validKeyRegex = /^[a-zA-Z0-9_\-]+$/;
if (!validKeyRegex.test(workerKeyValue)) {
showError('Worker key can only contain letters, numbers, underscores (_), and hyphens (-).');
return;
}
const result = await apiFetch('/worker-keys', {
method: 'POST',
body: JSON.stringify(data),
});
if (result && result.success) {
addWorkerKeyForm.reset();
await loadWorkerKeys(); // Wait for the list to reload
showSuccess('Worker key added successfully!');
}
});
// Delete Worker Key (no changes needed)
workerKeysListDiv.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-worker-key')) {
const key = e.target.dataset.key;
// Use key in the path for deletion, matching backend expectation
if (confirm(t('delete_confirm_worker', key))) {
const result = await apiFetch(`/worker-keys/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
if (result && result.success) {
await loadWorkerKeys(); // Wait for the list to reload
showSuccess(`Worker key ${key} deleted successfully!`);
}
}
}
});
// Save safety settings (no changes needed)
async function saveSafetySettingsToServer(key, isEnabled) {
try {
const result = await apiFetch('/worker-keys/safety-settings', {
method: 'POST',
body: JSON.stringify({
key: key,
safetyEnabled: isEnabled
}),
});
if (!result || !result.success) {
console.error('Failed to save safety settings to server');
showError('Failed to sync safety settings with server. Changes may not persist across browsers.');
}
} catch (error) {
console.error('Error saving safety settings to server:', error);
showError('Failed to sync safety settings with server. Changes may not persist across browsers.');
}
}
// Generate Random Worker Key with valid format
generateWorkerKeyBtn.addEventListener('click', () => {
const validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let randomKey = 'sk-';
for (let i = 0; i < 20; i++) {
const randomIndex = Math.floor(Math.random() * validChars.length);
randomKey += validChars[randomIndex];
}
workerKeyValueInput.value = randomKey;
});
// --- Model Form Logic ---
// Show/hide Custom Quota input based on category selection
modelCategorySelect.addEventListener('change', (e) => {
if (e.target.value === 'Custom') {
customQuotaDiv.classList.remove('hidden');
modelQuotaInput.required = true;
} else {
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
modelQuotaInput.value = '';
}
});
// Add/Update Model - Modified Submit Handler
addModelForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Check if there are any Gemini API keys before allowing model addition
const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');
if (geminiKeysList.length === 0) {
showError('无法添加模型:请先添加至少一个 Gemini API 密钥。');
return;
}
const formData = new FormData(addModelForm);
const data = {
id: formData.get('id').trim(),
category: formData.get('category')
};
// Only include dailyQuota if category is 'Custom' and input is visible/filled
if (data.category === 'Custom') {
const quotaInput = formData.get('dailyQuota')?.trim().toLowerCase();
if (quotaInput === undefined || quotaInput === null || quotaInput === '') {
showError("Daily Quota is required for Custom models. Enter a positive number, 'none', or '0'.");
return; // Stop submission
}
if (quotaInput === 'none' || quotaInput === '0') {
} else {
const quotaValue = parseInt(quotaInput, 10);
if (isNaN(quotaValue) || quotaValue <= 0 || quotaInput !== quotaValue.toString()) {
showError("Daily Quota for Custom models must be a positive whole number, 'none', or '0'.");
return;
}
data.dailyQuota = quotaValue;
}
}
const result = await apiFetch('/models', {
method: 'POST',
body: JSON.stringify(data),
});
if (result && result.success) {
addModelForm.reset();
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
await loadModels(); // Wait for models to reload
await loadGeminiKeys(); // Wait for gemini keys to reload (as model changes affect them)
showSuccess(`Model ${data.id} added/updated successfully!`);
}
});
// Delete Model (no changes needed)
modelsListDiv.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-model')) {
const modelId = e.target.dataset.id;
// Use model ID in the path for deletion, matching backend expectation
if (confirm(t('delete_confirm_model', modelId))) {
const result = await apiFetch(`/models/${encodeURIComponent(modelId)}`, {
method: 'DELETE',
});
if (result && result.success) {
await loadModels(); // Wait for models to reload
await loadGeminiKeys(); // Wait for gemini keys to reload
showSuccess(`Model ${modelId} deleted successfully!`);
}
}
}
});
// --- Category Quotas Modal Logic ---
setCategoryQuotasBtn.addEventListener('click', async () => {
hideError(categoryQuotasErrorDiv);
const currentQuotas = await loadCategoryQuotas();
if (currentQuotas) {
proQuotaInput.value = currentQuotas.proQuota ?? 50;
flashQuotaInput.value = currentQuotas.flashQuota ?? 1500;
// Set placeholders to show default values
proQuotaInput.placeholder = "Default: 50";
flashQuotaInput.placeholder = "Default: 1500";
categoryQuotasModal.classList.remove('hidden');
} else {
showError("Could not load current category quotas.", categoryQuotasErrorDiv, categoryQuotasErrorDiv);
}
});
closeCategoryQuotasModalBtn.addEventListener('click', () => {
categoryQuotasModal.classList.add('hidden');
});
cancelCategoryQuotasBtn.addEventListener('click', () => {
categoryQuotasModal.classList.add('hidden');
});
categoryQuotasModal.addEventListener('click', (e) => {
if (e.target === categoryQuotasModal) {
categoryQuotasModal.classList.add('hidden');
}
});
categoryQuotasForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError(categoryQuotasErrorDiv);
const proQuota = parseInt(proQuotaInput.value, 10);
const flashQuota = parseInt(flashQuotaInput.value, 10);
if (isNaN(proQuota) || proQuota < 0 || isNaN(flashQuota) || flashQuota < 0) {
showError("Quotas must be non-negative numbers.", categoryQuotasErrorDiv, categoryQuotasErrorDiv);
return;
}
const result = await apiFetch('/category-quotas', {
method: 'POST',
body: JSON.stringify({ proQuota, flashQuota }),
});
if (result && result.success) {
cachedCategoryQuotas = { proQuota, flashQuota };
categoryQuotasModal.classList.add('hidden');
await loadGeminiKeys(); // Wait for gemini keys to reload
showSuccess('Category quotas saved successfully!');
} else {
// Error already shown by apiFetch
showError(result?.error || "Failed to save category quotas.", categoryQuotasErrorDiv, categoryQuotasErrorDiv);
}
});
// --- Individual Quota Modal Logic ---
closeIndividualQuotaModalBtn.addEventListener('click', () => {
individualQuotaModal.classList.add('hidden');
});
cancelIndividualQuotaBtn.addEventListener('click', () => {
individualQuotaModal.classList.add('hidden');
});
// --- Run All Test Logic ---
runAllTestBtn.addEventListener('click', async () => {
if (isRunningAllTests) {
return; // Prevent multiple concurrent tests
}
await runAllGeminiKeysTest();
});
cancelAllTestBtn.addEventListener('click', () => {
testCancelRequested = true;
testStatusText.textContent = t('cancelling_tests');
cancelAllTestBtn.disabled = true;
});
// Ignore All Errors Logic
ignoreAllErrorsBtn.addEventListener('click', async () => {
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
if (!confirm(t('ignore_all_errors_confirm'))) {
return;
}
try {
operationInProgress = true; // Lock operations
showLoading();
const result = await apiFetch('/clear-all-errors', {
method: 'POST',
});
if (result && result.success) {
if (result.clearedCount === 0) {
showSuccess(t('no_error_keys_found'));
} else {
showSuccess(t('error_keys_ignored', result.clearedCount));
}
await loadGeminiKeys(); // Reload the keys list
}
} catch (error) {
console.error('Error ignoring error keys:', error);
showError(t('failed_to_ignore_error_keys', error.message));
} finally {
operationInProgress = false; // Release lock
hideLoading();
}
});
// Clean Error Keys Logic
cleanErrorKeysBtn.addEventListener('click', async () => {
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
if (!confirm(t('clean_error_keys_confirm'))) {
return;
}
try {
operationInProgress = true; // Lock operations
showLoading();
const result = await apiFetch('/error-keys', {
method: 'DELETE',
});
if (result && result.success) {
if (result.deletedCount === 0) {
showSuccess(t('no_error_keys_found'));
} else {
showSuccess(t('error_keys_cleaned', result.deletedCount));
}
await loadGeminiKeys(); // Reload the keys list
}
} catch (error) {
console.error('Error cleaning error keys:', error);
showError(t('failed_to_clean_error_keys', error.message));
} finally {
operationInProgress = false; // Release lock
hideLoading();
}
});
individualQuotaModal.addEventListener('click', (e) => {
if (e.target === individualQuotaModal) {
individualQuotaModal.classList.add('hidden');
}
});
individualQuotaForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError(individualQuotaErrorDiv);
const modelId = individualQuotaModelIdInput.value;
const individualQuota = parseInt(individualQuotaValueInput.value, 10);
if (isNaN(individualQuota) || individualQuota < 0) {
showError("Individual quota must be a non-negative number.", individualQuotaErrorDiv, individualQuotaErrorDiv);
return;
}
// Find the existing model to update
const modelToUpdate = cachedModels.find(m => m.id === modelId);
if (!modelToUpdate) {
showError(`Model ${modelId} not found.`, individualQuotaErrorDiv, individualQuotaErrorDiv);
return;
}
// Create the payload with existing data plus the new individualQuota
const payload = {
id: modelId,
category: modelToUpdate.category,
individualQuota: individualQuota > 0 ? individualQuota : undefined // If 0, set to undefined to remove quota
};
// If it's a Custom model, preserve the dailyQuota
if (modelToUpdate.category === 'Custom' && modelToUpdate.dailyQuota) {
payload.dailyQuota = modelToUpdate.dailyQuota;
}
const result = await apiFetch('/models', {
method: 'POST',
body: JSON.stringify(payload),
});
if (result && result.success) {
individualQuotaModal.classList.add('hidden');
await loadModels(); // Reload models to show updated quota
await loadGeminiKeys(); // Reload keys as they display model usage
if (individualQuota > 0) {
showSuccess(`Individual quota for ${modelId} set to ${individualQuota}.`);
} else {
showSuccess(`Individual quota for ${modelId} removed.`);
}
} else {
showError(result?.error || "Failed to set individual quota.", individualQuotaErrorDiv, individualQuotaErrorDiv);
}
});
// Verify if the user is authorized; redirect directly if not
async function checkAuth() {
try {
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = '/login';
return false;
}
const response = await fetch('/api/admin/models', { // Use an existing simple GET endpoint
method: 'GET',
credentials: 'include'
});
// Check for redirects that might indicate auth issues
if (response.redirected) {
const redirectUrl = new URL(response.url);
if (redirectUrl.pathname.includes('login') ||
!redirectUrl.pathname.includes('/api/admin')) {
console.log('Detected redirect to login page. Session likely expired.');
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return false;
}
}
if (!response.ok) {
if (response.status === 401 || response.status === 403 ||
(response.status >=
gitextract_h9d7l8di/
├── .dockerignore
├── .github/
│ └── workflows/
│ └── docker-publish.yml
├── .gitignore
├── Dockerfile
├── Dockerfile.huggingface
├── LICENSE
├── README.md
├── README_zh.md
├── doc/
│ ├── Deploy/
│ │ ├── Colab/
│ │ │ ├── Colab部署.md
│ │ │ └── colab启动.ipynb
│ │ ├── GitHub/
│ │ │ └── GitHub同步.md
│ │ ├── HuggingFace/
│ │ │ ├── Hugging Face Space部署-fork说明.md
│ │ │ └── Hugging Face Space部署.md
│ │ ├── Koyeb/
│ │ │ └── Koyeb部署.md
│ │ ├── Local/
│ │ │ └── 本地部署.md
│ │ ├── Render/
│ │ │ └── Render部署.md
│ │ └── Uptimerobot/
│ │ └── 配置Uptimerrobot.md
│ ├── Usage/
│ │ ├── KEEPALIVE.md
│ │ ├── Vertex/
│ │ │ └── Vertex代理配置.md
│ │ ├── 在客户端中使用.md
│ │ └── 配置API连接.md
│ └── 项目介绍.md
├── docker-compose.yml
├── get-jimihub.sh
├── package.json
├── public/
│ ├── admin/
│ │ ├── index.html
│ │ ├── script.js
│ │ ├── style.css
│ │ └── version.txt
│ ├── i18n.js
│ ├── login.html
│ └── login.script.js
└── src/
├── db/
│ └── index.js
├── index.js
├── middleware/
│ ├── adminAuth.js
│ └── workerAuth.js
├── routes/
│ ├── adminApi.js
│ ├── apiV1.js
│ └── auth.js
├── services/
│ ├── batchTestService.js
│ ├── configService.js
│ ├── geminiKeyService.js
│ ├── geminiProxyService.js
│ ├── schedulerService.js
│ └── vertexProxyService.js
└── utils/
├── githubSync.js
├── helpers.js
├── proxyPool.js
├── session.js
└── transform.js
SYMBOL INDEX (160 symbols across 20 files)
FILE: public/admin/script.js
function runAllGeminiKeysTest (line 84) | async function runAllGeminiKeysTest() {
function testSingleKey (line 171) | async function testSingleKey(keyId, modelId) {
function updateTestProgress (line 213) | function updateTestProgress(completed, total, statusMessage) {
function showLoading (line 222) | function showLoading() {
function hideLoading (line 226) | function hideLoading() {
function updateAddModelFormState (line 231) | function updateAddModelFormState(hasGeminiKeys) {
function showError (line 245) | function showError(message, element = errorTextSpan, container = errorMe...
function hideError (line 254) | function hideError(container = errorMessageDiv) {
function showSuccess (line 261) | function showSuccess(message, element = successTextSpan, container = suc...
function hideSuccess (line 271) | function hideSuccess(container = successMessageDiv) {
function apiFetch (line 279) | async function apiFetch(endpoint, options = {}, suppressGlobalError = fa...
function formatQuota (line 380) | function formatQuota(quota) {
function calculateRemainingPercentage (line 385) | function calculateRemainingPercentage(count, quota) {
function getProgressColor (line 394) | function getProgressColor(percentage) {
function renderGeminiKeys (line 401) | async function renderGeminiKeys(keys) {
function renderWorkerKeys (line 870) | function renderWorkerKeys(keys) {
function renderModels (line 945) | function renderModels(models) {
function loadGeminiKeys (line 1006) | async function loadGeminiKeys() {
function loadWorkerKeys (line 1017) | async function loadWorkerKeys() {
function loadModels (line 1026) | async function loadModels() {
function loadCategoryQuotas (line 1037) | async function loadCategoryQuotas() {
function loadGeminiAvailableModels (line 1048) | async function loadGeminiAvailableModels(forceRefresh = false) {
function updateModelIdDropdown (line 1086) | function updateModelIdDropdown(models) {
function saveSafetySettingsToServer (line 1651) | async function saveSafetySettingsToServer(key, isEnabled) {
function checkAuth (line 1981) | async function checkAuth() {
function initialLoad (line 2030) | async function initialLoad() {
function initDarkMode (line 2087) | function initDarkMode() {
function setupAuthRefresh (line 2116) | function setupAuthRefresh() {
function switchTab (line 2152) | function switchTab(tabName) {
function loadVertexConfig (line 2180) | async function loadVertexConfig() {
function renderVertexConfig (line 2194) | function renderVertexConfig(config) {
function toggleAuthMode (line 2253) | function toggleAuthMode() {
function saveVertexConfig (line 2267) | async function saveVertexConfig(configData) {
function testVertexConfig (line 2294) | async function testVertexConfig() {
function clearVertexConfig (line 2315) | async function clearVertexConfig() {
function setupSettingsModal (line 2416) | function setupSettingsModal() {
function loadSystemSettings (line 2453) | async function loadSystemSettings() {
function saveSystemSettings (line 2484) | async function saveSystemSettings() {
function compareVersions (line 2526) | function compareVersions(v1, v2) {
function checkForUpdates (line 2540) | async function checkForUpdates() {
function showVersionDisplay (line 2590) | function showVersionDisplay(version) {
FILE: public/i18n.js
class I18n (line 2) | class I18n {
method constructor (line 3) | constructor() {
method init (line 361) | init() {
method detectLanguage (line 372) | detectLanguage() {
method translate (line 384) | translate(key, ...args) {
method applyTranslations (line 397) | applyTranslations() {
method setupLanguageChangeListener (line 423) | setupLanguageChangeListener() {
FILE: public/login.script.js
function showLoading (line 9) | function showLoading() {
function hideLoading (line 14) | function hideLoading() {
function showError (line 19) | function showError(message) {
function hideError (line 24) | function hideError() {
FILE: src/db/index.js
function validateDatabaseFile (line 54) | function validateDatabaseFile(filePath) {
function initializeDatabase (line 80) | async function initializeDatabase() {
function syncToGitHub (line 170) | async function syncToGitHub() {
function initializeDatabaseSchemaInternal (line 236) | function initializeDatabaseSchemaInternal(callback) {
function closeDatabase (line 259) | function closeDatabase() {
method db (line 284) | get db() { return db; }
FILE: src/middleware/adminAuth.js
function requireAdminAuth (line 10) | async function requireAdminAuth(req, res, next) {
FILE: src/middleware/workerAuth.js
function requireWorkerAuth (line 10) | async function requireWorkerAuth(req, res, next) {
FILE: src/routes/adminApi.js
function parseBody (line 18) | function parseBody(req) {
constant BASE_GEMINI_URL (line 92) | const BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generati...
function shouldMark400Error (line 95) | function shouldMark400Error(responseBody) {
FILE: src/routes/apiV1.js
method read (line 162) | read() {}
method read (line 367) | read() {}
method transform (line 434) | transform(chunk, encoding, callback) {
method flush (line 606) | flush(callback) {
function processGeminiObject (line 689) | function processGeminiObject(geminiObj, stream) {
FILE: src/routes/auth.js
constant ADMIN_PASSWORD (line 7) | const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
FILE: src/services/batchTestService.js
constant BASE_GEMINI_URL (line 7) | const BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generati...
function shouldMark400Error (line 10) | function shouldMark400Error(responseBody) {
function testSingleKey (line 34) | async function testSingleKey(keyId, modelId) {
function runBatchTest (line 153) | async function runBatchTest() {
FILE: src/services/configService.js
function getSetting (line 111) | async function getSetting(key, defaultValue = null) {
function setSetting (line 133) | async function setSetting(key, value, skipSync = false, useTransaction =...
function getModelsConfig (line 174) | async function getModelsConfig() {
function setModelConfig (line 196) | async function setModelConfig(modelId, category, dailyQuota, individualQ...
function deleteModelConfig (line 239) | async function deleteModelConfig(modelId) {
function getCategoryQuotas (line 270) | async function getCategoryQuotas() {
function setCategoryQuotas (line 286) | async function setCategoryQuotas(proQuota, flashQuota) {
function getAllWorkerKeys (line 325) | async function getAllWorkerKeys() {
function getWorkerKeySafetySetting (line 340) | async function getWorkerKeySafetySetting(apiKey) {
function addWorkerKey (line 353) | async function addWorkerKey(apiKey, description = '') {
function updateWorkerKeySafety (line 389) | async function updateWorkerKeySafety(apiKey, safetyEnabled) {
function deleteWorkerKey (line 420) | async function deleteWorkerKey(apiKey) {
function getGitHubConfig (line 451) | async function getGitHubConfig() {
function setGitHubConfig (line 463) | async function setGitHubConfig(repo, token, dbPath = './database.db', en...
FILE: src/services/geminiKeyService.js
function addGeminiKey (line 14) | async function addGeminiKey(apiKey, name) {
function addMultipleGeminiKeys (line 95) | async function addMultipleGeminiKeys(apiKeys) {
function deleteGeminiKey (line 223) | async function deleteGeminiKey(keyId) {
function getAllGeminiKeysWithUsage (line 310) | async function getAllGeminiKeysWithUsage() {
function getErrorKeys (line 397) | async function getErrorKeys() {
function clearKeyError (line 412) | async function clearKeyError(keyId) {
function deleteAllErrorKeys (line 454) | async function deleteAllErrorKeys() {
function clearAllErrorKeys (line 532) | async function clearAllErrorKeys() {
function recordKeyError (line 583) | async function recordKeyError(keyId, status) {
function getNextAvailableGeminiKey (line 623) | async function getNextAvailableGeminiKey(requestedModelId, updateIndex =...
function incrementKeyUsage (line 833) | async function incrementKeyUsage(keyId, modelId, category) {
function forceSetQuotaToLimit (line 907) | async function forceSetQuotaToLimit(keyId, category, modelId, counterKey) {
function handle429Error (line 1045) | async function handle429Error(keyId, category, modelId, errorDetails) {
FILE: src/services/geminiProxyService.js
constant BASE_GEMINI_URL (line 12) | const BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generati...
function shouldMark400Error (line 15) | function shouldMark400Error(errorObject) {
function proxyChatCompletions (line 33) | async function proxyChatCompletions(openAIRequestBody, workerApiKey, str...
FILE: src/services/schedulerService.js
class SchedulerService (line 5) | class SchedulerService {
method constructor (line 6) | constructor() {
method initialize (line 14) | async initialize() {
method updateBatchTestSchedule (line 31) | async updateBatchTestSchedule() {
method startBatchTestSchedule (line 50) | async startBatchTestSchedule() {
method stopBatchTestSchedule (line 85) | async stopBatchTestSchedule() {
method getStatus (line 96) | getStatus() {
method triggerBatchTest (line 107) | async triggerBatchTest() {
method shutdown (line 127) | async shutdown() {
FILE: src/services/vertexProxyService.js
constant VERTEX_SUPPORTED_MODELS (line 12) | const VERTEX_SUPPORTED_MODELS = [
constant DEFAULT_REGION (line 18) | const DEFAULT_REGION = 'us-central1';
constant VERTEX_JSON_STRING (line 24) | let VERTEX_JSON_STRING = null;
function initializeVertexCredentials (line 33) | async function initializeVertexCredentials() {
function createServiceAccountFile (line 114) | async function createServiceAccountFile(vertexJsonString) {
function mapOpenaiRoleToVertex (line 145) | function mapOpenaiRoleToVertex(openaiRole) {
function parseImageDataUri (line 160) | function parseImageDataUri(uri) {
function convertOpenaiPartsToVertexParts (line 193) | async function convertOpenaiPartsToVertexParts(openAIContentParts) {
function convertOpenaiMessagesToVertex (line 255) | async function convertOpenaiMessagesToVertex(messages) {
function convertOpenaiToolsToVertex (line 355) | function convertOpenaiToolsToVertex(tools) {
function convertVertexFinishReasonToOpenai (line 388) | function convertVertexFinishReasonToOpenai(reason) {
function convertVertexToolCallToOpenai (line 409) | function convertVertexToolCallToOpenai(functionCall, index = 0) {
function createSafetySettings (line 431) | function createSafetySettings(blockLevel = 'OFF') {
function proxyVertexChatCompletions (line 455) | async function proxyVertexChatCompletions(openAIRequestBody, workerApiKe...
function getVertexSupportedModels (line 976) | function getVertexSupportedModels() {
function isVertexEnabled (line 985) | function isVertexEnabled() {
function reinitializeWithDatabaseConfig (line 995) | async function reinitializeWithDatabaseConfig() {
FILE: src/utils/githubSync.js
class GitHubSync (line 6) | class GitHubSync {
method constructor (line 7) | constructor(repoName, token, dbPath, encryptKey) {
method isConfigured (line 43) | isConfigured() {
method isEncryptionEnabled (line 48) | isEncryptionEnabled() {
method validateSQLiteHeader (line 53) | validateSQLiteHeader(data) {
method isEncryptedData (line 65) | isEncryptedData(data) {
method encryptData (line 84) | async encryptData(data) {
method decryptData (line 122) | async decryptData(data) {
method downloadDatabase (line 158) | async downloadDatabase() {
method scheduleSync (line 237) | scheduleSync() {
method uploadDatabase (line 268) | async uploadDatabase() {
FILE: src/utils/helpers.js
function getTodayInLA (line 5) | function getTodayInLA() {
function readRequestBody (line 23) | async function readRequestBody(req) {
FILE: src/utils/proxyPool.js
function initializeProxyPool (line 12) | function initializeProxyPool() {
function getNextProxyAgent (line 42) | function getNextProxyAgent() {
function getProxyPoolStatus (line 59) | function getProxyPoolStatus() {
FILE: src/utils/session.js
constant SESSION_COOKIE_NAME (line 3) | const SESSION_COOKIE_NAME = '__session';
constant SESSION_DURATION_SECONDS (line 4) | const SESSION_DURATION_SECONDS = 1 * 60 * 60;
constant SESSION_SECRET_KEY (line 7) | const SESSION_SECRET_KEY = crypto.randomBytes(32).toString('hex');
function bufferToBase64Url (line 15) | function bufferToBase64Url(buffer) {
function base64UrlToBuffer (line 27) | function base64UrlToBuffer(base64url) {
function generateSessionToken (line 38) | async function generateSessionToken() {
function verifySessionToken (line 62) | async function verifySessionToken(token) {
function getSessionTokenFromCookie (line 108) | function getSessionTokenFromCookie(req) {
function setSessionCookie (line 118) | function setSessionCookie(res, token) {
function clearSessionCookie (line 133) | function clearSessionCookie(res) {
function verifySessionCookie (line 148) | async function verifySessionCookie(req) {
FILE: src/utils/transform.js
function parseDataUri (line 8) | function parseDataUri(dataUri) {
function transformOpenAiToGemini (line 22) | function transformOpenAiToGemini(requestBody, requestedModelId, isSafety...
function transformGeminiStreamChunk (line 190) | function transformGeminiStreamChunk(geminiChunk, modelId) {
function transformGeminiResponseToOpenAI (line 311) | function transformGeminiResponseToOpenAI(geminiResponse, modelId) {
Condensed preview — 50 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (602K chars).
[
{
"path": ".dockerignore",
"chars": 1023,
"preview": "# Git files\n.git\n.gitignore\n\n# Docker files\nDockerfile\n.dockerignore\ndocker-compose.yml\nDockerfile.huggingface\n\n# Node d"
},
{
"path": ".github/workflows/docker-publish.yml",
"chars": 1387,
"preview": "name: Docker Image CI for Hugging Face\n\non:\n push:\n branches: [ \"main\" ]\n workflow_dispatch:\n\njobs:\n build_and_pus"
},
{
"path": ".gitignore",
"chars": 2131,
"preview": "# Logs\n\nlogs\n_.log\nnpm-debug.log_\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic report"
},
{
"path": "Dockerfile",
"chars": 132,
"preview": "FROM node:lts-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm install --only=production\nCOPY . .\nEXPOSE 3000\nCMD [ \"npm\""
},
{
"path": "Dockerfile.huggingface",
"chars": 232,
"preview": "FROM dreamhartley705/jimihub:latest\nENV HUGGING_FACE=1\nENV PORT=7860\nUSER root\nRUN mkdir -p /home/user/data && \\\n chm"
},
{
"path": "LICENSE",
"chars": 12234,
"preview": "Creative Commons Attribution-NonCommercial 4.0 International Public License\n\nBy exercising the Licensed Rights (defined "
},
{
"path": "README.md",
"chars": 9329,
"preview": "# JimiHub\n\n> **本项目遵循CC BY-NC 4.0协议,禁止任何形式的商业倒卖行为。** \nThis project is licensed under the Creative Commons Attribution-No"
},
{
"path": "README_zh.md",
"chars": 5633,
"preview": "# JimiHub\n\n## 简介\n\n`JimiHub` 是一个代理服务。它可以将 OpenAI API 格式的请求转发给 Google Gemini Pro API,使得为 OpenAI 开发的应用能够无缝切换或利用 Gemini 模型的能"
},
{
"path": "doc/Deploy/Colab/Colab部署.md",
"chars": 1476,
"preview": "# Colab 部署\n\n此部署方式利用 Colab 的 Notebook 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n注意,由于 Colab 的特性,此部署方式无法做到持续运行,每次运行后退出网页,实例最长"
},
{
"path": "doc/Deploy/Colab/colab启动.ipynb",
"chars": 8157,
"preview": "{\n \"cells\": [\n {\n \"cell_type\": \"code\",\n \"execution_count\": null,\n \"metadata\": {\n \"cellView\": \""
},
{
"path": "doc/Deploy/GitHub/GitHub同步.md",
"chars": 1624,
"preview": "# GitHub 同步功能\n\ngemini proxy panel 支持 GitHub 同步数据库功能,这个功能可以自动将数据库上传至您的私人 GitHub 仓库,并且在每次启动时自动下载最新的数据库,以保证数据的持久化存储。\n\n**注意:"
},
{
"path": "doc/Deploy/HuggingFace/Hugging Face Space部署-fork说明.md",
"chars": 952,
"preview": "# Hugging Face Space部署出现问题的应对\n\n由于本项目在Hugging Face Space中部署较多,疑似被官方封禁,直接拉取项目镜像可能会导致部署失败或Space被停用,如果您在使用中遇到类似的问题,请参考以下的步骤F"
},
{
"path": "doc/Deploy/HuggingFace/Hugging Face Space部署.md",
"chars": 1945,
"preview": "# Hugging Face Space 部署\n\n此部署方式利用 Hugging Face Space 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n> 本项目被Hugging Face标记"
},
{
"path": "doc/Deploy/Koyeb/Koyeb部署.md",
"chars": 1385,
"preview": "# Koyeb 部署\n\n此部署方式利用 Koyeb 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n1. **准备 GitHub 仓库和 PAT**(不可跳过):\n \n * 你需要一个"
},
{
"path": "doc/Deploy/Local/本地部署.md",
"chars": 2563,
"preview": "# 本地部署指南\n\n## Node.js 部署\n\n此方式适合本地开发和测试。\n\n1. **克隆仓库**:\n \n ```bash\n git clone https://github.com/dreamhartley/J"
},
{
"path": "doc/Deploy/Render/Render部署.md",
"chars": 1531,
"preview": "# Render 部署\n\n此部署方式利用 Render 的 Docker 环境运行,并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n1. **准备 GitHub 仓库和 PAT**:\n \n * 你需要一个**自己"
},
{
"path": "doc/Deploy/Uptimerobot/配置Uptimerrobot.md",
"chars": 531,
"preview": "# 使用 Uptimerrobot 保活容器\n\n1. **注册 Uptimerrobot**\n * 访问 [Uptimerrobot](https://uptimerobot.com/),选择登陆或创建一个账号,创建账号时使用邮箱进行创"
},
{
"path": "doc/Usage/KEEPALIVE.md",
"chars": 288,
"preview": "# 功能介绍: KEEPALIVE模式\n\n## Docker/Hugging Face Space/Node.js\n\n在网页设置中开启`KEEPALIVE`开关,可以启用KEEPALIVE响应模式。\n\n启用KEEPALIVE模式后,使用流式"
},
{
"path": "doc/Usage/Vertex/Vertex代理配置.md",
"chars": 1583,
"preview": "# Vertex 代理配置\n\n通过配置 `VERTEX` 环境变量,启用 Vertex 代理功能,添加使用 Vertex AI 平台的 Gemini 模型。\n\n---\n\n## 启用 Generative Language API\t\n\n * "
},
{
"path": "doc/Usage/在客户端中使用.md",
"chars": 0,
"preview": ""
},
{
"path": "doc/Usage/配置API连接.md",
"chars": 2067,
"preview": "# 在管理UI中配置API连接\n\n## 添加 AI Studio 密钥\n\n * 在 `Gemini API Keys` 界面中的 `API Key Value` 中填写并添加 Gemini Api 密钥,可选择批量进行添加,批量添加时使用英"
},
{
"path": "doc/项目介绍.md",
"chars": 1165,
"preview": "# JimiHub\n\n## 项目简介\n\nJimiHub 是一款将 Gemini API 请求转换为 OpenAI API 格式的代理工具,支持多个项目轮询、密钥管理分发等功能。\n\n## 主要功能\n\n- **简洁的管理界面**\n- **支持流"
},
{
"path": "docker-compose.yml",
"chars": 199,
"preview": "version: '3.8' \nservices:\n app:\n image: dreamhartley705/jimihub:latest\n container_name: jimihub\n ports:\n "
},
{
"path": "get-jimihub.sh",
"chars": 13327,
"preview": "#!/bin/bash\n\n# JimiHub管理脚本\n# 版本: 1.0\n\n# 定义颜色\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nN"
},
{
"path": "package.json",
"chars": 780,
"preview": "{\n \"name\": \"jimihub\",\n \"version\": \"1.0.0\",\n \"main\": \"src/index.js\",\n \"scripts\": {\n \"test\": \"echo \\\"Error: no test"
},
{
"path": "public/admin/index.html",
"chars": 34351,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-wi"
},
{
"path": "public/admin/script.js",
"chars": 115046,
"preview": "document.addEventListener('DOMContentLoaded', () => {\n // --- UI Elements ---\n const authCheckingUI = document.get"
},
{
"path": "public/admin/style.css",
"chars": 19298,
"preview": "/* Optional: Add custom CSS rules here if needed */\n\n/* Light mode warm background colors */\nbody[data-theme=\"light\"] {\n"
},
{
"path": "public/admin/version.txt",
"chars": 5,
"preview": "1.3.4"
},
{
"path": "public/i18n.js",
"chars": 22596,
"preview": "// 国际化配置和管理脚本\nclass I18n {\n constructor() {\n this.currentLanguage = 'zh'; // 默认中文\n this.translations = "
},
{
"path": "public/login.html",
"chars": 3258,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-wi"
},
{
"path": "public/login.script.js",
"chars": 2043,
"preview": "document.addEventListener('DOMContentLoaded', () => {\n const loginForm = document.getElementById('login-form');\n c"
},
{
"path": "src/db/index.js",
"chars": 9820,
"preview": "const sqlite3 = require('sqlite3').verbose();\nconst path = require('path');\nconst fs = require('fs');\nconst GitHubSync ="
},
{
"path": "src/index.js",
"chars": 5367,
"preview": "// Load environment variables from .env file FIRST\nrequire('dotenv').config();\n\nconst express = require('express');\ncons"
},
{
"path": "src/middleware/adminAuth.js",
"chars": 1565,
"preview": "const { verifySessionCookie } = require('../utils/session');\n\n/**\n * Express middleware to protect routes requiring admi"
},
{
"path": "src/middleware/workerAuth.js",
"chars": 2121,
"preview": "const dbModule = require('../db'); // Import the database module\n\n/**\n * Express middleware to validate the Worker API K"
},
{
"path": "src/routes/adminApi.js",
"chars": 30367,
"preview": "const express = require('express');\nconst requireAdminAuth = require('../middleware/adminAuth');\nconst configService = r"
},
{
"path": "src/routes/apiV1.js",
"chars": 41472,
"preview": "// src/routes/apiV1.js\n\nconst express = require('express');\nconst { Readable, Transform } = require('stream'); // For ha"
},
{
"path": "src/routes/auth.js",
"chars": 2572,
"preview": "const express = require('express');\nconst { generateSessionToken, setSessionCookie, clearSessionCookie } = require('../u"
},
{
"path": "src/services/batchTestService.js",
"chars": 9558,
"preview": "const fetch = require('node-fetch');\nconst configService = require('./configService');\nconst geminiKeyService = require("
},
{
"path": "src/services/configService.js",
"chars": 16836,
"preview": "const dbModule = require('../db');\n\n// --- Helper Functions for DB Interaction ---\n\n/**\n * Helper function to get databa"
},
{
"path": "src/services/geminiKeyService.js",
"chars": 52697,
"preview": "const dbModule = require('../db');\nconst configService = require('./configService'); // Use configService for DB helpers"
},
{
"path": "src/services/geminiProxyService.js",
"chars": 25831,
"preview": "const fetch = require('node-fetch');\nconst { Readable } = require('stream');\nconst { URL } = require('url'); // Import U"
},
{
"path": "src/services/schedulerService.js",
"chars": 4558,
"preview": "const cron = require('node-cron');\nconst configService = require('./configService');\nconst batchTestService = require('."
},
{
"path": "src/services/vertexProxyService.js",
"chars": 47436,
"preview": "const fetch = require('node-fetch');\nconst { Readable, Transform } = require('stream'); // Import Transform\nconst fs = r"
},
{
"path": "src/utils/githubSync.js",
"chars": 11325,
"preview": "const { Octokit } = require('@octokit/rest');\nconst fs = require('fs').promises;\nconst path = require('path');\nconst cry"
},
{
"path": "src/utils/helpers.js",
"chars": 2512,
"preview": "/**\n * Helper function to get today's date in Los Angeles timezone (YYYY-MM-DD format)\n * Uses a more reliable method fo"
},
{
"path": "src/utils/proxyPool.js",
"chars": 2911,
"preview": "let SocksProxyAgent; // Declare variable\ntry {\n SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent; // Tr"
},
{
"path": "src/utils/session.js",
"chars": 5174,
"preview": "const crypto = require('crypto');\n\nconst SESSION_COOKIE_NAME = '__session';\nconst SESSION_DURATION_SECONDS = 1 * 60 * 60"
},
{
"path": "src/utils/transform.js",
"chars": 20408,
"preview": "// --- Transformation logic migrated from Cloudflare Worker ---\n\n/**\n * Parses a data URI string.\n * @param {string} dat"
}
]
About this extraction
This page contains the full source code of the dreamhartley/gemini-proxy-panel GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 50 files (547.7 KB), approximately 126.1k tokens, and a symbol index with 160 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.