diff --git a/.codex/skills/chatgpt2api-search/SKILL.md b/.codex/skills/chatgpt2api-search/SKILL.md
new file mode 100644
index 0000000000000000000000000000000000000000..b2b65cf33be28926a25af259fe629b2c14318f85
--- /dev/null
+++ b/.codex/skills/chatgpt2api-search/SKILL.md
@@ -0,0 +1,27 @@
+---
+name: chatgpt2api-search
+description: Use when current web search is needed through this chatgpt2api server. Call the configured HTTP search endpoint with a prompt and return the answer with source URLs.
+---
+
+# ChatGPT2API Search
+
+Use this skill when the user asks for current web search, online lookup, recent information, or source-backed answers.
+
+## Endpoint
+
+POST http://127.0.0.1:8000/v1/search
+
+Headers:
+
+Authorization: Bearer chatgpt2api
+Content-Type: application/json
+
+Body:
+
+{
+ "prompt": ""
+}
+
+## Response
+
+Use `answer` as the main response. Include URLs from `sources` when available.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..c66c3f8b6280b59f75bdc258366070ef6896fdf4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,14 @@
+.git
+.gitignore
+.idea
+.venv
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+
+web/.next
+web/node_modules
+
+assets
+README.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..a8eb2763e242c01b10d59680c7f03b53f8cfebf9
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,48 @@
+# ============================================
+# ChatGPT2API 环境变量配置示例
+# ============================================
+
+# 认证密钥(必填)
+CHATGPT2API_AUTH_KEY=your_secret_key_here
+
+# 基础 URL(可选,用于生成图片 URL)
+# CHATGPT2API_BASE_URL=https://your-domain.com
+
+# ============================================
+# 存储后端配置
+# ============================================
+
+# 存储后端类型(可选值: json, sqlite, postgres, git)
+# 默认: json
+STORAGE_BACKEND=json
+
+# ============================================
+# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用)
+# ============================================
+
+# PostgreSQL 示例
+# DATABASE_URL=postgresql://user:password@localhost:5432/chatgpt2api
+
+# Supabase 示例
+# DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres
+
+# SQLite 示例(不指定时自动使用 data/accounts.db)
+# DATABASE_URL=sqlite:///app/data/accounts.db
+
+# ============================================
+# Git 仓库配置(当 STORAGE_BACKEND=git 时使用)
+# ============================================
+
+# Git 仓库地址(必填)
+# GIT_REPO_URL=https://github.com/your-username/your-private-repo.git
+
+# Git 访问令牌(必填)
+# GitHub: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# GitLab: glpat-xxxxxxxxxxxxxxxxxxxx
+# GIT_TOKEN=your_git_token_here
+
+# Git 分支(可选,默认: main)
+# GIT_BRANCH=main
+
+# Git 仓库中的文件路径(可选,默认: accounts.json)
+# GIT_FILE_PATH=accounts.json
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f7bdd844802ca267ba5343d1865ada80e28a3d63
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,61 @@
+name: Publish Docker Image
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+env:
+ IMAGE_NAME: chatgpt2api
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=ref,event=tag
+ type=sha
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+
+ - name: Build and push image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ target: app
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..8ef13f20322ca7cff93d5fde8fb5f44204a47df8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# Python-generated files
+__pycache__/
+*.py[oc]
+build/
+dist/
+wheels/
+*.egg-info
+web_dist
+# Virtual environments
+.venv
+.idea
+data
+config.json
+edit
+
+docker-compose-local.yml
+
+.claude/settings.local.json
+
+# Environment variables
+.env
+.env.local
+
+# Database files
+*.db
+*.sqlite
+*.sqlite3
+
+# Git cache
+git_cache/
+
+# 调试/测试输出目录
+loginwithpassword_*/
+logwithemailverifycode_*/
+test_batch_output/
\ No newline at end of file
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000000000000000000000000000000000000..24ee5b1be9961e38a503c8e764b7385dbb6ba124
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.13
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000000000000000000000000000000000..927b084ccca30b4a4074a90a42148d79559c535d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,52 @@
+# Changelog
+
+## Unreleased
+
++ [新增] 账号刷新改为异步模式,支持前端轮询刷新/重新登录进度。
++ [新增] 号池管理页面新增重新登录功能,支持密码登录恢复异常账号。
++ [新增] 刷新后自动重新登录异常账号(可在设置页开启)。
++ [新增] 图片生成支持并行模式,多张图片使用独立线程和账号同时生成。
++ [新增] 图片轮询超时自动换账号重试(最多4次),连接超时同账号递增等待重试。
++ [新增] 图片二次确认机制与先check再hit可配置化,关闭后可跳过等待直接返回结果。
++ [新增] 图片任务进度追踪,显示当前生成步骤(上传/预热/获取token/生成中等)。
++ [新增] 图片超时后续轮询功能,前端显示"继续等待"按钮。
++ [新增] 设置页新增图片二次确认、超时等待时间、自动重新登录等配置项。
++ [优化] 优化生图页面滚动加载性能,图片懒加载、会话切换滚动位置保存与恢复。
++ [优化] 图片结果页 LazyImage 懒加载,减少首屏渲染压力。
++ [修复] 图片服务响应添加 CORS 头,修复跨域访问问题。
++ [修复] Checkbox 图标颜色修复,确保可见性。
+
+## 1.4.0 - 2026-05-31
+
++ [新增] 新增AI生成可编辑PSD文件逆向。
++ [新增] 新增AI生成可编辑PPT文件逆向。
+
+## 1.3.1 - 2026-05-30
+
++ [新增] 新增ChatGPT搜索调试、Skills。
+
+## 1.3.0 - 2026-05-30
+
++ [新增] 新增ChatGPT搜索接口逆向。
+
+## 1.2.4 - 2026-05-30
+
++ [新增] 添加聊天补全缓存与重复请求合并。
++ [新增] 新增无限画布一键跳转功能
+
+## 1.2.3 - 2026-05-29
+
++ [新增] 新增账号级代理。
++ [修复] 修复503异常信息、前端邮箱换行问题。
+
+## 1.2.2 - 2026-05-29
+
++ [新增] 新增Codex链路生图、支持2k,4k。
++ [新增] 支持RT刷新账号信息。
+
+## 1.2.0 - 2026-05-28
+
++ [新增] 当前版本基线,包含 Web 面板、画图、号池管理、注册机、图片管理、日志管理和设置能力。
++ [新增] 前端版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和更新日志。
++ [优化] 优化注册机效率,成功率大幅提高。
++ [优化] 优化生图页面配置选项。
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..3fd89aac6cc7c99d5c8648c5dca8bc9752b68559
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,56 @@
+ARG BUILDPLATFORM
+ARG TARGETPLATFORM
+ARG TARGETARCH
+
+FROM --platform=$BUILDPLATFORM node:22-alpine AS web-build
+
+WORKDIR /app/web
+
+COPY web/package.json web/bun.lock ./
+RUN npm install
+
+COPY VERSION /app/VERSION
+COPY CHANGELOG.md /app/CHANGELOG.md
+COPY web ./
+RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build
+
+
+FROM --platform=$TARGETPLATFORM python:3.13-slim AS app
+
+ARG TARGETPLATFORM
+ARG TARGETARCH
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ UV_LINK_MODE=copy
+
+WORKDIR /app
+
+# 安装系统依赖
+# - git: Git 存储后端需要
+# - libpq-dev: PostgreSQL 客户端库
+# - gcc: 编译 psycopg2-binary 需要
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git \
+ libpq-dev \
+ gcc \
+ openssl \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN pip install --no-cache-dir uv
+
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-dev --no-install-project
+
+COPY main.py ./
+COPY config.json ./
+COPY VERSION ./
+COPY api ./api
+COPY services ./services
+COPY utils ./utils
+COPY scripts ./scripts
+COPY --from=web-build /app/web/out ./web_dist
+
+EXPOSE 7860
+
+CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--access-log"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..58e8f6506556e2827432bd52cc9fbf3d13b5bd70
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 kunkun
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 01cc872de64aec2b492507ea0e0f9f69e20976d0..d7d40f6e7861e037fa2b62bcb9b6fedf39670ba1 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,348 @@
---
-title: Chatgpt2api
-emoji: 😻
-colorFrom: red
-colorTo: purple
+title: ChatGPT2API
sdk: docker
+app_port: 7860
pinned: false
---
-Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
+ChatGPT2API
+
+
+ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。
+
+> [!WARNING]
+> 免责声明:
+>
+> 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。
+>
+> - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。
+> - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。
+> - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
+> - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
+> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
+> - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
+
+## 快速开始
+
+已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
+
+### Docker 运行
+
+```bash
+git clone git@github.com:basketikun/chatgpt2api.git
+cd chatgpt2api
+docker compose up -d
+```
+
+启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。
+
+- Web 面板:`http://localhost:3000`
+- API 地址:`http://localhost:3000/v1`
+- 数据目录:`./data`
+
+### 本地开发
+
+启动后端:
+
+```bash
+git clone git@github.com:basketikun/chatgpt2api.git
+cd chatgpt2api
+uv sync
+uv run main.py
+```
+
+启动前端:
+
+```bash
+cd chatgpt2api/web
+bun install
+bun run dev
+```
+
+后续更新新版本:
+
+```bash
+docker pull ghcr.io/basketikun/chatgpt2api:latest
+docker-compose down
+docker-compose up -d
+
+```
+
+### 存储后端配置
+
+支持通过环境变量 `STORAGE_BACKEND` 切换存储方式:
+
+- `json` - 本地 JSON 文件(默认)
+- `sqlite` - 本地 SQLite 数据库
+- `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`)
+- `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`)
+
+示例:使用 PostgreSQL
+
+```yaml
+environment:
+ - STORAGE_BACKEND=postgres
+ - DATABASE_URL=postgresql://user:password@host:5432/dbname
+```
+
+## 功能
+
+### API 兼容能力
+
+- 兼容 `POST /v1/images/generations` 图片生成接口
+- 兼容 `POST /v1/images/edits` 图片编辑接口
+- 兼容面向图片场景的 `POST /v1/chat/completions`
+- 兼容面向图片场景的 `POST /v1/responses`
+- `GET /v1/models` 返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、
+ `gpt-5-mini`
+- 支持通过 `n` 返回多张生成结果
+- 支持生成可编辑 PPT 文件
+- 支持生成可编辑 PSD 文件
+- 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回
+ `gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度
+
+### 在线画图功能
+
+- 内置在线画图工作台,支持生成、图片编辑与多图组图编辑
+- 支持 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` 模型选择
+- 编辑模式支持参考图上传
+- 前端支持多图生成交互
+- 本地保存图片会话历史,支持回看、删除和清空
+- 支持服务端缓存图片URL
+- 图片生成进度追踪,超时后可继续等待
+- 图片懒加载与滚动位置记忆,优化大量图片场景性能
+
+### 号池管理功能
+
+- 自动刷新账号邮箱、类型、额度和恢复时间(异步进度追踪)
+- 轮询可用账号执行图片生成与图片编辑
+- 遇到 Token 失效类错误时自动剔除无效 Token
+- 定时检查限流账号并自动刷新
+- 支持密码重新登录恢复异常账号,刷新后可自动重登
+- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
+- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
+- 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
+- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
+
+### 实验性 / 规划中
+
+- `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
+- `/v1/chat/completions` 文本链路支持短 TTL 缓存、重复请求合并与相邻重复消息清理,可通过 `chat_completion_cache` 配置调整
+- 详细状态说明见:[功能清单](./docs/feature-status.en.md)
+
+## 效果展示
+
+
+
+## API
+
+所有 AI 接口都需要请求头:
+
+```http
+Authorization: Bearer
+```
+
+
+GET /v1/models
+
+
+返回当前暴露的图片模型列表。
+
+```bash
+curl http://localhost:8000/v1/models \
+ -H "Authorization: Bearer "
+```
+
+
+说明
+
+
+| 字段 | 说明 |
+|:-----|:-----------------------------------------------------------------------------------------------------------|
+| 返回模型 | `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` |
+| 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 |
+
+
+
+
+
+
+POST /v1/images/generations
+
+
+OpenAI 兼容图片生成接口,用于文生图。
+
+```bash
+curl http://localhost:8000/v1/images/generations \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer " \
+ -d '{
+ "model": "gpt-image-2",
+ "prompt": "一只漂浮在太空里的猫",
+ "n": 1,
+ "response_format": "b64_json"
+ }'
+```
+
+
+字段说明
+
+
+| 字段 | 说明 |
+|:------------------|:---------------------------------------------------|
+| `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` |
+| `prompt` | 图片生成提示词 |
+| `n` | 生成数量,当前后端限制为 `1-4` |
+| `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` |
+
+
+
+
+
+
+POST /v1/images/edits
+
+
+OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。
+
+```bash
+curl http://localhost:8000/v1/images/edits \
+ -H "Authorization: Bearer " \
+ -F "model=gpt-image-2" \
+ -F "prompt=把这张图改成赛博朋克夜景风格" \
+ -F "n=1" \
+ -F "image=@./input.png"
+```
+
+也可以直接传图片 URL:
+
+```bash
+curl http://localhost:8000/v1/images/edits \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "gpt-image-2",
+ "prompt": "把这张图改成赛博朋克夜景风格",
+ "images": [
+ {"image_url": "https://example.com/input.png"}
+ ]
+ }'
+```
+
+
+字段说明
+
+
+| 字段 | 说明 |
+|:------------|:----------------------------------------------|
+| `model` | 图片模型, `gpt-image-2` |
+| `prompt` | 图片编辑提示词 |
+| `n` | 生成数量,当前后端限制为 `1-4` |
+| `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 |
+| `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` |
+| `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 |
+
+
+
+
+
+
+POST /v1/chat/completions
+
+
+面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。
+
+```bash
+curl http://localhost:8000/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer " \
+ -d '{
+ "model": "gpt-image-2",
+ "messages": [
+ {
+ "role": "user",
+ "content": "生成一张雨夜东京街头的赛博朋克猫"
+ }
+ ],
+ "n": 1
+ }'
+```
+
+
+字段说明
+
+
+| 字段 | 说明 |
+|:-----------|:------------------|
+| `model` | 图片模型,默认按图片生成场景处理 |
+| `messages` | 消息数组,需要是图片相关请求内容 |
+| `n` | 生成数量,按当前实现解析为图片数量 |
+| `stream` | 已实现,但仍在测试 |
+
+
+
+
+
+
+POST /v1/responses
+
+
+面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。
+
+```bash
+curl http://localhost:8000/v1/responses \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer " \
+ -d '{
+ "model": "gpt-5",
+ "input": "生成一张未来感城市天际线图片",
+ "tools": [
+ {
+ "type": "image_generation"
+ }
+ ]
+ }'
+```
+
+
+字段说明
+
+
+| 字段 | 说明 |
+|:---------|:------------------------------|
+| `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 |
+| `input` | 输入内容,需要能解析出图片生成提示词 |
+| `tools` | 必须包含 `image_generation` 工具请求 |
+| `stream` | 已实现,但仍在测试 |
+
+
+
+
+
+## 社区支持
+
+学 AI , 上 L 站:[LinuxDO](https://linux.do)
+
+## Contributors
+
+感谢所有为本项目做出贡献的开发者:
+
+
+
+
+
+## Star History
+
+[](https://www.star-history.com/?repos=basketikun%2Fchatgpt2api&type=date&legend=top-left)
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000000000000000000000000000000000000..e21e727f96fa5a19a233d5260fd3ba4f6ed3837c
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.4.0
\ No newline at end of file
diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..61876d2267671fabdae957b537f7cdedec15c905
--- /dev/null
+++ b/api/__init__.py
@@ -0,0 +1,2 @@
+from api.app import create_app
+
diff --git a/api/accounts.py b/api/accounts.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae2906d31161a762fafecc6a1c52fec2619e7abb
--- /dev/null
+++ b/api/accounts.py
@@ -0,0 +1,537 @@
+from __future__ import annotations
+
+import asyncio
+import io
+import json
+import re
+import uuid
+import zipfile
+from datetime import datetime
+from typing import Any, Literal
+
+from fastapi import APIRouter, Header, HTTPException
+from fastapi.concurrency import run_in_threadpool
+from fastapi.responses import Response
+from pydantic import BaseModel, Field
+
+from services.auth_service import auth_service
+
+from api.support import (
+ require_admin,
+ sanitize_cpa_pool,
+ sanitize_cpa_pools,
+ sanitize_sub2api_server,
+ sanitize_sub2api_servers,
+)
+from services.account_service import account_service
+from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
+from services.oauth_login_service import OAuthLoginError, oauth_login_service
+from services.sub2api_service import (
+ list_remote_accounts as sub2api_list_remote_accounts,
+ list_remote_groups as sub2api_list_remote_groups,
+ sub2api_config,
+ sub2api_import_service,
+)
+
+
+
+class UserKeyCreateRequest(BaseModel):
+ name: str = ""
+
+
+class UserKeyUpdateRequest(BaseModel):
+ name: str | None = None
+ enabled: bool | None = None
+ key: str | None = None
+
+
+class AccountCreateRequest(BaseModel):
+ tokens: list[str] = Field(default_factory=list)
+ accounts: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class AccountDeleteRequest(BaseModel):
+ tokens: list[str] = Field(default_factory=list)
+
+
+class AccountRefreshRequest(BaseModel):
+ access_tokens: list[str] = Field(default_factory=list)
+
+
+class AccountExportRequest(BaseModel):
+ access_tokens: list[str] = Field(default_factory=list)
+ format: Literal["json", "zip"] = "json"
+
+
+class AccountUpdateRequest(BaseModel):
+ access_token: str = ""
+ type: str | None = None
+ status: str | None = None
+ quota: int | None = None
+ proxy: str | None = None
+
+
+class CPAPoolCreateRequest(BaseModel):
+ name: str = ""
+ base_url: str = ""
+ secret_key: str = ""
+
+
+class CPAPoolUpdateRequest(BaseModel):
+ name: str | None = None
+ base_url: str | None = None
+ secret_key: str | None = None
+
+
+class CPAImportRequest(BaseModel):
+ names: list[str] = Field(default_factory=list)
+
+
+class Sub2APIServerCreateRequest(BaseModel):
+ name: str = ""
+ base_url: str = ""
+ email: str = ""
+ password: str = ""
+ api_key: str = ""
+ group_id: str = ""
+
+
+class Sub2APIServerUpdateRequest(BaseModel):
+ name: str | None = None
+ base_url: str | None = None
+ email: str | None = None
+ password: str | None = None
+ api_key: str | None = None
+ group_id: str | None = None
+
+
+class Sub2APIImportRequest(BaseModel):
+ account_ids: list[str] = Field(default_factory=list)
+
+
+class OAuthLoginStartRequest(BaseModel):
+ """起始 OAuth 桥。email_hint 可选,仅用于让 OpenAI 登录页预填邮箱。"""
+ email_hint: str = ""
+
+
+class OAuthLoginFinishRequest(BaseModel):
+ """提交 callback。callback 既可以是完整 URL 也可以只填 code。"""
+ session_id: str = ""
+ callback: str = ""
+
+
+def _account_payload_token(item: dict[str, Any]) -> str:
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
+
+
+def _unique_tokens(tokens: list[str]) -> list[str]:
+ return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
+
+
+def _download_timestamp() -> str:
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
+
+
+def _safe_export_name(value: str, fallback: str) -> str:
+ clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
+ return (clean or fallback)[:80]
+
+
+def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
+ buf = io.BytesIO()
+ used_names: set[str] = set()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
+ for index, item in enumerate(items, start=1):
+ raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
+ base_name = _safe_export_name(raw_name, f"account-{index:03d}")
+ name = base_name
+ suffix = 2
+ while name in used_names:
+ name = f"{base_name}-{suffix}"
+ suffix += 1
+ used_names.add(name)
+ archive.writestr(
+ f"{name}.json",
+ json.dumps(item, ensure_ascii=False, indent=2) + "\n",
+ )
+ return buf.getvalue()
+
+
+def create_router() -> APIRouter:
+ router = APIRouter()
+
+ @router.get("/api/auth/users")
+ async def list_user_keys(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"items": auth_service.list_keys(role="user")}
+
+ @router.post("/api/auth/users")
+ async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ item, raw_key = auth_service.create_key(role="user", name=body.name)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+ return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
+
+ @router.post("/api/auth/users/{key_id}")
+ async def update_user_key(
+ key_id: str,
+ body: UserKeyUpdateRequest,
+ authorization: str | None = Header(default=None),
+ ):
+ require_admin(authorization)
+ updates = {
+ key: value
+ for key, value in {
+ "name": body.name,
+ "enabled": body.enabled,
+ "key": body.key,
+ }.items()
+ if value is not None
+ }
+ if not updates:
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
+ try:
+ item = auth_service.update_key(key_id, updates, role="user")
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+ if item is None:
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
+ return {"item": item, "items": auth_service.list_keys(role="user")}
+
+ @router.delete("/api/auth/users/{key_id}")
+ async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ if not auth_service.delete_key(key_id, role="user"):
+ raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
+ return {"items": auth_service.list_keys(role="user")}
+
+ @router.get("/api/accounts")
+ async def get_accounts(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"items": account_service.list_accounts()}
+
+ @router.post("/api/accounts")
+ async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ account_payloads = [item for item in body.accounts if isinstance(item, dict)]
+ payload_tokens = [_account_payload_token(item) for item in account_payloads]
+ tokens = _unique_tokens([*body.tokens, *payload_tokens])
+ if not tokens:
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
+ if account_payloads:
+ result = account_service.add_account_items(account_payloads)
+ payload_token_set = set(_unique_tokens(payload_tokens))
+ extra_tokens = [token for token in tokens if token not in payload_token_set]
+ if extra_tokens:
+ extra_result = account_service.add_accounts(extra_tokens)
+ result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
+ result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
+ else:
+ result = account_service.add_accounts(tokens)
+ refresh_result = account_service.refresh_accounts(tokens)
+ return {
+ **result,
+ "refreshed": refresh_result.get("refreshed", 0),
+ "errors": refresh_result.get("errors", []),
+ "items": refresh_result.get("items", result.get("items", [])),
+ }
+
+ @router.delete("/api/accounts")
+ async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
+ if not tokens:
+ raise HTTPException(status_code=400, detail={"error": "tokens is required"})
+ return account_service.delete_accounts(tokens)
+
+ @router.post("/api/accounts/refresh")
+ async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
+ if not access_tokens:
+ access_tokens = account_service.list_tokens()
+ if not access_tokens:
+ raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
+
+ progress_id = str(uuid.uuid4())
+
+ async def _do_refresh():
+ try:
+ await run_in_threadpool(account_service.refresh_accounts, access_tokens, progress_id)
+ except Exception as e:
+ account_service.finish_refresh_progress(progress_id, error=str(e))
+
+ asyncio.create_task(_do_refresh())
+
+ return {"progress_id": progress_id}
+
+ @router.get("/api/accounts/refresh/progress/{progress_id}")
+ async def get_refresh_progress(progress_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ progress = account_service.get_refresh_progress(progress_id)
+ if progress is None:
+ raise HTTPException(status_code=404, detail={"error": "progress not found"})
+ return progress
+
+ @router.post("/api/accounts/re-login")
+ async def re_login_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
+ """对选中账号执行密码重新登录流程(密码登录→验证码登录→刷新token)。"""
+ require_admin(authorization)
+ access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
+ if not access_tokens:
+ raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
+
+ progress_id = str(uuid.uuid4())
+
+ async def _do_relogin():
+ try:
+ await run_in_threadpool(account_service.re_login_accounts, access_tokens, progress_id)
+ except Exception as e:
+ account_service.finish_relogin_progress(progress_id, error=str(e))
+
+ asyncio.create_task(_do_relogin())
+
+ return {"progress_id": progress_id}
+
+ @router.get("/api/accounts/re-login/progress/{progress_id}")
+ async def get_relogin_progress(progress_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ progress = account_service.get_relogin_progress(progress_id)
+ if progress is None:
+ raise HTTPException(status_code=404, detail={"error": "progress not found"})
+ return progress
+
+ @router.post("/api/accounts/export")
+ async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ access_tokens = _unique_tokens(body.access_tokens)
+ items = account_service.build_export_items(access_tokens)
+ if not items:
+ raise HTTPException(
+ status_code=400,
+ detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
+ )
+
+ timestamp = _download_timestamp()
+ if body.format == "zip":
+ content = _account_zip_bytes(items)
+ return Response(
+ content,
+ media_type="application/zip",
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
+ )
+
+ payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
+ return Response(
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
+ media_type="application/json",
+ headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
+ )
+
+ @router.post("/api/accounts/update")
+ async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ access_token = str(body.access_token or "").strip()
+ if not access_token:
+ raise HTTPException(status_code=400, detail={"error": "access_token is required"})
+ updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota, "proxy": body.proxy}.items() if value is not None}
+ if not updates:
+ raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
+ account = account_service.update_account(access_token, updates)
+ if account is None:
+ raise HTTPException(status_code=404, detail={"error": "account not found"})
+ return {"item": account, "items": account_service.list_accounts()}
+
+ @router.post("/api/accounts/oauth/start")
+ async def start_oauth_login(
+ body: OAuthLoginStartRequest,
+ authorization: str | None = Header(default=None),
+ ):
+ """登记一次 PKCE 会话,返回可让用户浏览器打开的 authorize URL。"""
+ require_admin(authorization)
+ try:
+ return await run_in_threadpool(oauth_login_service.start, body.email_hint)
+ except OAuthLoginError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.post("/api/accounts/oauth/finish")
+ async def finish_oauth_login(
+ body: OAuthLoginFinishRequest,
+ authorization: str | None = Header(default=None),
+ ):
+ """收用户从浏览器抓回的 callback URL / code,换出 token 三件套并落盘。"""
+ require_admin(authorization)
+ # 入参日志:截断敏感字段,仅保留前几位,方便排错而不泄密
+ cb_preview = (body.callback or "")[:80]
+ sid_preview = (body.session_id or "")[:8]
+ print(
+ f"[oauth-login] finish called: session_id={sid_preview}..., callback_preview={cb_preview!r}",
+ flush=True,
+ )
+ try:
+ tokens = await run_in_threadpool(oauth_login_service.finish, body.session_id, body.callback)
+ except OAuthLoginError as exc:
+ print(f"[oauth-login] finish rejected: {exc}", flush=True)
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ payload = {
+ "access_token": tokens["access_token"],
+ "refresh_token": tokens["refresh_token"],
+ "id_token": tokens["id_token"],
+ "source_type": "oauth_login",
+ }
+ add_result = await run_in_threadpool(account_service.add_account_items, [payload])
+ refresh_result = await run_in_threadpool(
+ account_service.refresh_accounts, [tokens["access_token"]]
+ )
+ return {
+ **add_result,
+ "refreshed": refresh_result.get("refreshed", 0),
+ "errors": refresh_result.get("errors", []),
+ "items": refresh_result.get("items", add_result.get("items", [])),
+ }
+
+ @router.get("/api/cpa/pools")
+ async def list_cpa_pools(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
+
+ @router.post("/api/cpa/pools")
+ async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ if not body.base_url.strip():
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
+ if not body.secret_key.strip():
+ raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
+ pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
+
+ @router.post("/api/cpa/pools/{pool_id}")
+ async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
+ if pool is None:
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
+ return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
+
+ @router.delete("/api/cpa/pools/{pool_id}")
+ async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ if not cpa_config.delete_pool(pool_id):
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
+ return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
+
+ @router.get("/api/cpa/pools/{pool_id}/files")
+ async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ pool = cpa_config.get_pool(pool_id)
+ if pool is None:
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
+ return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
+
+ @router.post("/api/cpa/pools/{pool_id}/import")
+ async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ pool = cpa_config.get_pool(pool_id)
+ if pool is None:
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
+ try:
+ job = cpa_import_service.start_import(pool, body.names)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+ return {"import_job": job}
+
+ @router.get("/api/cpa/pools/{pool_id}/import")
+ async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ pool = cpa_config.get_pool(pool_id)
+ if pool is None:
+ raise HTTPException(status_code=404, detail={"error": "pool not found"})
+ return {"import_job": pool.get("import_job")}
+
+ @router.get("/api/sub2api/servers")
+ async def list_sub2api_servers(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
+
+ @router.post("/api/sub2api/servers")
+ async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ if not body.base_url.strip():
+ raise HTTPException(status_code=400, detail={"error": "base_url is required"})
+ has_login = body.email.strip() and body.password.strip()
+ has_api_key = bool(body.api_key.strip())
+ if not has_login and not has_api_key:
+ raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
+ server = sub2api_config.add_server(
+ name=body.name,
+ base_url=body.base_url,
+ email=body.email,
+ password=body.password,
+ api_key=body.api_key,
+ group_id=body.group_id,
+ )
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
+
+ @router.post("/api/sub2api/servers/{server_id}")
+ async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
+ if server is None:
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
+ return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
+
+ @router.delete("/api/sub2api/servers/{server_id}")
+ async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ if not sub2api_config.delete_server(server_id):
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
+ return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
+
+ @router.get("/api/sub2api/servers/{server_id}/groups")
+ async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ server = sub2api_config.get_server(server_id)
+ if server is None:
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
+ try:
+ groups = await run_in_threadpool(sub2api_list_remote_groups, server)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
+ return {"server_id": server_id, "groups": groups}
+
+ @router.get("/api/sub2api/servers/{server_id}/accounts")
+ async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ server = sub2api_config.get_server(server_id)
+ if server is None:
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
+ try:
+ accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
+ return {"server_id": server_id, "accounts": accounts}
+
+ @router.post("/api/sub2api/servers/{server_id}/import")
+ async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ server = sub2api_config.get_server(server_id)
+ if server is None:
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
+ try:
+ job = sub2api_import_service.start_import(server, body.account_ids)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+ return {"import_job": job}
+
+ @router.get("/api/sub2api/servers/{server_id}/import")
+ async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ server = sub2api_config.get_server(server_id)
+ if server is None:
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
+ return {"import_job": server.get("import_job")}
+
+ return router
diff --git a/api/ai.py b/api/ai.py
new file mode 100644
index 0000000000000000000000000000000000000000..58c7dd9ca5d609e1719bb4974984231a534afcef
--- /dev/null
+++ b/api/ai.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Header, HTTPException, Request
+from fastapi.concurrency import run_in_threadpool
+from fastapi.responses import FileResponse
+from pydantic import BaseModel, ConfigDict, Field
+
+from api.image_inputs import parse_image_edit_request, read_image_sources
+from api.support import require_identity, resolve_image_base_url
+from services.content_filter import check_request, request_shape, request_text
+from services.editable_file_task_service import editable_file_task_service
+from services.log_service import LoggedCall
+from services.protocol import (
+ anthropic_v1_messages,
+ openai_v1_chat_complete,
+ openai_v1_image_edit,
+ openai_v1_image_generations,
+ openai_v1_models,
+ openai_v1_response,
+ openai_search,
+)
+
+
+class ImageGenerationRequest(BaseModel):
+ prompt: str = Field(..., min_length=1)
+ model: str = "gpt-image-2"
+ n: int = Field(default=1, ge=1, le=4)
+ size: str | None = None
+ quality: str = "auto"
+ response_format: str = "b64_json"
+ history_disabled: bool = True
+ stream: bool | None = None
+
+
+class ChatCompletionRequest(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ model: str | None = None
+ prompt: str | None = None
+ n: int | None = None
+ stream: bool | None = None
+ modalities: list[str] | None = None
+ messages: list[dict[str, object]] | None = None
+
+
+class ResponseCreateRequest(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ model: str | None = None
+ input: object | None = None
+ tools: list[dict[str, object]] | None = None
+ tool_choice: object | None = None
+ stream: bool | None = None
+
+
+class AnthropicMessageRequest(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ model: str | None = None
+ messages: list[dict[str, object]] | None = None
+ system: object | None = None
+ stream: bool | None = None
+
+
+class SearchRequest(BaseModel):
+ prompt: str = Field(..., min_length=1)
+
+
+class EditableFileTaskRequest(BaseModel):
+ prompt: str = ""
+ base64_images: list[str] = Field(default_factory=list)
+ client_task_id: str | None = None
+
+
+async def filter_or_log(call: LoggedCall, text: str) -> None:
+ try:
+ await run_in_threadpool(check_request, text)
+ except HTTPException as exc:
+ call.log("调用失败", status="failed", error=str(exc.detail))
+ raise
+
+
+def create_router() -> APIRouter:
+ router = APIRouter()
+
+ @router.get("/v1/models")
+ async def list_models(authorization: str | None = Header(default=None)):
+ require_identity(authorization)
+ try:
+ return await run_in_threadpool(openai_v1_models.list_models)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
+
+ @router.post("/v1/images/generations")
+ async def generate_images(
+ body: ImageGenerationRequest,
+ request: Request,
+ authorization: str | None = Header(default=None),
+ ):
+ identity = require_identity(authorization)
+ payload = body.model_dump(mode="python")
+ payload["base_url"] = resolve_image_base_url(request)
+ call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
+ await filter_or_log(call, body.prompt)
+ return await call.run(openai_v1_image_generations.handle, payload)
+
+ @router.post("/v1/images/edits")
+ async def edit_images(
+ request: Request,
+ authorization: str | None = Header(default=None),
+ ):
+ identity = require_identity(authorization)
+ payload, image_sources = await parse_image_edit_request(request)
+ prompt = str(payload["prompt"])
+ model = str(payload["model"])
+ call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
+ await filter_or_log(call, prompt)
+ payload["images"] = await read_image_sources(image_sources)
+ payload["base_url"] = resolve_image_base_url(request)
+ return await call.run(openai_v1_image_edit.handle, payload)
+
+ @router.post("/v1/chat/completions")
+ async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ payload = body.model_dump(mode="python")
+ model = str(payload.get("model") or "auto")
+ request_preview = request_text(payload.get("prompt"), payload.get("messages"))
+ call = LoggedCall(
+ identity,
+ "/v1/chat/completions",
+ model,
+ "文本生成",
+ request_text=request_preview,
+ request_shape=request_shape(payload.get("messages")),
+ )
+ await filter_or_log(call, request_preview)
+ return await call.run(openai_v1_chat_complete.handle, payload)
+
+ @router.post("/v1/responses")
+ async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ payload = body.model_dump(mode="python")
+ model = str(payload.get("model") or "auto")
+ request_preview = request_text(payload.get("input"), payload.get("instructions"))
+ call = LoggedCall(
+ identity,
+ "/v1/responses",
+ model,
+ "Responses",
+ request_text=request_preview,
+ request_shape=request_shape(payload.get("input")),
+ )
+ await filter_or_log(call, request_preview)
+ return await call.run(openai_v1_response.handle, payload)
+
+ @router.post("/v1/messages")
+ async def create_message(
+ body: AnthropicMessageRequest,
+ authorization: str | None = Header(default=None),
+ x_api_key: str | None = Header(default=None, alias="x-api-key"),
+ anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
+ ):
+ identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
+ payload = body.model_dump(mode="python")
+ model = str(payload.get("model") or "auto")
+ request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
+ call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
+ await filter_or_log(call, request_preview)
+ return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
+
+ @router.post("/v1/search")
+ async def search(body: SearchRequest, authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ call = LoggedCall(identity, "/v1/search", openai_search.MODEL, "搜索", request_text=body.prompt)
+ await filter_or_log(call, body.prompt)
+ return await call.run(openai_search.handle, body.model_dump(mode="python"))
+
+ @router.get("/v1/editable-file-tasks")
+ async def list_editable_file_tasks(ids: str = "", authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ task_ids = [item.strip() for item in ids.split(",") if item.strip()]
+ return await run_in_threadpool(editable_file_task_service.list_tasks, identity, task_ids)
+
+ @router.get("/files/{file_path:path}")
+ async def download_editable_file(file_path: str):
+ try:
+ path = await run_in_threadpool(editable_file_task_service.public_file_path, file_path)
+ except Exception as exc:
+ raise HTTPException(status_code=404, detail={"error": "file not found"}) from exc
+ return FileResponse(path, filename=path.name)
+
+ @router.post("/v1/ppt/generations")
+ async def create_ppt_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ await filter_or_log(LoggedCall(identity, "/v1/ppt/generations", "gpt-5-5-thinking", "PPT生成任务", request_text=body.prompt), body.prompt)
+ return await run_in_threadpool(
+ editable_file_task_service.submit_ppt,
+ identity,
+ client_task_id=body.client_task_id or "",
+ prompt=body.prompt,
+ base64_images=body.base64_images,
+ base_url=resolve_image_base_url(request),
+ )
+
+ @router.post("/v1/psd/generations")
+ async def create_psd_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ await filter_or_log(LoggedCall(identity, "/v1/psd/generations", "gpt-5-5-thinking", "PSD生成任务", request_text=body.prompt), body.prompt)
+ return await run_in_threadpool(
+ editable_file_task_service.submit_psd,
+ identity,
+ client_task_id=body.client_task_id or "",
+ prompt=body.prompt,
+ base64_images=body.base64_images,
+ base_url=resolve_image_base_url(request),
+ )
+
+ return router
diff --git a/api/app.py b/api/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..18f7bb1109e28e9690f9b9e6f5427090c49cfb0b
--- /dev/null
+++ b/api/app.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from threading import Event
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
+
+from api import accounts, ai, image_tasks, register, system
+from api.errors import install_exception_handlers
+from api.support import resolve_web_asset, start_limited_account_watcher
+from services.backup_service import backup_service
+from services.config import config
+from services.image_service import start_image_cleanup_scheduler
+
+
+def create_app() -> FastAPI:
+ app_version = config.app_version
+
+ @asynccontextmanager
+ async def lifespan(_: FastAPI):
+ stop_event = Event()
+ thread = start_limited_account_watcher(stop_event)
+ cleanup_thread = start_image_cleanup_scheduler(stop_event)
+ backup_service.start()
+ config.cleanup_old_images()
+ try:
+ yield
+ finally:
+ stop_event.set()
+ thread.join(timeout=1)
+ cleanup_thread.join(timeout=1)
+ backup_service.stop()
+
+ app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan)
+ install_exception_handlers(app)
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+ app.include_router(ai.create_router())
+ app.include_router(accounts.create_router())
+ app.include_router(image_tasks.create_router())
+ app.include_router(register.create_router())
+ app.include_router(system.create_router(app_version))
+
+ @app.get("/{full_path:path}", include_in_schema=False)
+ async def serve_web(full_path: str):
+ asset = resolve_web_asset(full_path)
+ if asset is not None:
+ return FileResponse(asset)
+ if full_path.strip("/").startswith("_next/"):
+ raise HTTPException(status_code=404, detail="Not Found")
+ fallback = resolve_web_asset("")
+ if fallback is None:
+ raise HTTPException(status_code=404, detail="Not Found")
+ return FileResponse(fallback)
+
+ return app
diff --git a/api/errors.py b/api/errors.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ffd46e07ae96ce9debb68ae841f0f37983b91d0
--- /dev/null
+++ b/api/errors.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from fastapi import FastAPI, Request
+from fastapi.encoders import jsonable_encoder
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+from services.protocol.error_response import anthropic_error_response, openai_error_response
+
+
+def _is_openai_compatible_path(path: str) -> bool:
+ return path == "/v1" or path.startswith("/v1/")
+
+
+def _is_anthropic_messages_path(path: str) -> bool:
+ return path == "/v1/messages"
+
+
+def _compatible_error_response(
+ request: Request,
+ detail: object,
+ status_code: int,
+ headers: dict[str, str] | None = None,
+) -> JSONResponse:
+ if _is_anthropic_messages_path(request.url.path):
+ return anthropic_error_response(detail, status_code, headers=headers)
+ return openai_error_response(detail, status_code, headers=headers)
+
+
+def install_exception_handlers(app: FastAPI) -> None:
+ @app.exception_handler(StarletteHTTPException)
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
+ if _is_openai_compatible_path(request.url.path):
+ return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers)
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"detail": jsonable_encoder(exc.detail)},
+ headers=exc.headers,
+ )
+
+ @app.exception_handler(RequestValidationError)
+ async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
+ if _is_openai_compatible_path(request.url.path):
+ return _compatible_error_response(request, exc.errors(), 422)
+ return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
diff --git a/api/image_inputs.py b/api/image_inputs.py
new file mode 100644
index 0000000000000000000000000000000000000000..31af6e08249243f4703f73a219da23bcd1460e86
--- /dev/null
+++ b/api/image_inputs.py
@@ -0,0 +1,294 @@
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+import mimetypes
+import re
+from pathlib import PurePosixPath
+from typing import Any, TypeGuard
+from urllib.parse import unquote, unquote_to_bytes, urlparse
+
+from curl_cffi import requests
+from fastapi import HTTPException, Request
+from fastapi.concurrency import run_in_threadpool
+from starlette.datastructures import UploadFile
+
+from services.proxy_service import proxy_settings
+
+ImageInput = tuple[bytes, str, str]
+ImageSource = str | UploadFile | ImageInput
+
+MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024
+IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"}
+
+
+def _clean(value: object, default: str = "") -> str:
+ """清理字符串:转换为字符串并去掉首尾空白。"""
+ text = str(value if value is not None else default).strip()
+ return text or default
+
+
+def _is_upload(value: object) -> TypeGuard[UploadFile]:
+ """识别上传文件:兼容 Starlette 表单返回的 UploadFile。"""
+ return isinstance(value, UploadFile)
+
+
+def _parse_bool(value: object) -> bool | None:
+ """解析布尔字段:兼容 JSON 布尔值和表单字符串。"""
+ if value is None or value == "":
+ return None
+ if isinstance(value, bool):
+ return value
+ text = _clean(value).lower()
+ if text in {"true", "1", "yes", "y", "on"}:
+ return True
+ if text in {"false", "0", "no", "n", "off"}:
+ return False
+ raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"})
+
+
+def _parse_count(value: object) -> int:
+ """解析生成数量:保持图片接口的 1 到 4 限制。"""
+ try:
+ count = int(value or 1)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
+ if count < 1 or count > 4:
+ raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
+ return count
+
+
+def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
+ """构造图片编辑载荷:从表单或 JSON 字段提取通用参数。"""
+ prompt = _clean(fields.get("prompt"))
+ if not prompt:
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
+ payload = {
+ "prompt": prompt,
+ "model": _clean(fields.get("model"), "gpt-image-2"),
+ "n": _parse_count(fields.get("n")),
+ "size": _clean(fields.get("size")) or None,
+ "quality": _clean(fields.get("quality"), "auto"),
+ "response_format": _clean(fields.get("response_format"), "b64_json"),
+ "stream": _parse_bool(fields.get("stream")),
+ }
+ if "client_task_id" in fields:
+ payload["client_task_id"] = _clean(fields.get("client_task_id"))
+ return payload
+
+
+def _json_reference_value(value: object) -> object:
+ """解析表单图片引用:支持把 images 字段写成 JSON 字符串。"""
+ if not isinstance(value, str):
+ return value
+ text = value.strip()
+ if not text or text[0] not in "[{":
+ return value
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ return value
+
+
+def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput:
+ try:
+ data = base64.b64decode(str(value).strip(), validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
+ if not data:
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
+ return data, filename, mime_type
+
+
+def _source_from_object(value: dict[str, Any]) -> list[ImageSource]:
+ """提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。"""
+ has_url = "image_url" in value or "url" in value
+ if value.get("file_id"):
+ raise HTTPException(
+ status_code=400,
+ detail={"error": "file_id image references are not supported; use image_url instead"},
+ )
+ inline = value.get("b64_json") or value.get("base64")
+ if inline:
+ filename = _clean(value.get("filename") or value.get("file_name"), "image.png")
+ mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png")
+ return [_decode_base64_image(inline, filename, mime_type)]
+ if not has_url:
+ raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"})
+ image_url = value.get("image_url", value.get("url"))
+ if isinstance(image_url, dict):
+ image_url = image_url.get("url")
+ return _sources_from_value(image_url)
+
+
+def _sources_from_value(value: object) -> list[ImageSource]:
+ """展开图片引用:把字符串、数组和对象统一成图片来源列表。"""
+ value = _json_reference_value(value)
+ if _is_upload(value):
+ return [value]
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ return []
+ if text.lower().startswith(("data:", "http://", "https://")):
+ return [text]
+ return [_decode_base64_image(text, "image.png", "image/png")]
+ if isinstance(value, list):
+ sources: list[ImageSource] = []
+ for item in value:
+ sources.extend(_sources_from_value(item))
+ return sources
+ if isinstance(value, dict):
+ return _source_from_object(value)
+ if value is None:
+ return []
+ raise HTTPException(status_code=400, detail={"error": "invalid image reference"})
+
+
+def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]:
+ """读取 JSON 图片引用:优先支持官方 images 数组字段。"""
+ sources: list[ImageSource] = []
+ for key in ("images", "image", "image_url"):
+ if key in body:
+ sources.extend(_sources_from_value(body.get(key)))
+ return sources
+
+
+async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource]]:
+ """解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。"""
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+ if content_type == "application/json":
+ try:
+ body = await request.json()
+ except json.JSONDecodeError as exc:
+ raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc
+ if not isinstance(body, dict):
+ raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"})
+ return _payload_from_fields(body), _json_image_sources(body)
+
+ form = await request.form()
+ fields: dict[str, Any] = {}
+ for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
+ value = form.get(key)
+ if isinstance(value, str):
+ fields[key] = value
+ sources: list[ImageSource] = []
+ for key, value in form.multi_items():
+ if key in IMAGE_REFERENCE_FIELDS:
+ sources.extend(_sources_from_value(value))
+ return _payload_from_fields(fields), sources
+
+
+def _extension_from_mime(mime_type: str) -> str:
+ """推导图片扩展名:把 MIME 类型转换为常见文件后缀。"""
+ subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png"
+ if subtype == "jpeg":
+ return "jpg"
+ return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png"
+
+
+def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
+ """生成安全文件名:清理 URL 文件名并补齐扩展名。"""
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
+ if not cleaned:
+ cleaned = fallback
+ if "." not in cleaned:
+ cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}"
+ return cleaned
+
+
+def _decode_data_url(url: str) -> ImageInput:
+ """解码 data URL:把内联图片转成标准图片输入元组。"""
+ header, separator, payload = url.partition(",")
+ if not separator:
+ raise HTTPException(status_code=400, detail={"error": "invalid data image URL"})
+ mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
+ if not mime_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
+ try:
+ data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload)
+ except (binascii.Error, ValueError) as exc:
+ raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc
+ if not data:
+ raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
+ return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
+
+
+def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
+ """识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
+ header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
+ guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
+ if header_type.startswith("image/"):
+ return header_type
+ if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
+ if guessed_type.startswith("image/"):
+ return guessed_type
+ if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
+ return "image/png"
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
+
+
+def _filename_from_url(parsed_path: str, mime_type: str) -> str:
+ """生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
+ raw_name = PurePosixPath(unquote(parsed_path)).name
+ return _safe_filename(raw_name, mime_type, "image_url")
+
+
+def _download_image_url(url: str) -> ImageInput:
+ """下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
+ source = _clean(url)
+ if source.startswith("data:"):
+ return _decode_data_url(source)
+ parsed = urlparse(source)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
+ try:
+ response = requests.get(
+ source,
+ headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
+ timeout=60,
+ allow_redirects=True,
+ **proxy_settings.build_session_kwargs(),
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
+ if not 200 <= response.status_code < 300:
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
+ content_length = _clean(response.headers.get("content-length"))
+ if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
+ data = response.content
+ if not data:
+ raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
+ if len(data) > MAX_IMAGE_REFERENCE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
+ mime_type = _response_mime_type(response, parsed.path)
+ return data, _filename_from_url(parsed.path, mime_type), mime_type
+
+
+async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
+ """读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
+ images: list[ImageInput] = []
+ for source in sources:
+ if isinstance(source, tuple):
+ images.append(source)
+ continue
+ if _is_upload(source):
+ try:
+ image_data = await source.read()
+ finally:
+ await source.close()
+ if not image_data:
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
+ images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
+ continue
+ images.append(await run_in_threadpool(_download_image_url, source))
+ if not images:
+ raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
+ return images
diff --git a/api/image_tasks.py b/api/image_tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..530913b8369ab28beefa375108064925c63f7b74
--- /dev/null
+++ b/api/image_tasks.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Header, HTTPException, Query, Request
+from fastapi.concurrency import run_in_threadpool
+from pydantic import BaseModel, Field
+
+from api.image_inputs import parse_image_edit_request, read_image_sources
+from api.support import require_identity, resolve_image_base_url
+from services.content_filter import check_request
+from services.image_task_service import image_task_service
+from services.log_service import LoggedCall
+
+
+class ImageGenerationTaskRequest(BaseModel):
+ client_task_id: str = Field(..., min_length=1)
+ prompt: str = Field(..., min_length=1)
+ model: str = "gpt-image-2"
+ size: str | None = None
+ quality: str = "auto"
+
+
+class ResumePollRequest(BaseModel):
+ extra_timeout_secs: float = Field(default=30.0, ge=5.0, le=120.0)
+
+
+def _parse_task_ids(value: str) -> list[str]:
+ return [item.strip() for item in value.split(",") if item.strip()]
+
+
+async def filter_or_log(call: LoggedCall, text: str) -> None:
+ try:
+ await run_in_threadpool(check_request, text)
+ except HTTPException as exc:
+ call.log("调用失败", status="failed", error=str(exc.detail))
+ raise
+
+
+def create_router() -> APIRouter:
+ router = APIRouter()
+
+ @router.get("/api/image-tasks")
+ async def list_image_tasks(
+ ids: str = Query(default=""),
+ authorization: str | None = Header(default=None),
+ ):
+ identity = require_identity(authorization)
+ return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
+
+ @router.post("/api/image-tasks/generations")
+ async def create_generation_task(
+ body: ImageGenerationTaskRequest,
+ request: Request,
+ authorization: str | None = Header(default=None),
+ ):
+ identity = require_identity(authorization)
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
+ try:
+ return await run_in_threadpool(
+ image_task_service.submit_generation,
+ identity,
+ client_task_id=body.client_task_id,
+ prompt=body.prompt,
+ model=body.model,
+ size=body.size,
+ quality=body.quality,
+ base_url=resolve_image_base_url(request),
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.post("/api/image-tasks/edits")
+ async def create_edit_task(
+ request: Request,
+ authorization: str | None = Header(default=None),
+ ):
+ identity = require_identity(authorization)
+ payload, image_sources = await parse_image_edit_request(request)
+ client_task_id = str(payload.get("client_task_id") or "").strip()
+ if not client_task_id:
+ raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
+ prompt = str(payload["prompt"])
+ model = str(payload["model"])
+ await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
+ images = await read_image_sources(image_sources)
+ try:
+ return await run_in_threadpool(
+ image_task_service.submit_edit,
+ identity,
+ client_task_id=client_task_id,
+ prompt=prompt,
+ model=model,
+ size=payload["size"],
+ quality=payload["quality"],
+ base_url=resolve_image_base_url(request),
+ images=images,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.post("/api/image-tasks/{task_id}/resume-poll")
+ async def resume_image_poll(
+ task_id: str,
+ body: ResumePollRequest,
+ request: Request,
+ authorization: str | None = Header(default=None),
+ ):
+ identity = require_identity(authorization)
+ try:
+ return await run_in_threadpool(
+ image_task_service.resume_poll,
+ identity,
+ task_id,
+ body.extra_timeout_secs,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ return router
diff --git a/api/register.py b/api/register.py
new file mode 100644
index 0000000000000000000000000000000000000000..859e1a21701b986e7bcaf6ed2315500d1bcc4eda
--- /dev/null
+++ b/api/register.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import asyncio
+import json
+
+from fastapi import APIRouter, Header
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from api.support import require_admin
+from services.register_service import register_service
+
+
+class RegisterConfigRequest(BaseModel):
+ mail: dict | None = None
+ proxy: str | None = None
+ total: int | None = None
+ threads: int | None = None
+ mode: str | None = None
+ target_quota: int | None = None
+ target_available: int | None = None
+ check_interval: int | None = None
+
+
+def create_router() -> APIRouter:
+ router = APIRouter()
+
+ @router.get("/api/register")
+ async def get_register_config(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"register": register_service.get()}
+
+ @router.post("/api/register")
+ async def update_register_config(body: RegisterConfigRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"register": register_service.update(body.model_dump(exclude_none=True))}
+
+ @router.post("/api/register/start")
+ async def start_register(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"register": register_service.start()}
+
+ @router.post("/api/register/stop")
+ async def stop_register(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"register": register_service.stop()}
+
+ @router.post("/api/register/reset")
+ async def reset_register(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"register": register_service.reset()}
+
+ @router.get("/api/register/events")
+ async def register_events(token: str = ""):
+ require_admin(f"Bearer {token}")
+
+ async def stream():
+ last = ""
+ while True:
+ payload = json.dumps(register_service.get(), ensure_ascii=False)
+ if payload != last:
+ last = payload
+ yield f"data: {payload}\n\n"
+ await asyncio.sleep(0.5)
+
+ return StreamingResponse(stream(), media_type="text/event-stream")
+
+ return router
diff --git a/api/support.py b/api/support.py
new file mode 100644
index 0000000000000000000000000000000000000000..0132da00eaa50703455f7eb668900a176c372896
--- /dev/null
+++ b/api/support.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+from pathlib import Path
+from threading import Event, Thread
+
+from fastapi import HTTPException, Request
+
+from services.account_service import account_service
+from services.auth_service import auth_service
+from services.config import config
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+WEB_DIST_DIR = BASE_DIR / "web_dist"
+
+
+def extract_bearer_token(authorization: str | None) -> str:
+ scheme, _, value = str(authorization or "").partition(" ")
+ if scheme.lower() != "bearer" or not value.strip():
+ return ""
+ return value.strip()
+
+
+def _legacy_admin_identity(token: str) -> dict[str, object] | None:
+ auth_key = str(config.auth_key or "").strip()
+ if auth_key and token == auth_key:
+ return {"id": "admin", "name": "管理员", "role": "admin"}
+ return None
+
+
+def require_identity(authorization: str | None) -> dict[str, object]:
+ token = extract_bearer_token(authorization)
+ identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
+ if identity is None:
+ raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
+ return identity
+
+
+def require_auth_key(authorization: str | None) -> None:
+ require_identity(authorization)
+
+
+def require_admin(authorization: str | None) -> dict[str, object]:
+ identity = require_identity(authorization)
+ if identity.get("role") != "admin":
+ raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
+ return identity
+
+
+def resolve_image_base_url(request: Request) -> str:
+ return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}"
+
+
+def raise_image_quota_error(exc: Exception) -> None:
+ message = str(exc)
+ if "no available image quota" in message.lower():
+ raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc
+ raise HTTPException(status_code=502, detail={"error": message}) from exc
+
+
+def sanitize_cpa_pool(pool: dict | None) -> dict | None:
+ if not isinstance(pool, dict):
+ return None
+ return {key: value for key, value in pool.items() if key != "secret_key"}
+
+
+def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
+ return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
+
+
+def sanitize_sub2api_server(server: dict | None) -> dict | None:
+ if not isinstance(server, dict):
+ return None
+ sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}}
+ sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip())
+ return sanitized
+
+
+def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]:
+ return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None]
+
+
+def start_limited_account_watcher(stop_event: Event) -> Thread:
+ interval_seconds = config.refresh_account_interval_minute * 60
+
+ def worker() -> None:
+ while not stop_event.is_set():
+ try:
+ limited_tokens = account_service.list_limited_tokens()
+ expiring_tokens = account_service.list_expiring_access_tokens()
+ keepalive_tokens = account_service.list_refresh_token_keepalive_tokens()
+ tokens = list(dict.fromkeys([*limited_tokens, *expiring_tokens]))
+ expiring_token_set = set(expiring_tokens)
+ keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set]
+ if tokens:
+ print(
+ "[account-watcher] checking "
+ f"{len(limited_tokens)} limited accounts, "
+ f"{len(expiring_tokens)} expiring access tokens"
+ )
+ account_service.refresh_accounts(tokens)
+ if keepalive_tokens:
+ print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens")
+ result = account_service.keepalive_refresh_tokens(keepalive_tokens)
+ if result.get("errors"):
+ print(f"[account-watcher] keepalive errors: {result['errors']}")
+ except Exception as exc:
+ print(f"[account-watcher] fail {exc}")
+ stop_event.wait(interval_seconds)
+
+ thread = Thread(target=worker, name="account-watcher", daemon=True)
+ thread.start()
+ return thread
+
+
+def resolve_web_asset(requested_path: str) -> Path | None:
+ if not WEB_DIST_DIR.exists():
+ return None
+ clean_path = requested_path.strip("/")
+ base_dir = WEB_DIST_DIR.resolve()
+ candidates = [base_dir / "index.html"] if not clean_path else [
+ base_dir / Path(clean_path),
+ base_dir / clean_path / "index.html",
+ base_dir / f"{clean_path}.html",
+ ]
+ for candidate in candidates:
+ try:
+ candidate.resolve().relative_to(base_dir)
+ except ValueError:
+ continue
+ if candidate.is_file():
+ return candidate
+ return None
diff --git a/api/system.py b/api/system.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6a38f4dc569447d59c53d3416266190841003dc
--- /dev/null
+++ b/api/system.py
@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+from urllib.parse import quote
+
+from fastapi import APIRouter, Header, HTTPException, Query, Request
+from fastapi.concurrency import run_in_threadpool
+from fastapi.responses import HTMLResponse, Response, StreamingResponse
+from pydantic import BaseModel, ConfigDict
+
+from api.support import require_admin, require_identity, resolve_image_base_url
+from services.backup_service import BackupError, backup_service
+from services.config import config
+from services.image_service import (
+ compress_images,
+ delete_images,
+ delete_to_target,
+ download_images_zip,
+ get_image_download_response,
+ get_image_response,
+ get_thumbnail_response,
+ list_images,
+ storage_stats,
+)
+from services.image_storage_service import ImageStorageError, image_storage_service
+from services.image_tags_service import delete_tag, get_all_tags, set_tags
+from services.log_service import log_service
+from services.proxy_service import test_proxy
+
+
+class SettingsUpdateRequest(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+
+class ProxyTestRequest(BaseModel):
+ url: str = ""
+
+
+class ImageDeleteRequest(BaseModel):
+ paths: list[str] = []
+ start_date: str = ""
+ end_date: str = ""
+ all_matching: bool = False
+
+class ImageDownloadRequest(BaseModel):
+ paths: list[str]
+
+class ImageTagsRequest(BaseModel):
+ path: str
+ tags: list[str]
+
+class LogDeleteRequest(BaseModel):
+ ids: list[str] = []
+class BackupDeleteRequest(BaseModel):
+ key: str = ""
+
+
+def create_router(app_version: str) -> APIRouter:
+ router = APIRouter()
+
+ @router.post("/auth/login")
+ async def login(authorization: str | None = Header(default=None)):
+ identity = require_identity(authorization)
+ return {
+ "ok": True,
+ "version": app_version,
+ "role": identity.get("role"),
+ "subject_id": identity.get("id"),
+ "name": identity.get("name"),
+ }
+
+ @router.get("/version")
+ async def get_version():
+ return {"version": app_version}
+
+ @router.get("/api/settings")
+ async def get_settings(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"config": config.get()}
+
+ @router.post("/api/settings")
+ async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ return {"config": config.update(body.model_dump(mode="python"))}
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.get("/api/images")
+ async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
+
+ @router.get("/images/{image_path:path}", include_in_schema=False)
+ async def get_image(image_path: str):
+ return get_image_response(image_path)
+
+ @router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
+ async def get_image_thumbnail(image_path: str):
+ return get_thumbnail_response(image_path)
+
+ @router.post("/api/images/delete")
+ async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
+
+ @router.post("/api/images/download")
+ async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ buf = download_images_zip(body.paths)
+ return StreamingResponse(
+ buf,
+ media_type="application/zip",
+ headers={"Content-Disposition": 'attachment; filename="images.zip"'},
+ )
+
+ @router.get("/api/images/download/{image_path:path}")
+ async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return get_image_download_response(image_path)
+
+ @router.get("/api/logs")
+ async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())}
+
+ @router.post("/api/logs/delete")
+ async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return log_service.delete(body.ids)
+
+ @router.post("/api/proxy/test")
+ async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ candidate = (body.url or "").strip() or config.get_proxy_settings()
+ if not candidate:
+ raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
+ return {"result": await run_in_threadpool(test_proxy, candidate)}
+
+ @router.get("/api/storage/info")
+ async def get_storage_info(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ storage = config.get_storage_backend()
+ return {
+ "backend": storage.get_backend_info(),
+ "health": storage.health_check(),
+ }
+
+ @router.post("/api/backup/test")
+ async def test_backup_connection(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ return {"result": await run_in_threadpool(backup_service.test_connection)}
+ except BackupError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.post("/api/image-storage/test")
+ async def test_image_storage_endpoint(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"result": await run_in_threadpool(image_storage_service.test_webdav)}
+
+ @router.post("/api/image-storage/sync")
+ async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ return {"result": await run_in_threadpool(image_storage_service.sync_all)}
+ except ImageStorageError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.get("/api/backups")
+ async def get_backups(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ return {
+ "items": await run_in_threadpool(backup_service.list_backups),
+ "state": backup_service.get_status(),
+ "settings": backup_service.get_settings(),
+ }
+ except BackupError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.post("/api/backups/run")
+ async def run_backup_endpoint(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ return {"result": await run_in_threadpool(backup_service.run_backup)}
+ except BackupError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.post("/api/backups/delete")
+ async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ await run_in_threadpool(backup_service.delete_backup, body.key)
+ return {"ok": True}
+ except BackupError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.get("/api/backups/detail")
+ async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)}
+ except BackupError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+
+ @router.get("/api/backups/download")
+ async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ try:
+ item = await run_in_threadpool(backup_service.download_backup, key)
+ except BackupError as exc:
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
+ filename = str(item.get("name") or "backup.bin")
+ quoted = quote(filename)
+ headers = {
+ "Content-Disposition": f"attachment; filename*=UTF-8''{quoted}",
+ "Content-Length": str(int(item.get("size") or 0)),
+ }
+ return Response(
+ content=bytes(item.get("payload") or b""),
+ media_type=str(item.get("content_type") or "application/octet-stream"),
+ headers=headers,
+ )
+
+
+ @router.get("/api/images/tags")
+ async def list_image_tags(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return {"tags": get_all_tags()}
+
+ @router.post("/api/images/tags")
+ async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ rel = body.path.strip().lstrip("/")
+ if not rel:
+ raise HTTPException(status_code=400, detail={"error": "path is required"})
+ tags = set_tags(rel, body.tags)
+ return {"ok": True, "tags": tags}
+
+ @router.delete("/api/images/tags/{tag}")
+ async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ count = delete_tag(tag)
+ return {"ok": True, "removed_from": count}
+
+ @router.get("/api/images/storage")
+ async def get_image_storage(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return storage_stats()
+
+ @router.post("/api/images/storage/compress")
+ async def compress_all_images(authorization: str | None = Header(default=None)):
+ require_admin(authorization)
+ return await run_in_threadpool(compress_images)
+
+ @router.post("/api/images/storage/cleanup-to-target")
+ async def cleanup_to_target(
+ target_free_mb: int = 500,
+ dry_run: bool = False,
+ authorization: str | None = Header(default=None),
+ ):
+ require_admin(authorization)
+ return await run_in_threadpool(delete_to_target, target_free_mb, dry_run)
+
+ @router.get("/health", response_model=None)
+ async def health_dashboard(format: str = Query(default="html")):
+ from services.account_service import account_service as acct_svc
+ stats = acct_svc.get_stats()
+ storage = config.get_storage_backend()
+ storage_health = storage.health_check()
+ healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0
+
+ stats_json = {
+ "status": "ok" if healthy else "degraded",
+ "healthy": healthy,
+ "version": app_version,
+ "storage": {"backend": storage.get_backend_info(), "health": storage_health},
+ "accounts": stats,
+ }
+ if format == "json":
+ return stats_json
+ return HTMLResponse(f"""
+
+
+号池健康监控 - chatgpt2api
+
+
+
+
+
+
+
+
号池状态
{'正常' if healthy else '异常'}
+
+
累计入库
{stats['cumulative_total']}
+
+
无限额
{stats['unlimited_quota_count']}
+
剩余额度
{stats['total_quota']}
+
+
+
+
成功/失败
{stats['total_success']}/{stats['total_fail']}
+
+
账号类型分布
+
+| 类型 | 数量 |
+{''.join(f'| {t} | {c} |
' for t,c in sorted(stats['by_type'].items()))}
+
+
JSON: /health?format=json
+
""")
+
+ return router
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
new file mode 100644
index 0000000000000000000000000000000000000000..288901909a7c7a5619bbb5f20ac224f180165243
--- /dev/null
+++ b/docker-compose.local.yml
@@ -0,0 +1,34 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: chatgpt2api:local
+ container_name: chatgpt2api-local
+ ports:
+ - "8000:80"
+ volumes:
+ - ./data:/app/data
+ - ./config.json:/app/config.json
+ environment:
+ STORAGE_BACKEND: sqlite
+ DATABASE_URL: sqlite:////app/data/accounts.db
+ # 存储后端配置 (可选值: json, sqlite, postgres, git)
+ # environment:
+ # STORAGE_BACKEND: json
+
+ # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用)
+ # DATABASE_URL: postgresql://user:password@host:5432/dbname
+ # DATABASE_URL: sqlite:////app/data/accounts.db
+
+ # Git 仓库配置 (当 STORAGE_BACKEND=git 时使用)
+ # GIT_REPO_URL: https://github.com/user/repo.git
+ # GIT_TOKEN: ghp_xxxxxxxxxxxx
+ # GIT_BRANCH: main
+ # GIT_FILE_PATH: accounts.json
+
+ # 认证密钥 (可选,覆盖 config.json)
+ # CHATGPT2API_AUTH_KEY: your_secret_key
+
+ # 基础 URL (可选)
+ # CHATGPT2API_BASE_URL: https://your-domain.com
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a80dc6435132a3da33ee3fc357e367a7763882c6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,29 @@
+services:
+ app:
+ image: ghcr.io/basketikun/chatgpt2api:latest
+ container_name: chatgpt2api
+ restart: unless-stopped
+ ports:
+ - "3000:80"
+ volumes:
+ - ./data:/app/data
+ - ./config.json:/app/config.json
+ environment:
+ # 存储后端配置 (可选值: json, sqlite, postgres, git)
+ - STORAGE_BACKEND=json
+
+ # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用)
+ # - DATABASE_URL=postgresql://user:password@host:5432/dbname
+ # - DATABASE_URL=sqlite:///app/data/accounts.db
+
+ # Git 仓库配置 (当 STORAGE_BACKEND=git 时使用)
+ # - GIT_REPO_URL=https://github.com/user/repo.git
+ # - GIT_TOKEN=ghp_xxxxxxxxxxxx
+ # - GIT_BRANCH=main
+ # - GIT_FILE_PATH=accounts.json
+
+ # 认证密钥 (可选,覆盖 config.json)
+ # - CHATGPT2API_AUTH_KEY=your_secret_key
+
+ # 基础 URL (可选)
+ # - CHATGPT2API_BASE_URL=https://your-domain.com
diff --git a/docs/feature-status.en.md b/docs/feature-status.en.md
new file mode 100644
index 0000000000000000000000000000000000000000..a72e2b7a74ec991a6ba3fb63bd1e80ddd2137780
--- /dev/null
+++ b/docs/feature-status.en.md
@@ -0,0 +1,44 @@
+# 功能状态
+
+本文基于当前仓库当前实现整理,用于帮助用户快速了解哪些功能已经可用、哪些仍在完善、哪些待实现。
+
+| 功能 | 状态 | 说明 |
+|:----------------------------------------|:--:|:--------------------------------------------------------------|
+| OpenAI 兼容 `POST /v1/images/generations` | ✅ | 已支持,用于图片生成,并可通过 `n` 返回多张图片。 |
+| OpenAI 兼容 `POST /v1/images/edits` | ✅ | 已支持,可上传图片进行编辑。 |
+| 面向图片工作流的 `POST /v1/chat/completions` | ✅ | 已支持图片相关请求。 |
+| 面向图片工作流的 `POST /v1/responses` | ✅ | 已支持图片生成工具调用。 |
+| `GET /v1/models` 接口 | ✅ | 当前返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini`。 |
+| 同时生成多张图片 | ✅ | 已支持,后端与前端都可进行多图生成。 |
+| 图片并行生成 | ✅ | 多张图片使用独立线程和账号同时生成,可通过 `image_parallel_generation` 配置关闭。 |
+| 图片生成进度追踪 | ✅ | 任务显示当前步骤(上传/预热/获取token/生成中等),支持耗时统计。 |
+| 图片超时续轮询 | ✅ | 超时任务可继续等待,前端显示"继续等待"按钮,后端 resume-poll API。 |
+| 图片二次确认与先check再hit | ✅ | 可通过 `image_settle_enabled` 和 `image_check_before_hit_enabled` 配置,关闭后跳过等待直接返回。 |
+| 前端图片工作台 | ✅ | 已支持图片生成、图片编辑、模型选择、历史记录与查看大图。 |
+| 前端图片懒加载与滚动优化 | ✅ | LazyImage 懒加载、会话切换滚动位置保存与恢复、bfcache 页面恢复同步。 |
+| 前端图片输入 / 参考图交互 | ✅ | 已支持参考图上传、预览、移除和编辑模式工作流。 |
+| Codex 画图接口逆向 | ✅ | 已支持,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`;如有需要可自行在其他场景映射回 `gpt-image-2`。这是 Codex 逆向链路,用于和官网画图区分,同一账号通常会同时支持官网和 Codex 两份生图额度。 |
+| Cherry Studio 接入 | ✅ | 已支持作为绘图接口接入 Cherry Studio。 |
+| New API 接入 | ✅ | 已支持接入 New API。 |
+| 账号池管理 | ✅ | 已支持列表、筛选、批量操作、导出、手动编辑、刷新和删除。 |
+| 账号刷新异步进度追踪 | ✅ | 刷新和重新登录改为异步模式,前端轮询显示进度。 |
+| 密码重新登录恢复异常账号 | ✅ | 号池管理页面支持重新登录,刷新后可自动重登异常账号。 |
+| 账号额度刷新与恢复时间同步 | ✅ | 已支持账号信息刷新,限流账号也会自动继续检查。 |
+| 失效 Token 自动清理 | ✅ | 已支持自动移除失效 Token。 |
+| CPA 连接管理 | ✅ | 已支持 CPA 连接的新增、修改、查询和删除。 |
+| CPA 文件浏览与按需导入 | ✅ | 已支持读取远程文件列表、筛选、勾选并导入到本地号池。 |
+| CPA 导入进度跟踪 | ✅ | 已支持导入进度展示与轮询更新。 |
+| `sub2api` 连接管理与账号浏览 | ✅ | 已支持 `sub2api` 服务器的新增、修改、删除、分组查询和 OpenAI OAuth 账号列表读取。 |
+| `sub2api` 导入 | ✅ | 已支持勾选 `sub2api` 中的 OpenAI OAuth 账号,批量拉取 `access_token` 导入本地号池,并展示导入进度。 |
+| Docker 自托管部署 | ✅ | 已支持 Docker Compose 部署,并提供多架构镜像。 |
+| 兼容接口中的多参考图能力 | ✅ | 已实现,支持在兼容接口中传入多参考图。 |
+| 更高级的 Token 调度策略 | ⚠️ | 当前已有基础轮询与限流刷新机制,更复杂的调度策略仍在完善中。 |
+| Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 |
+| `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 |
+| 流式输出支持 | ✅ | 已实现。 |
+| 文本补全缓存与重复请求合并 | ✅ | `/v1/chat/completions` 文本链路默认启用 60 秒短缓存、流式结果回放、in-flight 请求合并和相邻重复消息清理;可通过 `chat_completion_cache` 配置关闭或调整。 |
+| 图片尺寸参数 | ❌ | 待实现。 |
+| 服务端图片 URL 缓存 | ✅ | 已实现。 |
+| `rt_token` 刷新 | ❌ | 待实现。 |
+| 代理配置功能 | ✅ | 已支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理,并应用到出站请求。 |
+| Anthropic 协议支持 | ❌ | 待实现。 |
diff --git a/docs/review.md b/docs/review.md
new file mode 100644
index 0000000000000000000000000000000000000000..c4e92bf2436c237f51109e00387019d5177680fa
--- /dev/null
+++ b/docs/review.md
@@ -0,0 +1,9 @@
+# Review
+
+- [P2] Poll when the image tool was invoked
+ `services/protocol/conversation.py:586`
+ 对于没有输入图片的图像生成任务,延迟结果仍可能在 SSE 返回 `tool_invoked: true` 之后到达,但这里新的判断条件忽略了 `tool_invoked`。这样会直接返回中间文本,而不会继续轮询会话来拿图片 ID。
+
+- [P2] Align CloudMail domain validation with the UI
+ `web/src/app/register/components/register-card.tsx:266`
+ `cloudmail_gen` 的 placeholder 写的是留空会使用服务默认域名,但后端 `create_mailbox` 不接受空域名。用户按 UI 提示保存后,这个 provider 会在真正创建地址前失败。
diff --git a/docs/upstream-sse-conversation.md b/docs/upstream-sse-conversation.md
new file mode 100644
index 0000000000000000000000000000000000000000..c1ec7dce557b5a62c24dea22f16a402154f036b6
--- /dev/null
+++ b/docs/upstream-sse-conversation.md
@@ -0,0 +1,271 @@
+# 上游 Conversation SSE 协议说明
+
+Conversation SSE 是上游对话链路的流式返回协议。每条 SSE `data:` 通常是一段 JSON payload,也可能是协议标记或结束标记。客户端需要按顺序消费这些 payload,维护当前会话状态、文本内容、工具调用状态和图片结果指针。
+
+## 基本形态
+
+常见 payload 示例:
+
+```text
+"v1"
+{"type":"resume_conversation_token",...}
+{"p":"","o":"add","v":{...}}
+{"v":{...}}
+{"p":"/message/content/parts/0","o":"append","v":"..."}
+{"type":"server_ste_metadata","metadata":{...}}
+[DONE]
+```
+
+处理建议:
+
+| payload | 含义 | 处理方式 |
+|:--|:--|:--|
+| `"v1"` | 协议版本标记 | 可记录,通常不影响业务 |
+| `[DONE]` | 当前 SSE 流结束 | 停止继续读取 |
+| JSON object | 事件、消息或 patch | 按字段更新会话状态 |
+| JSON string | 短文本 patch 或协议标记 | 结合上下文处理 |
+| 非 JSON 内容 | 原始内容 | 保留为 raw 事件,避免中断流 |
+
+## 常用字段
+
+| 字段 | 说明 |
+|:--|:--|
+| `type` | 上游事件类型,如 `resume_conversation_token`、`input_message`、`message_marker`、`title_generation`、`server_ste_metadata` |
+| `conversation_id` | 当前会话 ID,可从多个事件中获得 |
+| `p` | patch 路径,例如 `/message/content/parts/0` |
+| `o` | patch 操作,例如 `add`、`append`、`replace`、`patch` |
+| `v` | patch 值,可能是字符串、数组,也可能包含完整 message |
+| `c` | 消息序号或游标,常见于 add 类事件 |
+| `message.id` | 消息 ID |
+| `message.author.role` | 消息角色,常见 `system`、`user`、`assistant`、`tool` |
+| `message.content.content_type` | 内容类型,如 `text`、`multimodal_text`、`model_editable_context` |
+| `message.content.parts` | 内容片段,可能包含文本、图片指针或多模态对象 |
+| `message.status` | 消息状态,如 `in_progress`、`finished_successfully` |
+| `message.end_turn` | 是否结束当前轮次 |
+| `metadata.tool_invoked` | 本轮是否调用工具 |
+| `metadata.turn_use_case` | 本轮用途,如 `text`、`multimodal` |
+| `metadata.async_task_type` | 异步工具任务类型,图片生成通常为 `image_gen` |
+
+## 会话启动事件
+
+上游通常会先返回恢复令牌或会话令牌:
+
+```json
+{
+ "type": "resume_conversation_token",
+ "kind": "topic",
+ "token": "...",
+ "conversation_id": "..."
+}
+```
+
+这个事件主要用于标识会话和恢复上下文。业务层通常只需要保存 `conversation_id`,`token` 不应该暴露给下游用户。
+
+## 消息 add 场景
+
+完整消息可能通过 `add` 或带 `v.message` 的事件出现:
+
+```json
+{
+ "p": "",
+ "o": "add",
+ "v": {
+ "message": {
+ "author": {"role": "assistant"},
+ "content": {"content_type": "text", "parts": [""]},
+ "status": "in_progress"
+ },
+ "conversation_id": "..."
+ },
+ "c": 3
+}
+```
+
+此类事件常用于创建一条新消息。若消息角色为 `assistant`,后续文本通常会通过 patch 继续追加。
+
+## 文本增量场景
+
+文本输出通常由多条 patch 组成:
+
+```json
+{"p":"/message/content/parts/0","o":"append","v":"Hello"}
+{"v":" world"}
+{"p":"","o":"patch","v":[
+ {"p":"/message/content/parts/0","o":"append","v":"!"},
+ {"p":"/message/status","o":"replace","v":"finished_successfully"},
+ {"p":"/message/end_turn","o":"replace","v":true}
+]}
+```
+
+处理要点:
+
+| 形态 | 含义 |
+|:--|:--|
+| `p == "/message/content/parts/0"` 且 `o == "append"` | 向当前文本追加内容 |
+| `o == "replace"` | 用新值替换目标字段 |
+| `o == "patch"` 且 `v` 是数组 | 批量 patch,需要按数组顺序处理 |
+| 只有 `v` 且 `v` 是字符串 | 可能是省略路径的文本增量,应结合当前文本流处理 |
+
+## 输入消息场景
+
+用户输入会以 `input_message` 或普通 `user` message 出现。图片编辑请求会包含用户上传的参考图:
+
+```json
+{
+ "type": "input_message",
+ "input_message": {
+ "author": {"role": "user"},
+ "content": {
+ "content_type": "multimodal_text",
+ "parts": [
+ {"asset_pointer": "sediment://file_input"},
+ "编辑提示词"
+ ]
+ }
+ },
+ "conversation_id": "..."
+}
+```
+
+这类 `sediment://...` 表示输入附件,不是生成结果。即使它可以被下载,也不能当作输出图片返回。
+
+## 图片工具成功场景
+
+图片生成或图片编辑成功时,上游一般会出现工具消息:
+
+```json
+{
+ "v": {
+ "message": {
+ "author": {"role": "tool"},
+ "content": {
+ "content_type": "multimodal_text",
+ "parts": [
+ {"asset_pointer": "file-service://file_result"},
+ {"asset_pointer": "sediment://file_result"}
+ ]
+ },
+ "metadata": {"async_task_type": "image_gen"}
+ }
+ },
+ "conversation_id": "..."
+}
+```
+
+只有同时满足以下条件的图片指针,才应该视为输出结果:
+
+| 条件 | 说明 |
+|:--|:--|
+| `message.author.role == "tool"` | 来源是工具消息 |
+| `metadata.async_task_type == "image_gen"` | 工具任务是图片生成 |
+| `asset_pointer` 为 `file-service://...` 或 `sediment://...` | 指向可解析图片资源 |
+
+## 图片指针类型
+
+| 指针 | 常见来源 | 说明 |
+|:--|:--|:--|
+| `file-service://file_xxx` | 图片工具输出 | 可通过文件下载接口解析 |
+| `sediment://file_xxx` | 输入附件或图片工具输出 | 需要结合消息角色判断来源 |
+| `file_upload` | 上传过程占位 | 通常不应作为输出 |
+
+不要只凭字符串里出现 `file_` 或 `sediment://` 就判定为输出图。必须结合消息角色和任务类型。
+
+## 策略拒绝场景
+
+当上游拒绝请求时,通常不会产生图片工具消息,而是返回普通 assistant 文本:
+
+```text
+I can't assist with that request. If you have another type of modification...
+```
+
+常见伴随事件:
+
+```json
+{"type":"title_generation","title":"Request Denied","conversation_id":"..."}
+```
+
+```json
+{
+ "type": "server_ste_metadata",
+ "metadata": {
+ "tool_invoked": false,
+ "turn_use_case": "multimodal",
+ "did_prompt_contain_image": true
+ },
+ "conversation_id": "..."
+}
+```
+
+处理要点:
+
+| 条件 | 行为 |
+|:--|:--|
+| 有 assistant 拒绝文本 | 应返回文本消息 |
+| `tool_invoked == false` | 说明没有实际工具结果 |
+| 没有 `role=tool` 且 `async_task_type=image_gen` 的消息 | 不应收集输出图片 |
+| 用户输入消息里有图片指针 | 仍然只视为输入附件 |
+
+## moderation 场景
+
+部分请求可能返回 moderation 事件:
+
+```json
+{
+ "type": "moderation",
+ "moderation_response": {
+ "blocked": true
+ },
+ "conversation_id": "..."
+}
+```
+
+若 `blocked == true`,应认为本轮被策略拦截。后续如有 assistant 文本,应优先返回该文本;若没有文本,可返回合适的错误信息。
+
+## marker 和 title 事件
+
+上游会返回一些辅助事件:
+
+```json
+{"type":"message_marker","marker":"user_visible_token","event":"first"}
+{"type":"message_marker","marker":"last_token","event":"last"}
+{"type":"title_generation","title":"...","conversation_id":"..."}
+```
+
+这些事件通常用于前端展示、标题生成或流式状态标记,不代表实际文本内容或图片结果。
+
+## metadata 事件
+
+`server_ste_metadata` 用于描述本轮调度和工具状态:
+
+```json
+{
+ "type": "server_ste_metadata",
+ "metadata": {
+ "tool_invoked": true,
+ "turn_use_case": "multimodal",
+ "model_slug": "i-mini-m",
+ "did_prompt_contain_image": true
+ }
+}
+```
+
+常用判断:
+
+| 字段 | 说明 |
+|:--|:--|
+| `tool_invoked == true` | 上游认为本轮调用过工具 |
+| `tool_invoked == false` | 上游未调用工具,常见于拒绝或纯文本响应 |
+| `turn_use_case == "text"` | 按文本响应处理 |
+| `turn_use_case == "multimodal"` | 多模态请求,不代表一定有图片输出 |
+| `did_prompt_contain_image == true` | 输入包含图片,不代表输出包含图片 |
+
+## 结束后的结果判断
+
+SSE 结束后可按以下顺序判断结果:
+
+1. 如果已经收集到图片工具输出指针,解析并下载输出图片。
+2. 如果没有输出图片指针,但有 assistant 文本,并且本轮被拦截或未调用工具,返回文本消息。
+3. 如果没有输出图片指针,但有 `conversation_id`,可查询完整会话明细,继续寻找图片工具输出。
+4. 查询完整会话时,仍然只读取 `role=tool` 且 `async_task_type=image_gen` 的消息。
+5. 如果没有图片结果也没有文本,返回上游异常或空结果错误。
+
diff --git a/main.py b/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe5205672b37940ad59c317b086072bf2a04a8f7
--- /dev/null
+++ b/main.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import uvicorn
+from api import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+ uvicorn.run(app, access_log=False, log_level="info")
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..5ae2627976627f0d0b5656268fc5918f179c62e5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,27 @@
+[project]
+name = "chatgpt2api"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "curl-cffi>=0.15.0",
+ "fastapi>=0.136.0",
+ "pillow>=12.2.0",
+ "pybase64>=1.4.3",
+ "python-multipart>=0.0.26",
+ "tiktoken>=0.12.0",
+ "uvicorn>=0.44.0",
+ "sqlalchemy>=2.0.0",
+ "psycopg2-binary>=2.9.0",
+ "gitpython>=3.1.0",
+]
+
+[dependency-groups]
+dev = [
+ "httpx>=0.28.1",
+]
+
+[[tool.uv.index]]
+url = "https://mirrors.aliyun.com/pypi/simple"
+default = true
diff --git a/scripts/migrate_storage.py b/scripts/migrate_storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..32adeaff3b622e19b2555476b4d7058d46eafd13
--- /dev/null
+++ b/scripts/migrate_storage.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""
+存储后端数据迁移脚本
+
+用法:
+ python scripts/migrate_storage.py --from json --to postgres
+ python scripts/migrate_storage.py --from postgres --to git
+ python scripts/migrate_storage.py --export accounts.json
+ python scripts/migrate_storage.py --import accounts.json
+"""
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+DATA_DIR = Path(__file__).resolve().parents[1] / "data"
+
+from services.storage.factory import create_storage_backend
+
+
+def export_to_json(output_file: str):
+ """导出当前存储后端的数据到 JSON 文件"""
+ print(f"[migrate] Exporting data to {output_file}")
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ storage = create_storage_backend(DATA_DIR)
+ accounts = storage.load_accounts()
+
+ output_path = Path(output_file)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ json.dumps(accounts, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ print(f"[migrate] Exported {len(accounts)} accounts to {output_file}")
+
+
+def import_from_json(input_file: str):
+ """从 JSON 文件导入数据到当前存储后端"""
+ print(f"[migrate] Importing data from {input_file}")
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ input_path = Path(input_file)
+ if not input_path.exists():
+ print(f"[migrate] Error: File not found: {input_file}")
+ sys.exit(1)
+
+ try:
+ accounts = json.loads(input_path.read_text(encoding="utf-8"))
+ if not isinstance(accounts, list):
+ print(f"[migrate] Error: Invalid JSON format, expected array")
+ sys.exit(1)
+ except json.JSONDecodeError as e:
+ print(f"[migrate] Error: Invalid JSON: {e}")
+ sys.exit(1)
+
+ storage = create_storage_backend(DATA_DIR)
+ storage.save_accounts(accounts)
+
+ print(f"[migrate] Imported {len(accounts)} accounts")
+
+
+def migrate_data(from_backend: str, to_backend: str):
+ """从一个存储后端迁移到另一个"""
+ print(f"[migrate] Migrating from {from_backend} to {to_backend}")
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ # 保存原始环境变量
+ original_backend = os.environ.get("STORAGE_BACKEND")
+
+ try:
+ # 从源后端读取数据
+ os.environ["STORAGE_BACKEND"] = from_backend
+ from_storage = create_storage_backend(DATA_DIR)
+ accounts = from_storage.load_accounts()
+ print(f"[migrate] Loaded {len(accounts)} accounts from {from_backend}")
+
+ # 写入目标后端
+ os.environ["STORAGE_BACKEND"] = to_backend
+ to_storage = create_storage_backend(DATA_DIR)
+ to_storage.save_accounts(accounts)
+ print(f"[migrate] Saved {len(accounts)} accounts to {to_backend}")
+
+ print(f"[migrate] Migration completed successfully!")
+
+ finally:
+ # 恢复原始环境变量
+ if original_backend:
+ os.environ["STORAGE_BACKEND"] = original_backend
+ elif "STORAGE_BACKEND" in os.environ:
+ del os.environ["STORAGE_BACKEND"]
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="ChatGPT2API 存储后端数据迁移工具",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+示例:
+ # 从 JSON 迁移到 PostgreSQL
+ python scripts/migrate_storage.py --from json --to postgres
+
+ # 从 PostgreSQL 迁移到 Git
+ python scripts/migrate_storage.py --from postgres --to git
+
+ # 导出当前数据到 JSON 文件
+ python scripts/migrate_storage.py --export backup.json
+
+ # 从 JSON 文件导入数据
+ python scripts/migrate_storage.py --import backup.json
+
+环境变量:
+ STORAGE_BACKEND - 存储后端类型 (json, sqlite, postgres, git)
+ DATABASE_URL - 数据库连接字符串
+ GIT_REPO_URL - Git 仓库地址
+ GIT_TOKEN - Git 访问令牌
+ """
+ )
+
+ parser.add_argument(
+ "--from",
+ dest="from_backend",
+ choices=["json", "sqlite", "postgres", "git"],
+ help="源存储后端",
+ )
+ parser.add_argument(
+ "--to",
+ dest="to_backend",
+ choices=["json", "sqlite", "postgres", "git"],
+ help="目标存储后端",
+ )
+ parser.add_argument(
+ "--export",
+ dest="export_file",
+ metavar="FILE",
+ help="导出数据到 JSON 文件",
+ )
+ parser.add_argument(
+ "--import",
+ dest="import_file",
+ metavar="FILE",
+ help="从 JSON 文件导入数据",
+ )
+
+ args = parser.parse_args()
+
+ # 检查参数
+ if args.from_backend and args.to_backend:
+ migrate_data(args.from_backend, args.to_backend)
+ elif args.export_file:
+ export_to_json(args.export_file)
+ elif args.import_file:
+ import_from_json(args.import_file)
+ else:
+ parser.print_help()
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_storage.py b/scripts/test_storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..15455f6bd7d339afeb10415b34462f96fe31fe52
--- /dev/null
+++ b/scripts/test_storage.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""
+存储后端测试脚本
+
+用法:
+ python scripts/test_storage.py
+"""
+
+import os
+import sys
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+DATA_DIR = Path(__file__).resolve().parents[1] / "data"
+
+from services.storage.factory import create_storage_backend
+
+
+def test_storage():
+ """测试当前配置的存储后端"""
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ print("=" * 60)
+ print("ChatGPT2API 存储后端测试")
+ print("=" * 60)
+
+ # 显示当前配置
+ backend_type = os.getenv("STORAGE_BACKEND", "json")
+ print(f"\n当前存储后端: {backend_type}")
+
+ if backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
+ database_url = os.getenv("DATABASE_URL", "")
+ if database_url:
+ # 隐藏密码
+ if "://" in database_url and "@" in database_url:
+ protocol, rest = database_url.split("://", 1)
+ if "@" in rest:
+ credentials, host = rest.split("@", 1)
+ if ":" in credentials:
+ username, _ = credentials.split(":", 1)
+ database_url = f"{protocol}://{username}:****@{host}"
+ print(f"数据库连接: {database_url}")
+ else:
+ print(f"数据库连接: 本地 SQLite (data/accounts.db)")
+
+ elif backend_type == "git":
+ repo_url = os.getenv("GIT_REPO_URL", "")
+ branch = os.getenv("GIT_BRANCH", "main")
+ file_path = os.getenv("GIT_FILE_PATH", "accounts.json")
+ print(f"Git 仓库: {repo_url}")
+ print(f"Git 分支: {branch}")
+ print(f"文件路径: {file_path}")
+
+ print("\n" + "=" * 60)
+
+ try:
+ # 创建存储后端
+ print("\n[1/5] 创建存储后端...")
+ storage = create_storage_backend(DATA_DIR)
+ print("✅ 存储后端创建成功")
+
+ # 获取后端信息
+ print("\n[2/5] 获取后端信息...")
+ info = storage.get_backend_info()
+ print(f"✅ 后端类型: {info.get('type')}")
+ print(f" 描述: {info.get('description')}")
+ for key, value in info.items():
+ if key not in ('type', 'description'):
+ print(f" {key}: {value}")
+
+ # 健康检查
+ print("\n[3/5] 执行健康检查...")
+ health = storage.health_check()
+ status = health.get("status")
+ if status == "healthy":
+ print(f"✅ 健康状态: {status}")
+ else:
+ print(f"❌ 健康状态: {status}")
+ print(f" 错误: {health.get('error')}")
+ return False
+
+ # 读取数据
+ print("\n[4/5] 读取账号数据...")
+ accounts = storage.load_accounts()
+ print(f"✅ 成功读取 {len(accounts)} 个账号")
+
+ # 写入测试(可选)
+ print("\n[5/5] 测试写入功能...")
+ test_account = {
+ "access_token": "test_token_" + str(os.getpid()),
+ "type": "Free",
+ "status": "测试",
+ "quota": 0,
+ "email": "test@example.com",
+ }
+
+ # 添加测试账号
+ test_accounts = accounts + [test_account]
+ storage.save_accounts(test_accounts)
+ print("✅ 写入测试账号成功")
+
+ # 验证写入
+ reloaded = storage.load_accounts()
+ if len(reloaded) == len(test_accounts):
+ print("✅ 验证写入成功")
+ else:
+ print(f"❌ 验证失败: 期望 {len(test_accounts)} 个账号,实际 {len(reloaded)} 个")
+ return False
+
+ # 恢复原始数据
+ storage.save_accounts(accounts)
+ print("✅ 恢复原始数据")
+
+ print("\n" + "=" * 60)
+ print("✅ 所有测试通过!")
+ print("=" * 60)
+ return True
+
+ except Exception as e:
+ print(f"\n❌ 测试失败: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+
+if __name__ == "__main__":
+ success = test_storage()
+ sys.exit(0 if success else 1)
diff --git a/scripts/verify_oauth_refresh.py b/scripts/verify_oauth_refresh.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f2650d2facda2e08173d1b8d074d5944e277309
--- /dev/null
+++ b/scripts/verify_oauth_refresh.py
@@ -0,0 +1,92 @@
+"""验证 OAuth 账号的自动刷新是否生效。
+
+用法(在容器内,工作目录 /app):
+ uv run python scripts/verify_oauth_refresh.py # 只读诊断,安全
+ uv run python scripts/verify_oauth_refresh.py --force # 真正触发一次刷新
+
+只读模式:列出每个账号的 access_token 剩余有效期、是否带 refresh_token、
+ 上次刷新时间与上次刷新错误,判断"有没有料可刷"。
+--force :对每个带 refresh_token 的账号强制走一次 refresh_access_token(force=True),
+ 直接验证 refresh_token + 本项目 client_id 能否从 OpenAI 换出新 access_token。
+ 这会真实轮换 access_token(新 token 有效,不损坏账号)。
+"""
+from __future__ import annotations
+
+import sys
+
+from services.account_service import account_service
+
+
+def _fmt_remaining(seconds: int | None) -> str:
+ if seconds is None:
+ return "无法解析 exp"
+ if seconds <= 0:
+ return f"已过期 {(-seconds) // 3600}h"
+ return f"{seconds // 3600}h{(seconds % 3600) // 60}m 后过期"
+
+
+def diagnose() -> list[str]:
+ """只读:打印每个账号的刷新就绪状态,返回带 refresh_token 的 token 列表。"""
+ tokens = account_service.list_tokens()
+ print(f"账号总数: {len(tokens)}\n")
+ refreshable: list[str] = []
+ for token in tokens:
+ account = account_service.get_account(token) or {}
+ remaining = account_service._token_expires_in(token)
+ has_rt = bool(str(account.get("refresh_token") or "").strip())
+ if has_rt:
+ refreshable.append(token)
+ print(f"- email={account.get('email') or '(未知)'}")
+ print(f" access_token[:20] = {token[:20]}...")
+ print(f" 距过期 = {_fmt_remaining(remaining)}")
+ print(f" refresh_token = {'有 ✅' if has_rt else '无 ❌(无法自动刷新)'}")
+ print(f" last_token_refresh_at = {account.get('last_token_refresh_at')}")
+ print(f" last_token_refresh_error = {account.get('last_token_refresh_error')}")
+ print()
+ return refreshable
+
+
+def force_refresh(tokens: list[str]) -> None:
+ """对每个账号 force 刷新一次,并对比前后状态判断成败。"""
+ if not tokens:
+ print("没有带 refresh_token 的账号,无法验证刷新。")
+ return
+ print("=" * 60)
+ print(f"开始对 {len(tokens)} 个账号 force 刷新(真实调用 OpenAI)...\n")
+ ok = 0
+ for token in tokens:
+ before = account_service.get_account(token) or {}
+ new_token = account_service.refresh_access_token(token, force=True, event="manual_verify")
+ after = account_service.get_account(new_token) or {}
+ err = str(after.get("last_token_refresh_error") or "").strip()
+ rotated = new_token != token
+ success = bool(new_token) and not err
+ if success:
+ ok += 1
+ print(f"- email={before.get('email') or '(未知)'}")
+ print(f" 旧 access_token[:20] = {token[:20]}...")
+ print(f" 新 access_token[:20] = {new_token[:20]}...")
+ print(f" token 是否轮换 = {'是' if rotated else '否(exp 未到刷新窗口时可能返回原值)'}")
+ print(f" last_token_refresh_at = {after.get('last_token_refresh_at')}")
+ print(f" last_token_refresh_error = {after.get('last_token_refresh_error') or '无'}")
+ print(f" >>> 刷新结果 = {'成功 ✅' if success else '失败 ❌'}")
+ print()
+ print("=" * 60)
+ print(f"汇总: {ok}/{len(tokens)} 个账号刷新成功")
+ if ok == len(tokens):
+ print("✅ 自动刷新机制对这些账号完全可用——refresh_token 与 client_id 匹配。")
+ else:
+ print("❌ 有账号刷新失败,看上面的 last_token_refresh_error,或 docker logs 里的 [oauth-login]/refresh 日志。")
+
+
+def main() -> None:
+ do_force = "--force" in sys.argv[1:]
+ refreshable = diagnose()
+ if do_force:
+ force_refresh(refreshable)
+ else:
+ print("提示: 加 --force 参数可真正触发一次刷新以验证能否成功。")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/services/__init__.py b/services/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/services/account_service.py b/services/account_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..faf844131fac6214034ef2651e560e6ff57f3095
--- /dev/null
+++ b/services/account_service.py
@@ -0,0 +1,1651 @@
+from __future__ import annotations
+
+import base64
+import json
+import secrets
+import time
+import uuid
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from threading import Condition, Lock, Thread
+from typing import Any
+from urllib.parse import urlencode
+
+from services.config import config
+from services.log_service import (
+ LOG_TYPE_ACCOUNT,
+ log_service,
+)
+from services.storage.base import StorageBackend
+from utils.helper import anonymize_token
+
+
+class AccountService:
+ """账号池服务,使用 token -> account 的 dict 保存账号。"""
+
+ _NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
+ _INVALID_CONFIRM_SECONDS = 30
+ _ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
+ _REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
+ _REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
+ _REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
+ _TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
+ _OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
+ _OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
+ _OAUTH_USER_AGENT = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/145.0.0.0 Safari/537.36"
+ )
+
+ # 刷新进度追踪
+ _refresh_progress: dict[str, dict] = {}
+ _refresh_progress_lock = Lock()
+ # 重新登录进度追踪
+ _relogin_progress: dict[str, dict] = {}
+ _relogin_progress_lock = Lock()
+
+ def __init__(self, storage_backend: StorageBackend):
+ self.storage = storage_backend
+ self._lock = Lock()
+ self._token_refresh_lock = Lock()
+ self._image_slot_condition = Condition(self._lock)
+ self._index = 0
+ self._accounts = self._load_accounts()
+ self._image_inflight: dict[str, int] = {}
+ self._token_aliases: dict[str, str] = {}
+ self._cumulative_total = self._load_cumulative_total()
+
+ def _get_cumulative_file(self) -> Path:
+ from services.config import DATA_DIR
+ return DATA_DIR / ".cumulative_total"
+
+ def _load_cumulative_total(self) -> int:
+ try:
+ f = self._get_cumulative_file()
+ if f.exists():
+ return int(f.read_text().strip())
+ except Exception:
+ pass
+ return len(self._accounts)
+
+ def _save_cumulative_total(self) -> None:
+ try:
+ self._get_cumulative_file().write_text(str(self._cumulative_total))
+ except Exception:
+ pass
+
+ @staticmethod
+ def _now() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+
+ @staticmethod
+ def _decode_jwt_payload(token: str) -> dict:
+ try:
+ payload = str(token or "").split(".")[1]
+ payload += "=" * ((4 - len(payload) % 4) % 4)
+ import base64
+ import json
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+ @staticmethod
+ def _parse_time(value: object) -> datetime | None:
+ raw = str(value or "").strip()
+ if not raw:
+ return None
+ try:
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ except Exception:
+ try:
+ parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
+ except Exception:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+ @staticmethod
+ def _timestamp_to_iso(value: object) -> str:
+ try:
+ ts = int(value)
+ except (TypeError, ValueError):
+ return ""
+ tz = timezone(timedelta(hours=8))
+ return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
+
+ def _load_accounts(self) -> dict[str, dict]:
+ accounts = self.storage.load_accounts()
+ return {
+ normalized["access_token"]: normalized
+ for item in accounts
+ if (normalized := self._normalize_account(item)) is not None
+ }
+
+ def _save_accounts(self) -> None:
+ self.storage.save_accounts(list(self._accounts.values()))
+
+ @staticmethod
+ def _is_image_account_available(account: dict) -> bool:
+ if not isinstance(account, dict):
+ return False
+ if account.get("status") in {"禁用", "限流", "异常"}:
+ return False
+ if bool(account.get("image_quota_unknown")):
+ return True
+ return int(account.get("quota") or 0) > 0
+
+ @classmethod
+ def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
+ if not plan_type:
+ return True
+ normalized_plan = cls._normalize_account_type(plan_type)
+ normalized_account = cls._normalize_account_type(account.get("type"))
+ if not normalized_plan or not normalized_account:
+ return False
+ return normalized_plan.lower() == normalized_account.lower()
+
+ @classmethod
+ def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
+ if not source_type:
+ return True
+ return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
+
+ @classmethod
+ def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
+ if not plan_types:
+ return True
+ normalized_account = cls._normalize_account_type(account.get("type"))
+ normalized_plans = {
+ normalized
+ for plan_type in plan_types
+ if (normalized := cls._normalize_account_type(plan_type))
+ }
+ return bool(normalized_account and normalized_account in normalized_plans)
+
+ @staticmethod
+ def _normalize_source_type(value: object) -> str:
+ return str(value or "web").strip().lower() or "web"
+
+ @staticmethod
+ def _normalize_account_type(value: object) -> str | None:
+ raw = str(value or "").strip()
+ if not raw:
+ return None
+ key = raw.lower().replace("-", "_").replace(" ", "_")
+ compact = key.replace("_", "")
+ aliases = {
+ "free": "free",
+ "plus": "Plus",
+ "pro": "Pro",
+ "prolite": "ProLite",
+ "team": "Team",
+ "business": "Team",
+ "enterprise": "Enterprise",
+ }
+ return aliases.get(compact) or aliases.get(key) or raw
+
+ def _search_account_type(self, payload: object) -> str | None:
+ if isinstance(payload, dict):
+ for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
+ plan = self._normalize_account_type(payload.get(key))
+ if plan:
+ return plan
+ for value in payload.values():
+ plan = self._search_account_type(value)
+ if plan:
+ return plan
+ elif isinstance(payload, list):
+ for value in payload:
+ plan = self._search_account_type(value)
+ if plan:
+ return plan
+ return None
+
+ def _normalize_account(self, item: dict) -> dict | None:
+ if not isinstance(item, dict):
+ return None
+ access_token = item.get("access_token") or item.get("accessToken") or ""
+ if not access_token:
+ return None
+ normalized = dict(item)
+ normalized.pop("accessToken", None)
+ normalized["access_token"] = access_token
+ if str(normalized.get("type") or "").strip().lower() == "codex":
+ normalized["export_type"] = "codex"
+ normalized.pop("type", None)
+ normalized["type"] = normalized.get("type") or "free"
+ normalized["status"] = normalized.get("status") or "正常"
+ normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
+ normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
+ normalized["email"] = normalized.get("email") or None
+ normalized["user_id"] = normalized.get("user_id") or None
+ normalized["proxy"] = str(normalized.get("proxy") or "").strip()
+ source_type = normalized.get("source_type")
+ if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
+ source_type = "codex"
+ normalized["source_type"] = self._normalize_source_type(source_type)
+ limits_progress = normalized.get("limits_progress")
+ normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
+ normalized["default_model_slug"] = normalized.get("default_model_slug") or None
+ normalized["restore_at"] = normalized.get("restore_at") or None
+ normalized["success"] = int(normalized.get("success") or 0)
+ normalized["fail"] = int(normalized.get("fail") or 0)
+ normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
+ normalized["last_used_at"] = normalized.get("last_used_at")
+ normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
+ normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
+ normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
+ normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
+ normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
+ normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
+ normalized["created_at"] = normalized.get("created_at") or AccountService._now()
+ return normalized
+
+ @staticmethod
+ def _jwt_exp(access_token: str) -> int:
+ try:
+ return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
+ except (TypeError, ValueError):
+ return 0
+
+ @classmethod
+ def _token_expires_in(cls, access_token: str) -> int | None:
+ exp = cls._jwt_exp(access_token)
+ if exp <= 0:
+ return None
+ return exp - int(time.time())
+
+ @classmethod
+ def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
+ if force:
+ return True
+ remaining = cls._token_expires_in(access_token)
+ return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
+
+ @classmethod
+ def _token_issued_at(cls, access_token: str) -> datetime | None:
+ try:
+ iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
+ except (TypeError, ValueError):
+ return None
+ if iat <= 0:
+ return None
+ return datetime.fromtimestamp(iat, tz=timezone.utc)
+
+ @staticmethod
+ def _safe_response_text(response: object, limit: int = 300) -> str:
+ try:
+ return str(getattr(response, "text", "") or "")[:limit]
+ except Exception:
+ return ""
+
+ def _resolve_access_token_locked(self, access_token: str) -> str:
+ token = str(access_token or "").strip()
+ seen: set[str] = set()
+ while token and token not in self._accounts and token in self._token_aliases and token not in seen:
+ seen.add(token)
+ token = self._token_aliases.get(token, token)
+ return token
+
+ def resolve_access_token(self, access_token: str) -> str:
+ if not access_token:
+ return ""
+ with self._lock:
+ return self._resolve_access_token_locked(access_token)
+
+ def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
+ with self._lock:
+ resolved = self._resolve_access_token_locked(access_token)
+ account = self._accounts.get(resolved)
+ return resolved, dict(account) if account else None
+
+ def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
+ now = datetime.now(timezone.utc).isoformat()
+ with self._lock:
+ resolved = self._resolve_access_token_locked(access_token)
+ current = self._accounts.get(resolved)
+ if current is None:
+ return
+ next_item = dict(current)
+ next_item["last_token_refresh_error"] = str(error or "refresh token failed")
+ next_item["last_token_refresh_error_at"] = now
+ account = self._normalize_account(next_item)
+ if account is not None:
+ self._accounts[resolved] = account
+ self._save_accounts()
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "refresh_token 刷新 access_token 失败",
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
+ )
+
+ def _recent_token_refresh_error(self, account: dict) -> bool:
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
+ if last_error_at is None:
+ return False
+ return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
+
+ def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
+ last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
+ if last_error_at is None:
+ return False
+ return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
+
+ def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
+ return (
+ self._parse_time(account.get("last_token_refresh_at"))
+ or self._token_issued_at(str(account.get("access_token") or ""))
+ or self._parse_time(account.get("created_at"))
+ )
+
+ def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
+ if not str(account.get("refresh_token") or "").strip():
+ return None
+ if account.get("status") == "禁用":
+ return None
+ if self._recent_refresh_token_keepalive_error(account, now):
+ return None
+ anchor = self._refresh_token_keepalive_anchor(account)
+ if anchor is None:
+ return now
+ due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
+ return due_at if due_at <= now else None
+
+ def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]:
+ from curl_cffi import requests
+ from services.proxy_service import proxy_settings
+
+ session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome", verify=True))
+ try:
+ response = session.post(
+ self._OAUTH_TOKEN_URL,
+ headers={
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": self._OAUTH_USER_AGENT,
+ },
+ data={
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": self._OAUTH_CLIENT_ID,
+ },
+ timeout=60,
+ )
+ data = response.json() if response.text else {}
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
+ detail = ""
+ if isinstance(data, dict):
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
+ detail = detail or self._safe_response_text(response)
+ raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
+ return {
+ "access_token": str(data.get("access_token") or "").strip(),
+ "refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
+ "id_token": str(data.get("id_token") or "").strip(),
+ }
+ finally:
+ session.close()
+
+ def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
+ now = datetime.now(timezone.utc).isoformat()
+ with self._image_slot_condition:
+ old_token = self._resolve_access_token_locked(old_access_token)
+ current = self._accounts.get(old_token)
+ if current is None:
+ return old_token
+ new_token = str(token_data.get("access_token") or old_token).strip()
+ if not new_token:
+ return old_token
+
+ next_item = dict(current)
+ next_item["access_token"] = new_token
+ if token_data.get("refresh_token"):
+ next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
+ if token_data.get("id_token"):
+ next_item["id_token"] = str(token_data.get("id_token") or "").strip()
+ next_item["last_token_refresh_at"] = now
+ next_item["last_token_refresh_error"] = None
+ next_item["last_token_refresh_error_at"] = None
+ next_item["invalid_count"] = 0
+ next_item["last_invalid_at"] = None
+ next_item["last_refresh_error"] = None
+ next_item["last_refresh_error_at"] = None
+
+ account = self._normalize_account(next_item)
+ if account is None:
+ return old_token
+
+ rotated = new_token != old_token
+ if rotated:
+ self._accounts.pop(old_token, None)
+ self._token_aliases[old_token] = new_token
+ old_inflight = int(self._image_inflight.pop(old_token, 0))
+ if old_inflight:
+ self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
+ self._accounts[new_token] = account
+ self._save_accounts()
+ self._image_slot_condition.notify_all()
+
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "refresh_token 已刷新 access_token",
+ {"source": event, "token": anonymize_token(new_token), "rotated": rotated},
+ )
+ return new_token
+
+ def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
+ if not access_token:
+ return ""
+ with self._token_refresh_lock:
+ resolved_token, account = self._get_account_for_token(access_token)
+ if not account:
+ return access_token
+ active_token = str(account.get("access_token") or resolved_token or access_token)
+ if not self._token_needs_refresh(active_token, force=force):
+ return active_token
+ refresh_token = str(account.get("refresh_token") or "").strip()
+ if not refresh_token:
+ return active_token
+ if not force and self._recent_token_refresh_error(account):
+ return active_token
+ try:
+ token_data = self._request_access_token_refresh(refresh_token, account)
+ except Exception as exc:
+ error_str = str(exc or "")
+ self._record_token_refresh_error(active_token, event, error_str)
+ # 如果是 app_session_terminated 错误,尝试密码重新登录
+ if "app_session_terminated" in error_str.lower():
+ # 获取账号信息(email, password)
+ email = str(account.get("email") or "").strip()
+ password = str(account.get("password") or "").strip()
+ if email and password:
+ # 创建新线程执行密码重新登录
+ t = Thread(
+ target=self._password_re_login_thread,
+ args=(active_token, email, password, event),
+ daemon=True,
+ )
+ t.start()
+ return active_token
+ return self._apply_refreshed_tokens(active_token, token_data, event)
+
+ def _password_re_login_thread(self, access_token: str, email: str, password: str, event: str, progress_id: str | None = None) -> None:
+ """密码重新登录线程入口"""
+ try:
+ result = self._login_with_password(email, password)
+ if result.get("ok"):
+ # 登录成功,更新账号
+ new_access_token = result.get("access_token", "")
+ new_refresh_token = result.get("refresh_token", "")
+ new_id_token = result.get("id_token", "")
+ new_expires_at = result.get("expires_at")
+
+ # 构建 token_data 供 _apply_refreshed_tokens 使用
+ token_data = {
+ "access_token": new_access_token,
+ "refresh_token": new_refresh_token,
+ "id_token": new_id_token,
+ }
+
+ # 使用 _apply_refreshed_tokens 更新账号(处理 token 别名)
+ new_token = self._apply_refreshed_tokens(access_token, token_data, f"{event}:password_relogin")
+
+ # 额外更新 source_type 和 status(静默,避免重复日志)
+ self.update_account(new_token, {
+ "source_type": result.get("source_type", "password"),
+ "status": "正常",
+ }, quiet=True)
+
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "更新账号",
+ {
+ "source": event,
+ "old_token": anonymize_token(access_token),
+ "new_token": anonymize_token(new_access_token),
+ "email": email,
+ "status": "成功",
+ },
+ )
+ if progress_id:
+ self.update_relogin_progress(progress_id, access_token, "成功")
+ else:
+ # 登录失败
+ error_type = result.get("error", "")
+ if error_type == "password_verify_failed_403" and isinstance(result.get("detail"), dict):
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "更新账号",
+ {
+ "source": event,
+ "token": anonymize_token(access_token),
+ "email": email,
+ "status": "失败",
+ "error": error_type,
+ "detail": result.get("detail", {}),
+ },
+ )
+ detail_error = result["detail"].get("error", {})
+ if isinstance(detail_error, dict) and detail_error.get("code") == "account_deactivated":
+ # 账号已删除/停用 → 标记为禁用
+ self.update_account(access_token, {"status": "禁用", "quota": 0}, quiet=True)
+ account = self.get_account(access_token) or {}
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "账号已停用-标记禁用",
+ {
+ "source": event,
+ "token": anonymize_token(access_token),
+ "email": email,
+ "detail": result.get("detail", {}),
+ },
+ )
+ if progress_id:
+ self.update_relogin_progress(progress_id, access_token, "禁用")
+ else:
+ # 永久故障:将账号标记为异常(或自动移除)
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
+ if progress_id:
+ self.update_relogin_progress(progress_id, access_token, "异常", error_type)
+ else:
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "更新账号",
+ {
+ "source": event,
+ "token": anonymize_token(access_token),
+ "email": email,
+ "status": "失败",
+ "error": error_type,
+ "detail": result.get("detail", {}),
+ },
+ )
+ # 永久故障:将账号标记为异常(或自动移除)
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
+ if progress_id:
+ self.update_relogin_progress(progress_id, access_token, "异常", error_type)
+ except Exception as exc:
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "更新账号",
+ {
+ "source": event,
+ "token": anonymize_token(access_token),
+ "email": email,
+ "status": "异常",
+ "error": str(exc),
+ },
+ )
+ # 将账号标记为异常(或自动移除)
+ self.remove_invalid_token(access_token, f"{event}:password_relogin_exception", quiet=True)
+ if progress_id:
+ self.update_relogin_progress(progress_id, access_token, "异常", str(exc))
+
+ def _login_with_password(self, email: str, password: str) -> dict:
+ """通过邮箱+密码登录,返回 {access_token, refresh_token, id_token, ...}"""
+ from curl_cffi import requests
+
+ # 常量
+ auth_base = "https://auth.openai.com"
+ platform_oauth_audience = "https://api.openai.com/v1"
+ platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
+ platform_oauth_client_id = self._OAUTH_CLIENT_ID
+ platform_oauth_redirect_uri = "https://platform.openai.com/auth/callback"
+ user_agent = self._OAUTH_USER_AGENT
+
+ # 创建 session
+ session_kwargs = {"impersonate": "chrome110", "verify": False}
+ proxy = config.get_proxy_settings()
+ if proxy:
+ session_kwargs["proxy"] = proxy
+ session = requests.Session(**session_kwargs)
+
+ try:
+ device_id = str(uuid.uuid4())
+
+ # ─── 方式2: OAuth authorize 流程 ──────────────────────────
+ # 使用 Platform Client + PKCE(与注册流程相同)
+
+ from utils.pkce import generate_pkce
+ code_verifier, code_challenge = generate_pkce()
+
+ # ② 发起 OAuth authorize 请求 (使用 Platform Client + PKCE)
+ session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
+ session.cookies.set("oai-did", device_id, domain="auth.openai.com")
+ params = {
+ "issuer": auth_base,
+ "client_id": platform_oauth_client_id,
+ "audience": platform_oauth_audience,
+ "redirect_uri": platform_oauth_redirect_uri,
+ "device_id": device_id,
+ "screen_hint": "login_or_signup",
+ "max_age": "0",
+ "login_hint": email,
+ "scope": "openid profile email offline_access",
+ "response_type": "code",
+ "response_mode": "query",
+ "state": secrets.token_urlsafe(32),
+ "nonce": secrets.token_urlsafe(32),
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "auth0Client": platform_auth0_client,
+ }
+ authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
+ resp = session.get(
+ authorize_url,
+ headers={
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "user-agent": user_agent,
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-fetch-dest": "document",
+ "sec-fetch-mode": "navigate",
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-user": "?1",
+ "upgrade-insecure-requests": "1",
+ "referer": "https://platform.openai.com/",
+ },
+ allow_redirects=True,
+ timeout=30,
+ )
+
+ if resp.status_code not in (200, 302):
+ return {"ok": False, "error": f"authorize_failed_{resp.status_code}", "detail": {"url": resp.url, "text": resp.text[:500]}}
+
+ # 检测最终 URL 是否指向错误页面
+ final_url = str(resp.url)
+ if "/error" in final_url and "payload=" in final_url:
+ from urllib.parse import parse_qs, urlparse
+ try:
+ parsed_query = parse_qs(urlparse(final_url).query)
+ error_payload_b64 = parsed_query.get("payload", [""])[0]
+ error_payload_b64 += "=" * ((4 - len(error_payload_b64) % 4) % 4)
+ error_payload = json.loads(base64.b64decode(error_payload_b64))
+ error_code = error_payload.get("errorCode", "")
+ if error_code == "rate_limit_exceeded":
+ return {"ok": False, "error": "rate_limit_exceeded", "detail": error_payload}
+ else:
+ return {"ok": False, "error": f"authorize_error_{error_code}", "detail": error_payload}
+ except Exception as e:
+ return {"ok": False, "error": "authorize_redirect_error", "detail": {"url": final_url, "parse_error": str(e)}}
+
+ # ③ 提交密码验证
+ login_headers = {
+ "accept": "application/json",
+ "accept-language": "zh-CN,zh;q=0.9",
+ "content-type": "application/json",
+ "origin": auth_base,
+ "priority": "u=1, i",
+ "user-agent": user_agent,
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "same-origin",
+ "referer": f"{auth_base}/email-verification",
+ "oai-device-id": device_id,
+ }
+
+ # 添加 sentinel token
+ try:
+ from utils.sentinel import build_sentinel_token
+ sentinel_val, oai_sc_val = build_sentinel_token(session, device_id, "password_verify")
+ login_headers["openai-sentinel-token"] = sentinel_val
+ if oai_sc_val:
+ session.cookies.set("oai-sc", oai_sc_val, domain=".openai.com")
+ except Exception:
+ pass
+
+ login_resp = session.post(
+ f"{auth_base}/api/accounts/password/verify",
+ headers=login_headers,
+ json={"password": password},
+ timeout=30,
+ )
+
+ login_data = {}
+ try:
+ login_data = login_resp.json() if login_resp.text else {}
+ except Exception:
+ pass
+
+ if login_resp.status_code != 200:
+ error_code = login_data.get("error", {}).get("code", "")
+ error_msg = login_data.get("error", {}).get("message", "")
+ if error_code == "unsupported_country_region_territory":
+ return {"ok": False, "error": "unsupported_country_region_territory", "detail": login_data}
+ elif error_code == "invalid_state":
+ return {"ok": False, "error": "invalid_state", "detail": login_data}
+ elif "Invalid credentials" in error_msg or "wrong password" in error_msg.lower():
+ return {"ok": False, "error": "invalid_password", "detail": login_data}
+ return {"ok": False, "error": f"password_verify_failed_{login_resp.status_code}", "detail": login_data}
+
+ # 获取 authorization code
+ continue_url = str(login_data.get("continue_url") or "").strip()
+ auth_code = ""
+ if continue_url:
+ from urllib.parse import parse_qs, urlparse
+ parsed_params = parse_qs(urlparse(continue_url).query)
+ auth_code = str((parsed_params.get("code") or [""])[0]).strip()
+
+ # ─── 处理邮箱 OTP 验证 ──────────────────────────
+ if not auth_code:
+ page_type = ""
+ page_info = login_data.get("page")
+ if isinstance(page_info, dict):
+ page_type = str(page_info.get("type") or "")
+
+ if page_type == "email_otp_verification":
+ # 需要验证码才能登录,直接标记为账号异常
+ return {"ok": False, "error": "need_verification_code", "detail": login_data}
+ else:
+ return {"ok": False, "error": "no_auth_code", "detail": login_data}
+
+ # ④ 用 code 换 token (使用 Platform Client + code_verifier,与注册流程相同)
+ platform_base = "https://platform.openai.com"
+ token_resp = session.post(
+ f"{auth_base}/api/accounts/oauth/token",
+ headers={
+ "accept": "*/*",
+ "accept-language": "zh-CN,zh;q=0.9",
+ "auth0-client": platform_auth0_client,
+ "cache-control": "no-cache",
+ "content-type": "application/json",
+ "origin": platform_base,
+ "pragma": "no-cache",
+ "priority": "u=1, i",
+ "referer": f"{platform_base}/",
+ "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "same-site",
+ "user-agent": user_agent,
+ },
+ json={
+ "client_id": platform_oauth_client_id,
+ "code_verifier": code_verifier,
+ "grant_type": "authorization_code",
+ "code": auth_code,
+ "redirect_uri": platform_oauth_redirect_uri,
+ },
+ verify=False,
+ timeout=60,
+ )
+
+ token_data = {}
+ try:
+ token_data = token_resp.json() if token_resp.text else {}
+ except Exception:
+ pass
+
+ if token_resp.status_code != 200 or not token_data.get("access_token"):
+ return {"ok": False, "error": "token_exchange_failed", "detail": token_data}
+
+ access_token = str(token_data.get("access_token") or "").strip()
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
+ id_token = str(token_data.get("id_token") or "").strip()
+
+ # ⑤ 用 access_token 获取用户信息
+ user_info = {}
+ try:
+ me_resp = session.get(
+ "https://chatgpt.com/backend-api/me",
+ headers={
+ "accept": "application/json",
+ "authorization": f"Bearer {access_token}",
+ "user-agent": user_agent,
+ },
+ timeout=30,
+ )
+ if me_resp.status_code == 200:
+ user_info = me_resp.json() if me_resp.text else {}
+ except Exception:
+ pass
+
+ # 解析 JWT payload
+ jwt_payload = self._decode_jwt_payload(access_token)
+
+ email_from_jwt = str(jwt_payload.get("https://api.openai.com/profile", {}).get("email") or "").strip()
+ account_id_from_jwt = str(
+ jwt_payload.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") or ""
+ ).strip()
+
+ account_info = user_info.get("account") if isinstance(user_info.get("account"), dict) else {}
+ result = {
+ "ok": True,
+ "email": email_from_jwt or email,
+ "account_id": account_id_from_jwt or account_info.get("account_id", ""),
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "id_token": id_token,
+ "expires_at": jwt_payload.get("exp"),
+ "source_type": "password",
+ }
+
+ return result
+
+ finally:
+ session.close()
+
+ def list_expiring_access_tokens(self) -> list[str]:
+ with self._lock:
+ return [
+ token
+ for account in self._accounts.values()
+ if str(account.get("refresh_token") or "").strip()
+ and (token := str(account.get("access_token") or "").strip())
+ and self._token_needs_refresh(token)
+ ]
+
+ def list_refresh_token_keepalive_tokens(self) -> list[str]:
+ now = datetime.now(timezone.utc)
+ due_items: list[tuple[datetime, str]] = []
+ with self._lock:
+ for account in self._accounts.values():
+ due_at = self._refresh_token_keepalive_due_at(account, now)
+ token = str(account.get("access_token") or "").strip()
+ if due_at is not None and token:
+ due_items.append((due_at, token))
+ due_items.sort(key=lambda item: item[0])
+ return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
+
+ def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
+ if not access_tokens:
+ return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
+
+ refreshed = 0
+ errors = []
+ for access_token in access_tokens:
+ before = self.resolve_access_token(access_token)
+ after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
+ account = self.get_account(after)
+ if account and str(account.get("last_token_refresh_error") or "").strip():
+ errors.append({
+ "token": anonymize_token(before),
+ "error": str(account.get("last_token_refresh_error") or "refresh token failed"),
+ })
+ continue
+ if account:
+ refreshed += 1
+
+ return {
+ "refreshed": refreshed,
+ "errors": errors,
+ "items": self.list_accounts(),
+ "relogined": 0,
+ }
+
+ def list_tokens(self) -> list[str]:
+ with self._lock:
+ return list(self._accounts)
+
+ def _list_ready_candidate_tokens(
+ self,
+ excluded_tokens: set[str] | None = None,
+ plan_type: str | None = None,
+ source_type: str | None = None,
+ plan_types: set[str] | tuple[str, ...] | None = None,
+ ) -> list[str]:
+ excluded = set(excluded_tokens or set())
+ return [
+ token
+ for item in self._accounts.values()
+ if self._is_image_account_available(item)
+ and self._account_matches_plan_type(item, plan_type)
+ and self._account_matches_any_plan_type(item, plan_types)
+ and self._account_matches_source_type(item, source_type)
+ and (token := item.get("access_token") or "")
+ and token not in excluded
+ ]
+
+ def _list_available_candidate_tokens(
+ self,
+ excluded_tokens: set[str] | None = None,
+ plan_type: str | None = None,
+ source_type: str | None = None,
+ plan_types: set[str] | tuple[str, ...] | None = None,
+ ) -> list[str]:
+ max_concurrency = max(1, int(config.image_account_concurrency or 1))
+ return [
+ token
+ for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
+ if int(self._image_inflight.get(token, 0)) < max_concurrency
+ ]
+
+ def _acquire_next_candidate_token(
+ self,
+ excluded_tokens: set[str] | None = None,
+ plan_type: str | None = None,
+ source_type: str | None = None,
+ plan_types: set[str] | tuple[str, ...] | None = None,
+ ) -> str:
+ with self._image_slot_condition:
+ while True:
+ if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types):
+ raise RuntimeError(
+ f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
+ if plan_type or source_type else "no available image quota"
+ )
+ tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
+ if tokens:
+ access_token = tokens[self._index % len(tokens)]
+ self._index += 1
+ self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
+ return access_token
+ self._image_slot_condition.wait(timeout=1.0)
+
+ def release_image_slot(self, access_token: str) -> None:
+ if not access_token:
+ return
+ with self._image_slot_condition:
+ access_token = self._resolve_access_token_locked(access_token)
+ current_inflight = int(self._image_inflight.get(access_token, 0))
+ if current_inflight <= 1:
+ self._image_inflight.pop(access_token, None)
+ else:
+ self._image_inflight[access_token] = current_inflight - 1
+ self._image_slot_condition.notify_all()
+
+ def get_available_access_token(
+ self,
+ plan_type: str | None = None,
+ source_type: str | None = None,
+ plan_types: set[str] | tuple[str, ...] | None = None,
+ ) -> str:
+ """从候选池中获取一个可用的图片生图 token。
+
+ 基于本地缓存做初筛,然后通过 fetch_remote_info 做远程验证(token 有效性、配额等)。
+ 限制最大尝试次数防止 token rotation 导致无限循环。
+ """
+ max_attempts = 20 # 防止无限循环
+ attempted_tokens: set[str] = set()
+ for _attempt in range(max_attempts):
+ access_token = self._acquire_next_candidate_token(
+ excluded_tokens=attempted_tokens,
+ plan_type=plan_type,
+ source_type=source_type,
+ plan_types=plan_types,
+ )
+ attempted_tokens.add(access_token)
+ try:
+ account = self.fetch_remote_info(access_token, "get_available_access_token")
+ except Exception:
+ self.release_image_slot(access_token)
+ continue
+ # fetch_remote_info 内部可能因 token rotation 导致 access_token 变化,
+ # 把新 token 也加入排除列表,防止重复尝试
+ resolved = str((account or {}).get("access_token") or "")
+ if resolved and resolved != access_token:
+ attempted_tokens.add(resolved)
+ if (
+ self._is_image_account_available(account or {})
+ and self._account_matches_plan_type(account or {}, plan_type)
+ and self._account_matches_any_plan_type(account or {}, plan_types)
+ and self._account_matches_source_type(account or {}, source_type)
+ ):
+ return str((account or {}).get("access_token") or access_token)
+ self.release_image_slot(access_token)
+ raise RuntimeError(
+ f"no available {plan_type or source_type or ''} image quota (tried {len(attempted_tokens)} tokens)".replace(" ", " ").strip()
+ if plan_type or source_type else f"no available image quota (tried {len(attempted_tokens)} tokens)"
+ )
+
+ def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
+ excluded = set(excluded_tokens or set())
+ with self._lock:
+ candidates = [
+ token
+ for account in self._accounts.values()
+ if account.get("status") not in {"禁用", "异常"}
+ and (token := account.get("access_token") or "")
+ and token not in excluded
+ ]
+ if not candidates:
+ return ""
+ access_token = candidates[self._index % len(candidates)]
+ self._index += 1
+ return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
+
+ def mark_text_used(self, access_token: str) -> None:
+ if not access_token:
+ return
+ with self._lock:
+ access_token = self._resolve_access_token_locked(access_token)
+ current = self._accounts.get(access_token)
+ if current is None:
+ return
+ next_item = dict(current)
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ account = self._normalize_account(next_item)
+ if account is None:
+ return
+ self._accounts[access_token] = account
+ self._save_accounts()
+
+ def remove_invalid_token(self, access_token: str, event: str, quiet: bool = False) -> bool:
+ if not config.auto_remove_invalid_accounts:
+ self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
+ return False
+ removed = bool(self.delete_accounts([access_token])["removed"])
+ if removed:
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
+ {"source": event, "token": anonymize_token(access_token)})
+ elif access_token:
+ self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
+ return removed
+
+ def get_account(self, access_token: str) -> dict | None:
+ if not access_token:
+ return None
+ with self._lock:
+ access_token = self._resolve_access_token_locked(access_token)
+ account = self._accounts.get(access_token)
+ return dict(account) if account else None
+
+ def list_accounts(self) -> list[dict]:
+ with self._lock:
+ return [dict(item) for item in self._accounts.values()]
+
+ def list_limited_tokens(self) -> list[str]:
+ with self._lock:
+ return [
+ token
+ for item in self._accounts.values()
+ if item.get("status") == "限流"
+ and (token := item.get("access_token") or "")
+ ]
+
+ @staticmethod
+ def _account_payload_token(item: dict) -> str:
+ return str(item.get("access_token") or item.get("accessToken") or "").strip()
+
+ @staticmethod
+ def _prepare_account_payload(item: dict) -> dict | None:
+ if not isinstance(item, dict):
+ return None
+ access_token = AccountService._account_payload_token(item)
+ if not access_token:
+ return None
+ payload = dict(item)
+ payload.pop("accessToken", None)
+ payload["access_token"] = access_token
+ # CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
+ if str(payload.get("type") or "").strip().lower() == "codex":
+ payload["export_type"] = "codex"
+ payload["source_type"] = "codex"
+ payload.pop("type", None)
+ if str(payload.get("export_type") or "").strip().lower() == "codex":
+ payload["source_type"] = "codex"
+ if payload.get("plan_type") and not payload.get("type"):
+ payload["type"] = str(payload.get("plan_type") or "").strip()
+ return payload
+
+ def add_account_items(self, items: list[dict]) -> dict:
+ payloads = [
+ payload
+ for item in items
+ if (payload := self._prepare_account_payload(item)) is not None
+ ]
+ return self._add_account_payloads(payloads)
+
+ def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
+ tokens = list(dict.fromkeys(token for token in tokens if token))
+ if not tokens:
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
+ return self._add_account_payloads([
+ {"access_token": token, "source_type": self._normalize_source_type(source_type)}
+ for token in tokens
+ ])
+
+ def _add_account_payloads(self, payloads: list[dict]) -> dict:
+ deduped: dict[str, dict] = {}
+ for payload in payloads:
+ if not isinstance(payload, dict):
+ continue
+ access_token = self._account_payload_token(payload)
+ if not access_token:
+ continue
+ current = deduped.get(access_token, {})
+ deduped[access_token] = {**current, **payload, "access_token": access_token}
+
+ if not deduped:
+ return {"added": 0, "skipped": 0, "items": self.list_accounts()}
+
+ with self._lock:
+ added = 0
+ skipped = 0
+ for access_token, payload in deduped.items():
+ current = self._accounts.get(access_token)
+ if current is None:
+ added += 1
+ self._cumulative_total += 1
+ self._save_cumulative_total()
+ current = {"created_at": self._now()}
+ else:
+ skipped += 1
+ incoming = dict(payload)
+ if not incoming.get("created_at"):
+ incoming.pop("created_at", None)
+ account = self._normalize_account(
+ {
+ **current,
+ **incoming,
+ "access_token": access_token,
+ "type": str(incoming.get("type") or current.get("type") or "free"),
+ }
+ )
+ if account is not None:
+ self._accounts[access_token] = account
+ self._save_accounts()
+ items = [dict(item) for item in self._accounts.values()]
+ log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
+ {"added": added, "skipped": skipped})
+ return {"added": added, "skipped": skipped, "items": items}
+
+ def delete_accounts(self, tokens: list[str]) -> dict:
+ target_set = set(token for token in tokens if token)
+ if not target_set:
+ return {"removed": 0, "items": self.list_accounts()}
+ with self._lock:
+ target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
+ removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
+ for token in target_set:
+ self._image_inflight.pop(token, None)
+ self._token_aliases = {
+ old: new
+ for old, new in self._token_aliases.items()
+ if old not in target_set and new not in target_set
+ }
+ if removed:
+ if self._accounts:
+ self._index %= len(self._accounts)
+ else:
+ self._index = 0
+ self._save_accounts()
+ log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
+ items = [dict(item) for item in self._accounts.values()]
+ return {"removed": removed, "items": items}
+
+ def update_account(self, access_token: str, updates: dict, quiet: bool = False) -> dict | None:
+ if not access_token:
+ return None
+ with self._lock:
+ access_token = self._resolve_access_token_locked(access_token)
+ current = self._accounts.get(access_token)
+ if current is None:
+ return None
+ account = self._normalize_account({**current, **updates, "access_token": access_token})
+ if account is None:
+ return None
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
+ self._accounts.pop(access_token, None)
+ self._save_accounts()
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
+ return None
+ self._accounts[access_token] = account
+ self._save_accounts()
+ if not quiet:
+ log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
+ {"token": anonymize_token(access_token), "status": account.get("status")})
+ return dict(account)
+ return None
+
+ def _record_refresh_success(self, access_token: str) -> None:
+ with self._lock:
+ access_token = self._resolve_access_token_locked(access_token)
+ current = self._accounts.get(access_token)
+ if current is None:
+ return
+ next_item = dict(current)
+ next_item["invalid_count"] = 0
+ next_item["last_invalid_at"] = None
+ next_item["last_refresh_error"] = None
+ next_item["last_refresh_error_at"] = None
+ account = self._normalize_account(next_item)
+ if account is not None:
+ self._accounts[access_token] = account
+
+ def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
+ if not isinstance(account, dict):
+ return False
+ created_at = self._parse_time(account.get("created_at"))
+ if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
+ return True
+ last_invalid_at = self._parse_time(account.get("last_invalid_at"))
+ invalid_count = int(account.get("invalid_count") or 0)
+ if invalid_count <= 1:
+ return True
+ if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
+ return True
+ return False
+
+ def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool:
+ now = datetime.now(timezone.utc)
+ with self._lock:
+ access_token = self._resolve_access_token_locked(access_token)
+ current = self._accounts.get(access_token)
+ if current is None:
+ return True
+ should_defer = self._should_defer_invalid_token(current, now)
+ next_item = dict(current)
+ next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
+ next_item["last_invalid_at"] = now.isoformat()
+ next_item["last_refresh_error"] = str(error or "invalid access token")
+ next_item["last_refresh_error_at"] = now.isoformat()
+ account = self._normalize_account(next_item)
+ if account is not None:
+ self._accounts[access_token] = account
+ self._save_accounts()
+ if should_defer:
+ log_service.add(
+ LOG_TYPE_ACCOUNT,
+ "暂缓标记异常账号",
+ {"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
+ )
+ return False
+ return True
+
+ def mark_image_result(self, access_token: str, success: bool) -> dict | None:
+ if not access_token:
+ return None
+ self.release_image_slot(access_token)
+ with self._lock:
+ access_token = self._resolve_access_token_locked(access_token)
+ current = self._accounts.get(access_token)
+ if current is None:
+ return None
+ next_item = dict(current)
+ next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ image_quota_unknown = bool(next_item.get("image_quota_unknown"))
+ if success:
+ next_item["success"] = int(next_item.get("success") or 0) + 1
+ if not image_quota_unknown:
+ next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
+ if not image_quota_unknown and next_item["quota"] == 0:
+ next_item["status"] = "限流"
+ next_item["restore_at"] = next_item.get("restore_at") or None
+ elif next_item.get("status") == "限流":
+ next_item["status"] = "正常"
+ else:
+ next_item["fail"] = int(next_item.get("fail") or 0) + 1
+ account = self._normalize_account(next_item)
+ if account is None:
+ return None
+ if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
+ self._accounts.pop(access_token, None)
+ self._save_accounts()
+ log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
+ return None
+ self._accounts[access_token] = account
+ self._save_accounts()
+ return dict(account)
+ return None
+
+ def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
+ if not access_token:
+ raise ValueError("access_token is required")
+
+ active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
+ try:
+ from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
+ result = OpenAIBackendAPI(active_token).get_user_info()
+ except InvalidAccessTokenError as exc:
+ refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
+ if refreshed_token and refreshed_token != active_token:
+ try:
+ result = OpenAIBackendAPI(refreshed_token).get_user_info()
+ except InvalidAccessTokenError as retry_exc:
+ if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)):
+ self.remove_invalid_token(refreshed_token, event)
+ raise
+ active_token = refreshed_token
+ else:
+ if self._record_invalid_token_seen(active_token, event, str(exc)):
+ self.remove_invalid_token(active_token, event)
+ raise
+ self._record_refresh_success(active_token)
+ return self.update_account(active_token, result)
+
+ # ---- 刷新进度追踪 ----
+
+ def init_refresh_progress(self, progress_id: str, total: int) -> None:
+ """初始化刷新进度记录。"""
+ with self._refresh_progress_lock:
+ self._refresh_progress[progress_id] = {
+ "total": total,
+ "processed": 0,
+ "done": False,
+ "error": None,
+ "status_counts": {"正常": 0, "限流": 0, "异常": 0, "禁用": 0},
+ "total_quota": 0,
+ }
+
+ def update_refresh_progress(self, progress_id: str, token: str) -> None:
+ """刷新单个账号后,更新进度计数。"""
+ account = self.get_account(token)
+ status = str(account.get("status") or "正常").strip() if account else "正常"
+ quota = max(0, int(account.get("quota") or 0)) if account else 0
+
+ with self._refresh_progress_lock:
+ progress = self._refresh_progress.get(progress_id)
+ if progress is None:
+ return
+ progress["processed"] += 1
+ progress["status_counts"][status] = progress["status_counts"].get(status, 0) + 1
+ progress["total_quota"] += quota
+
+ def finish_refresh_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
+ """标记刷新完成。"""
+ with self._refresh_progress_lock:
+ progress = self._refresh_progress.get(progress_id)
+ if progress is None:
+ return
+ progress["done"] = True
+ progress["result"] = result
+ if error:
+ progress["error"] = error
+
+ def get_refresh_progress(self, progress_id: str) -> dict | None:
+ """查询刷新进度。"""
+ with self._refresh_progress_lock:
+ progress = self._refresh_progress.get(progress_id)
+ return dict(progress) if progress else None
+
+ def clean_refresh_progress(self, progress_id: str) -> None:
+ """清理过期进度记录。"""
+ with self._refresh_progress_lock:
+ self._refresh_progress.pop(progress_id, None)
+
+ # ---- 重新登录进度追踪 ----
+
+ def init_relogin_progress(self, progress_id: str, total: int) -> None:
+ """初始化重新登录进度记录。"""
+ with self._relogin_progress_lock:
+ self._relogin_progress[progress_id] = {
+ "total": total,
+ "processed": 0,
+ "done": False,
+ "error": None,
+ "results": [],
+ }
+
+ def update_relogin_progress(self, progress_id: str, token: str, status: str, error: str | None = None) -> None:
+ """更新单个重新登录进度。当所有账号处理完毕时自动标记完成。"""
+ with self._relogin_progress_lock:
+ progress = self._relogin_progress.get(progress_id)
+ if progress is None:
+ return
+ progress["processed"] += 1
+ progress["results"].append({
+ "token": anonymize_token(token),
+ "status": status,
+ "error": error,
+ })
+ if progress["processed"] >= progress["total"]:
+ progress["done"] = True
+
+ def finish_relogin_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
+ """标记重新登录完成。"""
+ with self._relogin_progress_lock:
+ progress = self._relogin_progress.get(progress_id)
+ if progress is None:
+ return
+ progress["done"] = True
+ progress["result"] = result
+ if error:
+ progress["error"] = error
+
+ def get_relogin_progress(self, progress_id: str) -> dict | None:
+ """查询重新登录进度。"""
+ with self._relogin_progress_lock:
+ progress = self._relogin_progress.get(progress_id)
+ return dict(progress) if progress else None
+
+ def clean_relogin_progress(self, progress_id: str) -> None:
+ """清理过期进度记录。"""
+ with self._relogin_progress_lock:
+ self._relogin_progress.pop(progress_id, None)
+
+ def refresh_accounts(self, access_tokens: list[str], progress_id: str | None = None) -> dict[str, Any]:
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
+ if not access_tokens:
+ items = self.list_accounts()
+ result = {"refreshed": 0, "errors": [], "items": items, "relogined": 0}
+ if progress_id:
+ self.finish_refresh_progress(progress_id, result)
+ return result
+
+ refreshed = 0
+ errors = []
+ max_workers = min(10, len(access_tokens))
+
+ if progress_id:
+ self.init_refresh_progress(progress_id, len(access_tokens))
+
+ executor = ThreadPoolExecutor(max_workers=max_workers)
+ try:
+ futures = {
+ executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
+ for token in access_tokens
+ }
+ for future in as_completed(futures):
+ token = futures[future]
+ try:
+ account = future.result()
+ except (KeyboardInterrupt, SystemExit):
+ executor.shutdown(wait=False, cancel_futures=True)
+ raise
+ except Exception as exc:
+ error_str = str(exc)
+ # TLS/代理连接错误是网络问题,不计入账号失败
+ from services.protocol.conversation import is_tls_connection_error
+ if not is_tls_connection_error(error_str):
+ errors.append({"token": anonymize_token(token), "error": error_str})
+ else:
+ if account is not None:
+ refreshed += 1
+
+ if progress_id:
+ self.update_refresh_progress(progress_id, token)
+ except (KeyboardInterrupt, SystemExit):
+ if progress_id:
+ self.finish_refresh_progress(progress_id, error="cancelled")
+ executor.shutdown(wait=False, cancel_futures=True)
+ raise
+ else:
+ executor.shutdown(wait=True, cancel_futures=True)
+
+ # 自动重新登录异常账号(仅当配置开启时)
+ relogined = 0
+ if config.auto_relogin_after_refresh:
+ for token in access_tokens:
+ account = self.get_account(token)
+ if not account:
+ continue
+ status = str(account.get("status") or "").strip()
+ if status != "异常":
+ continue
+ email = str(account.get("email") or "").strip()
+ password = str(account.get("password") or "").strip()
+ if not email or not password:
+ continue
+ t = Thread(
+ target=self._password_re_login_thread,
+ args=(token, email, password, "auto_relogin_after_refresh"),
+ daemon=True,
+ )
+ t.start()
+ relogined += 1
+
+ result = {
+ "refreshed": refreshed,
+ "errors": errors,
+ "items": self.list_accounts(),
+ "relogined": relogined,
+ }
+
+ if progress_id:
+ self.finish_refresh_progress(progress_id, result)
+
+ return result
+
+ def re_login_accounts(self, access_tokens: list[str], progress_id: str | None = None) -> dict[str, Any]:
+ """对选中账号执行密码重新登录流程。
+
+ 仅对包含 email + password 的账号有效。
+ 登录成功后自动将状态设为"正常"。
+ """
+ access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
+ if not access_tokens:
+ result = {"relogined": 0, "skipped": 0, "errors": [], "items": self.list_accounts()}
+ if progress_id:
+ self.finish_relogin_progress(progress_id, result)
+ return result
+
+ if progress_id:
+ self.init_relogin_progress(progress_id, len(access_tokens))
+
+ relogined = 0
+ skipped = 0
+ errors = []
+
+ for token in access_tokens:
+ account = self.get_account(token)
+ if not account:
+ errors.append({"token": anonymize_token(token), "error": "账号不存在"})
+ if progress_id:
+ self.update_relogin_progress(progress_id, token, "跳过", "账号不存在")
+ continue
+
+ email = str(account.get("email") or "").strip()
+ password = str(account.get("password") or "").strip()
+ if not email or not password:
+ skipped += 1
+ if progress_id:
+ self.update_relogin_progress(progress_id, token, "跳过", "无邮箱密码")
+ continue
+
+ # 在新线程中执行密码重新登录
+ t = Thread(
+ target=self._password_re_login_thread,
+ args=(token, email, password, "manual_relogin", progress_id),
+ daemon=True,
+ )
+ t.start()
+ relogined += 1
+
+ result = {
+ "relogined": relogined,
+ "skipped": skipped,
+ "errors": errors,
+ "items": self.list_accounts(),
+ }
+ if progress_id:
+ # 如果所有账号都已同步处理完毕(没有启动线程),直接标记完成
+ if relogined == 0:
+ self.finish_relogin_progress(progress_id, result)
+ else:
+ # 有线程在运行,等线程结束后再完成
+ pass
+ return result
+
+ def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
+ target_tokens = set(token for token in (access_tokens or []) if token)
+ with self._lock:
+ accounts = [
+ dict(item)
+ for item in self._accounts.values()
+ if not target_tokens or str(item.get("access_token") or "") in target_tokens
+ ]
+
+ items: list[dict[str, str]] = []
+ for account in accounts:
+ access_token = str(account.get("access_token") or "").strip()
+ refresh_token = str(account.get("refresh_token") or "").strip()
+ id_token = str(account.get("id_token") or "").strip()
+ if not access_token or not refresh_token or not id_token:
+ continue
+
+ access_payload = self._decode_jwt_payload(access_token)
+ id_payload = self._decode_jwt_payload(id_token)
+ auth_claim = access_payload.get("https://api.openai.com/auth")
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
+ profile_claim = access_payload.get("https://api.openai.com/profile")
+ profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
+
+ email = (
+ str(account.get("email") or "").strip()
+ or str(profile_claim.get("email") or "").strip()
+ or str(id_payload.get("email") or "").strip()
+ )
+ account_id = (
+ str(account.get("account_id") or "").strip()
+ or str(auth_claim.get("chatgpt_account_id") or "").strip()
+ or str(account.get("user_id") or "").strip()
+ )
+ item = {
+ "type": str(account.get("export_type") or "codex"),
+ "email": email,
+ "account_id": account_id,
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "id_token": id_token,
+ "expired": self._timestamp_to_iso(access_payload.get("exp")),
+ "last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
+ }
+ password = str(account.get("password") or "").strip()
+ if password:
+ item["password"] = password
+ items.append(item)
+ return items
+
+ def get_stats(self) -> dict:
+ with self._lock:
+ items = list(self._accounts.values())
+ total = len(items)
+ active = sum(1 for a in items if a.get("status") == "正常")
+ limited = sum(1 for a in items if a.get("status") == "限流")
+ abnormal = sum(1 for a in items if a.get("status") == "异常")
+ disabled = sum(1 for a in items if a.get("status") == "禁用")
+ total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
+ unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown")))
+ total_success = sum(int(a.get("success") or 0) for a in items)
+ total_fail = sum(int(a.get("fail") or 0) for a in items)
+ by_type = {}
+ for a in items:
+ t = a.get("type", "unknown")
+ by_type[t] = by_type.get(t, 0) + 1
+ return {
+ "total": total,
+ "cumulative_total": self._cumulative_total,
+ "active": active,
+ "limited": limited,
+ "abnormal": abnormal,
+ "disabled": disabled,
+ "total_quota": total_quota,
+ "unlimited_quota_count": unlimited,
+ "total_success": total_success,
+ "total_fail": total_fail,
+ "by_type": by_type,
+ }
+
+ def account_health(self) -> dict:
+ stats = self.get_stats()
+ return {
+ "healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0,
+ "status": "ok" if stats["active"] > 0 else "degraded",
+ **stats,
+ }
+
+
+account_service = AccountService(config.get_storage_backend())
diff --git a/services/auth_service.py b/services/auth_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef9a29bf533624b74ae0ccee8015799f12d63871
--- /dev/null
+++ b/services/auth_service.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+import uuid
+from datetime import datetime, timezone
+from threading import Lock
+from typing import Literal
+
+from services.config import config
+from services.storage.base import StorageBackend
+
+AuthRole = Literal["admin", "user"]
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _hash_key(value: str) -> str:
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
+
+
+class AuthService:
+ def __init__(self, storage: StorageBackend):
+ self.storage = storage
+ self._lock = Lock()
+ self._items = self._load()
+ self._last_used_flush_at: dict[str, datetime] = {}
+
+ @staticmethod
+ def _clean(value: object) -> str:
+ return str(value or "").strip()
+
+ @staticmethod
+ def _default_name(role: object) -> str:
+ return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
+
+ def _normalize_item(self, raw: object) -> dict[str, object] | None:
+ if not isinstance(raw, dict):
+ return None
+ role = self._clean(raw.get("role")).lower()
+ if role not in {"admin", "user"}:
+ return None
+ key_hash = self._clean(raw.get("key_hash"))
+ if not key_hash:
+ return None
+ item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
+ name = self._clean(raw.get("name")) or self._default_name(role)
+ created_at = self._clean(raw.get("created_at")) or _now_iso()
+ last_used_at = self._clean(raw.get("last_used_at")) or None
+ return {
+ "id": item_id,
+ "name": name,
+ "role": role,
+ "key_hash": key_hash,
+ "enabled": bool(raw.get("enabled", True)),
+ "created_at": created_at,
+ "last_used_at": last_used_at,
+ }
+
+ def _load(self) -> list[dict[str, object]]:
+ try:
+ items = self.storage.load_auth_keys()
+ except Exception:
+ return []
+ if not isinstance(items, list):
+ return []
+ return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
+
+ def _save(self) -> None:
+ self.storage.save_auth_keys(self._items)
+
+ def _reload_locked(self) -> None:
+ self._items = self._load()
+
+ @staticmethod
+ def _public_item(item: dict[str, object]) -> dict[str, object]:
+ return {
+ "id": item.get("id"),
+ "name": item.get("name"),
+ "role": item.get("role"),
+ "enabled": bool(item.get("enabled", True)),
+ "created_at": item.get("created_at"),
+ "last_used_at": item.get("last_used_at"),
+ }
+
+ def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
+ with self._lock:
+ self._reload_locked()
+ items = [item for item in self._items if role is None or item.get("role") == role]
+ return [self._public_item(item) for item in items]
+
+ def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
+ for item in self._items:
+ item_id = self._clean(item.get("id"))
+ if exclude_id and item_id == exclude_id:
+ continue
+ stored_hash = self._clean(item.get("key_hash"))
+ if stored_hash and hmac.compare_digest(stored_hash, key_hash):
+ return True
+ return False
+
+ def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
+ candidate = self._clean(raw_key)
+ if not candidate:
+ raise ValueError("请输入新的专用密钥")
+ admin_key = self._clean(config.auth_key)
+ if admin_key and hmac.compare_digest(candidate, admin_key):
+ raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥")
+ key_hash = _hash_key(candidate)
+ if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
+ raise ValueError("这个专用密钥已经存在,请换一个新的密钥")
+ return key_hash
+
+ def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool:
+ candidate = self._clean(name)
+ if not candidate:
+ return False
+ for item in self._items:
+ item_id = self._clean(item.get("id"))
+ if exclude_id and item_id == exclude_id:
+ continue
+ if role is not None and item.get("role") != role:
+ continue
+ if self._clean(item.get("name")) == candidate:
+ return True
+ return False
+
+ def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str:
+ base_name = self._default_name(role)
+ if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id):
+ return base_name
+ suffix = 2
+ while True:
+ candidate = f"{base_name} {suffix}"
+ if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
+ return candidate
+ suffix += 1
+
+ def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str:
+ candidate = self._clean(name)
+ if not candidate:
+ return self._build_default_name_locked(role, exclude_id=exclude_id)
+ if self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
+ raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧")
+ return candidate
+
+ def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
+ with self._lock:
+ self._reload_locked()
+ normalized_name = self._build_name_locked(name, role=role)
+ while True:
+ raw_key = f"sk-{secrets.token_urlsafe(24)}"
+ try:
+ key_hash = self._build_key_hash_locked(raw_key)
+ break
+ except ValueError:
+ continue
+ item = {
+ "id": uuid.uuid4().hex[:12],
+ "name": normalized_name,
+ "role": role,
+ "key_hash": key_hash,
+ "enabled": True,
+ "created_at": _now_iso(),
+ "last_used_at": None,
+ }
+ self._items.append(item)
+ self._save()
+ return self._public_item(item), raw_key
+
+ def update_key(
+ self,
+ key_id: str,
+ updates: dict[str, object],
+ *,
+ role: AuthRole | None = None,
+ ) -> dict[str, object] | None:
+ normalized_id = self._clean(key_id)
+ if not normalized_id:
+ return None
+ with self._lock:
+ self._reload_locked()
+ for index, item in enumerate(self._items):
+ if item.get("id") != normalized_id:
+ continue
+ if role is not None and item.get("role") != role:
+ return None
+ next_item = dict(item)
+ next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user"
+ if "name" in updates and updates.get("name") is not None:
+ next_item["name"] = self._build_name_locked(
+ str(updates.get("name") or ""),
+ role=next_role,
+ exclude_id=normalized_id,
+ )
+ if "enabled" in updates and updates.get("enabled") is not None:
+ next_item["enabled"] = bool(updates.get("enabled"))
+ if "key" in updates and updates.get("key") is not None:
+ next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
+ self._items[index] = next_item
+ self._save()
+ return self._public_item(next_item)
+ return None
+
+ def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool:
+ normalized_id = self._clean(key_id)
+ if not normalized_id:
+ return False
+ with self._lock:
+ self._reload_locked()
+ before = len(self._items)
+ self._items = [
+ item
+ for item in self._items
+ if not (item.get("id") == normalized_id and (role is None or item.get("role") == role))
+ ]
+ if len(self._items) == before:
+ return False
+ self._save()
+ return True
+
+ def authenticate(self, raw_key: str) -> dict[str, object] | None:
+ candidate = self._clean(raw_key)
+ if not candidate:
+ return None
+ candidate_hash = _hash_key(candidate)
+ with self._lock:
+ for index, item in enumerate(self._items):
+ if not bool(item.get("enabled", True)):
+ continue
+ stored_hash = self._clean(item.get("key_hash"))
+ if not stored_hash or not hmac.compare_digest(stored_hash, candidate_hash):
+ continue
+ next_item = dict(item)
+ now = datetime.now(timezone.utc)
+ next_item["last_used_at"] = now.isoformat()
+ self._items[index] = next_item
+ item_id = self._clean(next_item.get("id"))
+ last_flush_at = self._last_used_flush_at.get(item_id)
+ if last_flush_at is None or (now - last_flush_at).total_seconds() >= 60:
+ try:
+ self._save()
+ self._last_used_flush_at[item_id] = now
+ except Exception:
+ pass
+ return self._public_item(next_item)
+ return None
+
+
+auth_service = AuthService(config.get_storage_backend())
diff --git a/services/backup_service.py b/services/backup_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..785cb63233a40360c66199ce6375fee1eef7403f
--- /dev/null
+++ b/services/backup_service.py
@@ -0,0 +1,673 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import io
+import json
+import os
+import random
+import subprocess
+import tarfile
+import threading
+from datetime import UTC, datetime
+from pathlib import Path
+from urllib.parse import quote, urlencode
+
+from curl_cffi import requests
+
+from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state
+from services.image_storage_service import IMAGE_INDEX_FILE
+from services.image_tags_service import TAGS_FILE
+
+
+def _utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+def _iso_now() -> str:
+ return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
+
+
+def _clean(value: object) -> str:
+ return str(value or "").strip()
+
+
+def _sha256_hex(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def _is_backup_object(key: object) -> bool:
+ name = _clean(key).rsplit("/", 1)[-1]
+ return name.startswith("backup-") and (name.endswith(".tar.gz") or name.endswith(".tar.gz.enc"))
+
+
+def _hmac_sha256(key: bytes, message: str) -> bytes:
+ return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
+
+
+def _openssl_encrypt(data: bytes, passphrase: str) -> bytes:
+ env = dict(os.environ)
+ env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
+ try:
+ result = subprocess.run(
+ [
+ "openssl",
+ "enc",
+ "-aes-256-cbc",
+ "-pbkdf2",
+ "-salt",
+ "-md",
+ "sha256",
+ "-pass",
+ "env:CHATGPT2API_BACKUP_PASSPHRASE",
+ ],
+ input=data,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=True,
+ env=env,
+ )
+ except FileNotFoundError as exc:
+ raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc
+ except subprocess.CalledProcessError as exc:
+ detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
+ raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc
+ return result.stdout
+
+
+def _openssl_decrypt(data: bytes, passphrase: str) -> bytes:
+ env = dict(os.environ)
+ env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
+ try:
+ result = subprocess.run(
+ [
+ "openssl",
+ "enc",
+ "-d",
+ "-aes-256-cbc",
+ "-pbkdf2",
+ "-md",
+ "sha256",
+ "-pass",
+ "env:CHATGPT2API_BACKUP_PASSPHRASE",
+ ],
+ input=data,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=True,
+ env=env,
+ )
+ except FileNotFoundError as exc:
+ raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc
+ except subprocess.CalledProcessError as exc:
+ detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
+ raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc
+ return result.stdout
+
+
+def _guess_content_type(name: str) -> str:
+ if name.endswith(".json"):
+ return "application/json"
+ if name.endswith(".jsonl"):
+ return "application/x-ndjson"
+ if name.endswith(".tar.gz"):
+ return "application/gzip"
+ if name.endswith(".gz"):
+ return "application/gzip"
+ return "application/octet-stream"
+
+
+def _json_bytes(value: object) -> bytes:
+ return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
+
+
+def _count_items(value: object) -> int:
+ if isinstance(value, list):
+ return len(value)
+ if isinstance(value, dict):
+ return len(value)
+ return 0
+
+
+class BackupError(RuntimeError):
+ pass
+
+
+class CloudflareR2Client:
+ def __init__(self, settings: dict[str, object]) -> None:
+ self.account_id = _clean(settings.get("account_id"))
+ self.access_key_id = _clean(settings.get("access_key_id"))
+ self.secret_access_key = _clean(settings.get("secret_access_key"))
+ self.bucket = _clean(settings.get("bucket"))
+ self.prefix = _clean(settings.get("prefix")) or "backups"
+ self.session = requests.Session(impersonate="chrome", verify=True)
+
+ def validate(self) -> None:
+ missing = []
+ if not self.account_id:
+ missing.append("Account ID")
+ if not self.access_key_id:
+ missing.append("Access Key ID")
+ if not self.secret_access_key:
+ missing.append("Secret Access Key")
+ if not self.bucket:
+ missing.append("Bucket")
+ if missing:
+ raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}")
+
+ @property
+ def endpoint(self) -> str:
+ return f"https://{self.account_id}.r2.cloudflarestorage.com"
+
+ def _aws_v4_headers(
+ self,
+ method: str,
+ path: str,
+ *,
+ query: dict[str, str] | None = None,
+ body: bytes = b"",
+ extra_headers: dict[str, str] | None = None,
+ ) -> tuple[str, dict[str, str]]:
+ now = _utc_now()
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
+ date_stamp = now.strftime("%Y%m%d")
+ encoded_query = urlencode(sorted((query or {}).items()))
+ payload_hash = _sha256_hex(body)
+ host = f"{self.account_id}.r2.cloudflarestorage.com"
+ headers = {
+ "host": host,
+ "x-amz-content-sha256": payload_hash,
+ "x-amz-date": amz_date,
+ }
+ if extra_headers:
+ for key, value in extra_headers.items():
+ headers[key.lower()] = value.strip()
+ sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items())
+ canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items)
+ signed_headers = ";".join(key for key, _ in sorted_items)
+ canonical_request = "\n".join([
+ method.upper(),
+ path,
+ encoded_query,
+ canonical_headers,
+ signed_headers,
+ payload_hash,
+ ])
+ credential_scope = f"{date_stamp}/auto/s3/aws4_request"
+ string_to_sign = "\n".join([
+ "AWS4-HMAC-SHA256",
+ amz_date,
+ credential_scope,
+ _sha256_hex(canonical_request.encode("utf-8")),
+ ])
+ k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp)
+ k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest()
+ k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
+ k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
+ signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
+ authorization = (
+ "AWS4-HMAC-SHA256 "
+ f"Credential={self.access_key_id}/{credential_scope}, "
+ f"SignedHeaders={signed_headers}, "
+ f"Signature={signature}"
+ )
+ request_headers = {key: value for key, value in headers.items()}
+ request_headers["authorization"] = authorization
+ return encoded_query, request_headers
+
+ def _request(
+ self,
+ method: str,
+ key: str = "",
+ *,
+ query: dict[str, str] | None = None,
+ body: bytes = b"",
+ extra_headers: dict[str, str] | None = None,
+ timeout: float = 60.0,
+ ):
+ object_path = f"/{self.bucket}"
+ if key:
+ object_path += f"/{quote(key.lstrip('/'), safe='/')}"
+ encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers)
+ url = f"{self.endpoint}{object_path}"
+ if encoded_query:
+ url += f"?{encoded_query}"
+ response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout)
+ return response
+
+ def test_connection(self) -> dict[str, object]:
+ self.validate()
+ response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0)
+ if response.status_code >= 400:
+ raise BackupError(f"连接 R2 失败:HTTP {response.status_code}")
+ return {"ok": True, "status": int(response.status_code)}
+
+ def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]:
+ headers = {"content-type": content_type}
+ if metadata:
+ for item_key, item_value in metadata.items():
+ headers[f"x-amz-meta-{item_key}"] = str(item_value)
+ response = self._request("PUT", key, body=payload, extra_headers=headers)
+ if response.status_code >= 400:
+ raise BackupError(f"上传备份失败:HTTP {response.status_code}")
+ return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')}
+
+ def delete_object(self, key: str) -> None:
+ response = self._request("DELETE", key, timeout=30.0)
+ if response.status_code >= 400 and response.status_code != 404:
+ raise BackupError(f"删除备份失败:HTTP {response.status_code}")
+
+ def download_bytes(self, key: str) -> bytes:
+ response = self._request("GET", key, timeout=60.0)
+ if response.status_code >= 400:
+ raise BackupError(f"读取备份失败:HTTP {response.status_code}")
+ return bytes(response.content or b"")
+
+ def list_objects(self) -> list[dict[str, object]]:
+ items: list[dict[str, object]] = []
+ continuation = ""
+ while True:
+ query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"}
+ if continuation:
+ query["continuation-token"] = continuation
+ response = self._request("GET", query=query, timeout=30.0)
+ if response.status_code >= 400:
+ raise BackupError(f"获取备份列表失败:HTTP {response.status_code}")
+ text = response.text
+ for block in text.split("")[1:]:
+ key = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else ""
+ if not key:
+ continue
+ size_text = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "0"
+ updated = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else ""
+ items.append({
+ "key": key,
+ "size": int(size_text or 0),
+ "updated_at": updated,
+ })
+ truncated = "true" in text
+ if not truncated:
+ break
+ if "" not in text:
+ break
+ continuation = _clean(text.split("", 1)[1].split("", 1)[0])
+ if not continuation:
+ break
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
+ return items
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class BackupService:
+ def __init__(self) -> None:
+ self._lock = threading.RLock()
+ self._stop_event = threading.Event()
+ self._thread: threading.Thread | None = None
+ self._running = False
+
+ def start(self) -> None:
+ with self._lock:
+ if self._thread and self._thread.is_alive():
+ return
+ self._stop_event.clear()
+ self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler")
+ self._thread.start()
+
+ def stop(self) -> None:
+ with self._lock:
+ self._stop_event.set()
+ thread = self._thread
+ self._thread = None
+ if thread and thread.is_alive():
+ thread.join(timeout=2)
+
+ def _run(self) -> None:
+ while not self._stop_event.is_set():
+ try:
+ self.run_scheduled_backup_if_needed()
+ except Exception:
+ pass
+ self._stop_event.wait(30)
+
+ def run_scheduled_backup_if_needed(self) -> None:
+ settings = config.get_backup_settings()
+ if not settings.get("enabled"):
+ return
+ state = self.get_status()
+ if state.get("running"):
+ return
+ interval_minutes = int(settings.get("interval_minutes") or 360)
+ last_finished_raw = _clean(state.get("last_finished_at"))
+ if last_finished_raw:
+ try:
+ last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00"))
+ elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds()
+ if elapsed < interval_minutes * 60:
+ return
+ except Exception:
+ pass
+ self.run_backup(trigger="schedule")
+
+ def get_status(self) -> dict[str, object]:
+ return {
+ **load_backup_state(),
+ "running": self._running,
+ }
+
+ def is_configured(self) -> bool:
+ settings = config.get_backup_settings()
+ return all([
+ _clean(settings.get("account_id")),
+ _clean(settings.get("access_key_id")),
+ _clean(settings.get("secret_access_key")),
+ _clean(settings.get("bucket")),
+ ])
+
+ def get_settings(self) -> dict[str, object]:
+ settings = dict(config.get_backup_settings())
+ settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else ""
+ settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else ""
+ return settings
+
+ def update_settings(self, payload: dict[str, object]) -> dict[str, object]:
+ current = config.get_backup_settings()
+ merged = dict(current)
+ merged.update(dict(payload or {}))
+ if "include" in payload and isinstance(payload.get("include"), dict):
+ include = dict(current.get("include") or {})
+ include.update(payload.get("include") or {})
+ merged["include"] = include
+ if payload.get("secret_access_key") == "********":
+ merged["secret_access_key"] = current.get("secret_access_key")
+ if payload.get("passphrase") == "********":
+ merged["passphrase"] = current.get("passphrase")
+ updated = config.update({"backup": merged})
+ return dict(updated.get("backup") or {})
+
+ def test_connection(self) -> dict[str, object]:
+ client = CloudflareR2Client(config.get_backup_settings())
+ try:
+ return client.test_connection()
+ finally:
+ client.close()
+
+ def list_backups(self) -> list[dict[str, object]]:
+ if not self.is_configured():
+ return []
+ client = CloudflareR2Client(config.get_backup_settings())
+ try:
+ items = client.list_objects()
+ finally:
+ client.close()
+ parsed: list[dict[str, object]] = []
+ for item in items:
+ key = _clean(item.get("key"))
+ name = key.rsplit("/", 1)[-1]
+ encrypted = name.endswith(".enc")
+ parsed.append({
+ "key": key,
+ "name": name,
+ "size": int(item.get("size") or 0),
+ "updated_at": item.get("updated_at"),
+ "encrypted": encrypted,
+ })
+ return parsed
+
+ def delete_backup(self, key: str) -> None:
+ candidate = _clean(key)
+ if not candidate:
+ raise BackupError("备份对象 key 不能为空")
+ client = CloudflareR2Client(config.get_backup_settings())
+ try:
+ client.delete_object(candidate)
+ finally:
+ client.close()
+
+ def download_backup(self, key: str) -> dict[str, object]:
+ candidate = _clean(key)
+ if not candidate:
+ raise BackupError("备份对象 key 不能为空")
+ client = CloudflareR2Client(config.get_backup_settings())
+ try:
+ payload = client.download_bytes(candidate)
+ finally:
+ client.close()
+ name = candidate.rsplit("/", 1)[-1] or "backup.bin"
+ if candidate.endswith(".enc"):
+ passphrase = _clean(config.get_backup_settings().get("passphrase"))
+ if not passphrase:
+ raise BackupError("当前未配置加密口令,无法下载并解密已加密备份")
+ payload = _openssl_decrypt(payload, passphrase)
+ if name.endswith(".enc"):
+ name = name[:-4] or "backup.tar.gz"
+ return {
+ "key": candidate,
+ "name": name,
+ "content_type": _guess_content_type(name),
+ "payload": payload,
+ "size": len(payload),
+ }
+
+ def get_backup_detail(self, key: str) -> dict[str, object]:
+ candidate = _clean(key)
+ if not candidate:
+ raise BackupError("备份对象 key 不能为空")
+ client = CloudflareR2Client(config.get_backup_settings())
+ try:
+ payload = client.download_bytes(candidate)
+ finally:
+ client.close()
+ detail = self._decode_backup_payload(candidate, payload)
+ detail["key"] = candidate
+ detail["name"] = candidate.rsplit("/", 1)[-1]
+ detail["encrypted"] = candidate.endswith(".enc")
+ return detail
+
+ def run_backup(self, *, trigger: str = "manual") -> dict[str, object]:
+ with self._lock:
+ current = self.get_status()
+ if self._running:
+ raise BackupError("当前已有备份任务正在执行")
+ started_at = _iso_now()
+ self._running = True
+ save_backup_state({
+ "last_started_at": started_at,
+ "last_finished_at": current.get("last_finished_at"),
+ "last_status": "idle",
+ "last_error": None,
+ "last_object_key": current.get("last_object_key"),
+ })
+ try:
+ result = self._run_backup_once(trigger=trigger)
+ save_backup_state({
+ "last_started_at": started_at,
+ "last_finished_at": _iso_now(),
+ "last_status": "success",
+ "last_error": None,
+ "last_object_key": result["key"],
+ })
+ return result
+ except Exception as exc:
+ save_backup_state({
+ "last_started_at": started_at,
+ "last_finished_at": _iso_now(),
+ "last_status": "error",
+ "last_error": str(exc) or exc.__class__.__name__,
+ "last_object_key": current.get("last_object_key"),
+ })
+ raise
+ finally:
+ self._running = False
+
+ def _run_backup_once(self, *, trigger: str) -> dict[str, object]:
+ settings = config.get_backup_settings()
+ client = CloudflareR2Client(settings)
+ client.validate()
+ payload_raw = self._build_backup_archive(settings, trigger=trigger)
+ encrypted = bool(settings.get("encrypt"))
+ if encrypted:
+ passphrase = _clean(settings.get("passphrase"))
+ if not passphrase:
+ raise BackupError("已启用备份加密,但未设置加密口令")
+ payload = _openssl_encrypt(payload_raw, passphrase)
+ suffix = ".tar.gz.enc"
+ else:
+ payload = payload_raw
+ suffix = ".tar.gz"
+ timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
+ random_tag = f"{random.randint(0, 0xFFFF):04x}"
+ object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}"
+ metadata = {
+ "created-at": _iso_now(),
+ "encrypted": "true" if encrypted else "false",
+ "trigger": trigger,
+ }
+ try:
+ result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata)
+ self._apply_rotation(client, int(settings.get("rotation_keep") or 0))
+ return {
+ "key": result["key"],
+ "size": len(payload),
+ "encrypted": encrypted,
+ }
+ finally:
+ client.close()
+
+ def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]:
+ decoded = payload
+ if key.endswith(".enc"):
+ passphrase = _clean(config.get_backup_settings().get("passphrase"))
+ if not passphrase:
+ raise BackupError("当前未配置加密口令,无法查看已加密备份")
+ decoded = _openssl_decrypt(decoded, passphrase)
+ return self._decode_archive_detail(decoded)
+
+ def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None:
+ if keep <= 0:
+ return
+ items = [item for item in client.list_objects() if _is_backup_object(item.get("key"))]
+ if len(items) <= keep:
+ return
+ for item in items[keep:]:
+ key = _clean(item.get("key"))
+ if key:
+ client.delete_object(key)
+
+ def _decode_archive_detail(self, payload: bytes) -> dict[str, object]:
+ files: list[dict[str, object]] = []
+ snapshots: list[dict[str, object]] = []
+ metadata: dict[str, object] = {}
+ try:
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
+ members = [member for member in archive.getmembers() if member.isfile()]
+ for member in members:
+ extracted = archive.extractfile(member)
+ if extracted is None:
+ continue
+ raw = extracted.read()
+ name = member.name
+ if name == "backup-metadata.json":
+ try:
+ parsed = json.loads(raw.decode("utf-8"))
+ if isinstance(parsed, dict):
+ metadata = parsed
+ except Exception:
+ metadata = {}
+ continue
+ if name.startswith("snapshots/") and name.endswith(".json"):
+ count = 0
+ try:
+ parsed_snapshot = json.loads(raw.decode("utf-8"))
+ count = _count_items(parsed_snapshot)
+ except Exception:
+ count = 0
+ snapshots.append({
+ "name": name.removeprefix("snapshots/").removesuffix(".json"),
+ "count": count,
+ })
+ continue
+ files.append({
+ "name": name,
+ "exists": True,
+ "content_type": _guess_content_type(name),
+ "size": len(raw),
+ "sha256": _sha256_hex(raw),
+ })
+ except tarfile.TarError as exc:
+ raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc
+ files.sort(key=lambda item: str(item.get("name") or ""))
+ snapshots.sort(key=lambda item: str(item.get("name") or ""))
+ return {
+ "created_at": metadata.get("created_at"),
+ "trigger": metadata.get("trigger"),
+ "app_version": metadata.get("app_version"),
+ "storage_backend": metadata.get("storage_backend"),
+ "files": files,
+ "snapshots": snapshots,
+ }
+
+ def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes:
+ include = settings.get("include") if isinstance(settings.get("include"), dict) else {}
+ metadata = {
+ "version": 2,
+ "created_at": _iso_now(),
+ "trigger": trigger,
+ "app_version": config.app_version,
+ "storage_backend": config.get_storage_backend().get_backend_info(),
+ }
+ buffer = io.BytesIO()
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
+ self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata))
+ if include.get("config"):
+ self._add_file_to_archive(archive, CONFIG_FILE, "config.json")
+ if include.get("register"):
+ self._add_file_to_archive(archive, DATA_DIR / "register.json", "data/register.json")
+ if include.get("cpa"):
+ self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json")
+ if include.get("sub2api"):
+ self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json")
+ if include.get("logs"):
+ self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl")
+ if include.get("image_tasks"):
+ self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json")
+ self._add_file_to_archive(archive, IMAGE_INDEX_FILE, "data/image_index.json")
+ if include.get("accounts_snapshot"):
+ self._add_bytes_to_archive(
+ archive,
+ "snapshots/accounts.json",
+ _json_bytes(config.get_storage_backend().load_accounts()),
+ )
+ if include.get("auth_keys_snapshot"):
+ self._add_bytes_to_archive(
+ archive,
+ "snapshots/auth_keys.json",
+ _json_bytes(config.get_storage_backend().load_auth_keys()),
+ )
+ if include.get("images"):
+ self._add_file_to_archive(archive, TAGS_FILE, "data/image_tags.json")
+ self._add_directory_to_archive(archive, config.images_dir, "data/images")
+ return buffer.getvalue()
+
+ def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None:
+ info = tarfile.TarInfo(name=name)
+ info.size = len(payload)
+ info.mtime = int(_utc_now().timestamp())
+ archive.addfile(info, io.BytesIO(payload))
+
+ def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None:
+ if not source.exists() or not source.is_file():
+ return
+ archive.add(source, arcname=arcname)
+
+ def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None:
+ if not source_dir.exists() or not source_dir.is_dir():
+ return
+ for path in sorted(source_dir.rglob("*")):
+ if path.is_file():
+ relative = path.relative_to(source_dir).as_posix()
+ archive.add(path, arcname=f"{arcname_root}/{relative}")
+
+
+backup_service = BackupService()
diff --git a/services/config.py b/services/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..961b5c60373d66c404635e492ce38d02a2cb4bd7
--- /dev/null
+++ b/services/config.py
@@ -0,0 +1,486 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+import os
+import sys
+from pathlib import Path
+import time
+
+from services.storage.base import StorageBackend
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+DATA_DIR = BASE_DIR / "data"
+CONFIG_FILE = BASE_DIR / "config.json"
+VERSION_FILE = BASE_DIR / "VERSION"
+BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
+
+DEFAULT_BACKUP_INCLUDE = {
+ "config": True,
+ "register": True,
+ "cpa": True,
+ "sub2api": True,
+ "logs": True,
+ "image_tasks": True,
+ "accounts_snapshot": True,
+ "auth_keys_snapshot": True,
+ "images": False,
+}
+
+DEFAULT_IMAGE_STORAGE = {
+ "enabled": False,
+ "mode": "local",
+ "webdav_url": "",
+ "webdav_username": "",
+ "webdav_password": "",
+ "webdav_root_path": "chatgpt2api/images",
+ "public_base_url": "",
+}
+
+DEFAULT_CHAT_COMPLETION_CACHE = {
+ "enabled": True,
+ "ttl_seconds": 60,
+ "max_entries": 256,
+ "dedupe_inflight": True,
+ "stream_cache": True,
+ "normalize_messages": True,
+ "drop_adjacent_duplicates": True,
+ "drop_assistant_history": False,
+}
+
+
+def _normalize_bool(value: object, default: bool = False) -> bool:
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in {"1", "true", "yes", "on"}:
+ return True
+ if lowered in {"0", "false", "no", "off"}:
+ return False
+ if value is None:
+ return default
+ return bool(value)
+
+
+def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
+ try:
+ normalized = int(value)
+ except (TypeError, ValueError):
+ normalized = default
+ return max(minimum, normalized)
+
+
+def _normalize_backup_include(value: object) -> dict[str, bool]:
+ source = value if isinstance(value, dict) else {}
+ normalized = dict(DEFAULT_BACKUP_INCLUDE)
+ for key in normalized:
+ normalized[key] = _normalize_bool(source.get(key), normalized[key])
+ return normalized
+
+
+def _normalize_backup_settings(value: object) -> dict[str, object]:
+ source = value if isinstance(value, dict) else {}
+ return {
+ "enabled": _normalize_bool(source.get("enabled"), False),
+ "provider": "cloudflare_r2",
+ "account_id": str(source.get("account_id") or "").strip(),
+ "access_key_id": str(source.get("access_key_id") or "").strip(),
+ "secret_access_key": str(source.get("secret_access_key") or "").strip(),
+ "bucket": str(source.get("bucket") or "").strip(),
+ "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
+ "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
+ "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
+ "encrypt": _normalize_bool(source.get("encrypt"), False),
+ "passphrase": str(source.get("passphrase") or "").strip(),
+ "include": _normalize_backup_include(source.get("include")),
+ }
+
+
+def _normalize_backup_state(value: object) -> dict[str, object]:
+ source = value if isinstance(value, dict) else {}
+ return {
+ "last_started_at": str(source.get("last_started_at") or "").strip() or None,
+ "last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
+ "last_status": str(source.get("last_status") or "idle").strip() or "idle",
+ "last_error": str(source.get("last_error") or "").strip() or None,
+ "last_object_key": str(source.get("last_object_key") or "").strip() or None,
+ }
+
+
+def _normalize_image_storage_settings(value: object) -> dict[str, object]:
+ source = value if isinstance(value, dict) else {}
+ mode = str(source.get("mode") or "local").strip().lower()
+ if mode not in {"local", "webdav", "both"}:
+ mode = "local"
+ enabled = _normalize_bool(source.get("enabled"), False)
+ if not enabled:
+ mode = "local"
+ root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
+ return {
+ "enabled": enabled,
+ "mode": mode,
+ "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
+ "webdav_username": str(source.get("webdav_username") or "").strip(),
+ "webdav_password": str(source.get("webdav_password") or "").strip(),
+ "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
+ "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
+ }
+
+
+def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
+ source = value if isinstance(value, dict) else {}
+ return {
+ "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
+ "ttl_seconds": _normalize_positive_int(
+ source.get("ttl_seconds"),
+ int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
+ 0,
+ ),
+ "max_entries": _normalize_positive_int(
+ source.get("max_entries"),
+ int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
+ 1,
+ ),
+ "dedupe_inflight": _normalize_bool(
+ source.get("dedupe_inflight"),
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
+ ),
+ "stream_cache": _normalize_bool(
+ source.get("stream_cache"),
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
+ ),
+ "normalize_messages": _normalize_bool(
+ source.get("normalize_messages"),
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
+ ),
+ "drop_adjacent_duplicates": _normalize_bool(
+ source.get("drop_adjacent_duplicates"),
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
+ ),
+ "drop_assistant_history": _normalize_bool(
+ source.get("drop_assistant_history"),
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
+ ),
+ }
+
+
+def _validate_image_storage_settings(settings: dict[str, object]) -> None:
+ if not _normalize_bool(settings.get("enabled"), False):
+ return
+ if not str(settings.get("webdav_url") or "").strip():
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
+ if not str(settings.get("webdav_password") or "").strip():
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
+
+
+@dataclass(frozen=True)
+class LoadedSettings:
+ auth_key: str
+ refresh_account_interval_minute: int
+
+
+def _normalize_auth_key(value: object) -> str:
+ return str(value or "").strip()
+
+
+def _is_invalid_auth_key(value: object) -> bool:
+ return _normalize_auth_key(value) == ""
+
+
+def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
+ if not path.exists():
+ return {}
+ if path.is_dir():
+ print(
+ f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
+ file=sys.stderr,
+ )
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _load_settings() -> LoadedSettings:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ raw_config = _read_json_object(CONFIG_FILE, name="config.json")
+ auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
+ if _is_invalid_auth_key(auth_key):
+ raise ValueError(
+ "❌ auth-key 未设置!\n"
+ "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
+ )
+
+ try:
+ refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
+ except (TypeError, ValueError):
+ refresh_interval = 5
+
+ return LoadedSettings(
+ auth_key=auth_key,
+ refresh_account_interval_minute=refresh_interval,
+ )
+
+
+class ConfigStore:
+ def __init__(self, path: Path):
+ self.path = path
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ self.data = self._load()
+ self._storage_backend: StorageBackend | None = None
+ if _is_invalid_auth_key(self.auth_key):
+ raise ValueError(
+ "❌ auth-key 未设置!\n"
+ "请按以下任意一种方式解决:\n"
+ "1. 在 Render 的 Environment 变量中添加:\n"
+ " CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
+ "2. 或者在 config.json 中填写:\n"
+ ' "auth-key": "your_real_auth_key"'
+ )
+
+ def _load(self) -> dict[str, object]:
+ return _read_json_object(self.path, name="config.json")
+
+ def _save(self) -> None:
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+ @property
+ def auth_key(self) -> str:
+ return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
+
+ @property
+ def accounts_file(self) -> Path:
+ return DATA_DIR / "accounts.json"
+
+ @property
+ def refresh_account_interval_minute(self) -> int:
+ try:
+ return int(self.data.get("refresh_account_interval_minute", 5))
+ except (TypeError, ValueError):
+ return 5
+
+ @property
+ def image_retention_days(self) -> int:
+ try:
+ return max(1, int(self.data.get("image_retention_days", 30)))
+ except (TypeError, ValueError):
+ return 30
+
+ @property
+ def image_poll_timeout_secs(self) -> int:
+ try:
+ return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
+ except (TypeError, ValueError):
+ return 120
+
+ @property
+ def image_poll_interval_secs(self) -> float:
+ try:
+ return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
+ except (TypeError, ValueError):
+ return 10.0
+
+ @property
+ def image_poll_initial_wait_secs(self) -> float:
+ """Image generation upstream takes ~30s; polling immediately wastes requests
+ and trips a transient 429. Default 10s gives the conversation document time
+ to commit before the first poll."""
+ try:
+ return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
+ except (TypeError, ValueError):
+ return 10.0
+
+ @property
+ def image_account_concurrency(self) -> int:
+ try:
+ return max(1, int(self.data.get("image_account_concurrency", 3)))
+ except (TypeError, ValueError):
+ return 3
+
+ @property
+ def image_parallel_generation(self) -> bool:
+ value = self.data.get("image_parallel_generation", True)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @property
+ def image_settle_enabled(self) -> bool:
+ """图片二次确认机制:找到 file_ids 后等待一段时间再次确认。"""
+ value = self.data.get("image_settle_enabled", True)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @property
+ def image_check_before_hit_enabled(self) -> bool:
+ """先check再hit:通过轮询确认 file_ids 存在后再返回,而非仅依赖 SSE 事件。"""
+ value = self.data.get("image_check_before_hit_enabled", True)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @property
+ def image_settle_secs(self) -> float:
+ """二次确认等待时间(秒)。"""
+ try:
+ return max(0.5, float(self.data.get("image_settle_secs", 2.0)))
+ except (TypeError, ValueError):
+ return 2.0
+
+ @property
+ def auto_remove_invalid_accounts(self) -> bool:
+ value = self.data.get("auto_remove_invalid_accounts", False)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @property
+ def auto_remove_rate_limited_accounts(self) -> bool:
+ value = self.data.get("auto_remove_rate_limited_accounts", False)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @property
+ def auto_relogin_after_refresh(self) -> bool:
+ value = self.data.get("auto_relogin_after_refresh", False)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ @property
+ def log_levels(self) -> list[str]:
+ levels = self.data.get("log_levels")
+ if not isinstance(levels, list):
+ return []
+ allowed = {"debug", "info", "warning", "error"}
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
+
+ @property
+ def sensitive_words(self) -> list[str]:
+ words = self.data.get("sensitive_words")
+ return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
+
+ @property
+ def ai_review(self) -> dict[str, object]:
+ value = self.data.get("ai_review")
+ return value if isinstance(value, dict) else {}
+
+ @property
+ def global_system_prompt(self) -> str:
+ return str(self.data.get("global_system_prompt") or "").strip()
+
+ @property
+ def images_dir(self) -> Path:
+ path = DATA_DIR / "images"
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+ @property
+ def image_thumbnails_dir(self) -> Path:
+ path = DATA_DIR / "image_thumbnails"
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+ def cleanup_old_images(self) -> int:
+ cutoff = time.time() - self.image_retention_days * 86400
+ removed = 0
+ for path in self.images_dir.rglob("*"):
+ if path.is_file() and path.stat().st_mtime < cutoff:
+ path.unlink()
+ removed += 1
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
+ try:
+ path.rmdir()
+ except OSError:
+ pass
+ return removed
+
+ @property
+ def base_url(self) -> str:
+ return str(
+ os.getenv("CHATGPT2API_BASE_URL")
+ or self.data.get("base_url")
+ or ""
+ ).strip().rstrip("/")
+
+ @property
+ def app_version(self) -> str:
+ try:
+ value = VERSION_FILE.read_text(encoding="utf-8").strip()
+ except FileNotFoundError:
+ return "0.0.0"
+ return value or "0.0.0"
+
+ def get(self) -> dict[str, object]:
+ data = dict(self.data)
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
+ data["image_retention_days"] = self.image_retention_days
+ data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
+ data["image_poll_interval_secs"] = self.image_poll_interval_secs
+ data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
+ data["image_account_concurrency"] = self.image_account_concurrency
+ data["image_parallel_generation"] = self.image_parallel_generation
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
+ data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
+ data["auto_relogin_after_refresh"] = self.auto_relogin_after_refresh
+ data["log_levels"] = self.log_levels
+ data["sensitive_words"] = self.sensitive_words
+ data["ai_review"] = self.ai_review
+ data["global_system_prompt"] = self.global_system_prompt
+ data["backup"] = self.get_backup_settings()
+ data["image_storage"] = self.get_image_storage_settings()
+ data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
+ data.pop("auth-key", None)
+ return data
+
+ def get_proxy_settings(self) -> str:
+ return str(self.data.get("proxy") or "").strip()
+
+ def update(self, data: dict[str, object]) -> dict[str, object]:
+ next_data = dict(self.data)
+ next_data.update(dict(data or {}))
+ if "backup" in next_data:
+ next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
+ if "image_storage" in next_data:
+ next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
+ _validate_image_storage_settings(next_data["image_storage"])
+ if "chat_completion_cache" in next_data:
+ next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
+ next_data.get("chat_completion_cache")
+ )
+ next_data.pop("backup_state", None)
+ self.data = next_data
+ self._save()
+ return self.get()
+
+ def get_backup_settings(self) -> dict[str, object]:
+ return _normalize_backup_settings(self.data.get("backup"))
+
+ def get_image_storage_settings(self) -> dict[str, object]:
+ return _normalize_image_storage_settings(self.data.get("image_storage"))
+
+ def get_chat_completion_cache_settings(self) -> dict[str, object]:
+ return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
+
+ def get_storage_backend(self) -> StorageBackend:
+ """获取存储后端实例(单例)"""
+ if self._storage_backend is None:
+ from services.storage.factory import create_storage_backend
+ self._storage_backend = create_storage_backend(DATA_DIR)
+ return self._storage_backend
+
+
+def load_backup_state() -> dict[str, object]:
+ return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
+
+
+def save_backup_state(state: dict[str, object]) -> dict[str, object]:
+ normalized = _normalize_backup_state(state)
+ BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ return normalized
+
+
+config = ConfigStore(CONFIG_FILE)
diff --git a/services/content_filter.py b/services/content_filter.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ab24fe7f2fa7b3e7cb98ff941c85692b5b9d2d8
--- /dev/null
+++ b/services/content_filter.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+import re
+
+from curl_cffi import requests
+from fastapi import HTTPException
+
+from services.config import config
+from services.proxy_service import proxy_settings
+from utils.log import logger
+
+DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
+
+# Strip base64 image data URIs before review: a text-only review model can't
+# analyze image bytes, and a single inlined image easily blows past the token
+# budget of the upstream review service.
+_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
+
+# Cap aligned to the upstream review service's max context. If text still
+# exceeds the cap after base64 stripping, keep equal head/tail halves so both
+# the system prompt and the most recent user message survive.
+_MAX_REVIEW_TEXT_LEN = 100_000
+_TRUNCATION_MARKER = "\n…[truncated]…\n"
+
+
+def _text(value: object) -> str:
+ if isinstance(value, str):
+ return value
+ if isinstance(value, list):
+ return "\n".join(_text(item) for item in value)
+ if isinstance(value, dict):
+ return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
+ return ""
+
+
+def request_text(*values: object) -> str:
+ return "\n".join(part for value in values if (part := _text(value).strip()))
+
+
+def request_shape(*values: object) -> dict[str, int]:
+ """Return a safe structural summary without logging prompts or image bytes."""
+ stats = {
+ "response_message_items": 0,
+ "input_image_parts": 0,
+ "image_url_parts": 0,
+ "image_parts": 0,
+ "data_url_images": 0,
+ "remote_image_urls": 0,
+ "literal_image_placeholders": 0,
+ }
+
+ def walk(value: object, key: str = "") -> None:
+ if isinstance(value, str):
+ text = value.strip()
+ lower = text.lower()
+ if "" in lower:
+ stats["literal_image_placeholders"] += 1
+ if lower.startswith("data:image/"):
+ stats["data_url_images"] += 1
+ elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
+ stats["remote_image_urls"] += 1
+ return
+ if isinstance(value, list):
+ for item in value:
+ walk(item, key)
+ return
+ if not isinstance(value, dict):
+ return
+ item_type = str(value.get("type") or "").strip()
+ if item_type == "message":
+ stats["response_message_items"] += 1
+ elif item_type == "input_image":
+ stats["input_image_parts"] += 1
+ elif item_type == "image_url":
+ stats["image_url_parts"] += 1
+ elif item_type == "image":
+ stats["image_parts"] += 1
+ for child_key, child in value.items():
+ walk(child, str(child_key))
+
+ for value in values:
+ walk(value)
+ return {key: value for key, value in stats.items() if value}
+
+
+def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
+ """Strip base64 data URIs and truncate to the review-service context limit.
+
+ Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
+ and truncated_chars so callers can emit structured logs.
+ """
+ sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
+ truncated_chars = 0
+ if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
+ # Reserve marker space so the result stays within the cap.
+ half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
+ truncated_chars = len(sanitized) - 2 * half
+ sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
+ stats = {
+ "base64_blocks_stripped": base64_blocks_stripped,
+ "truncated_chars": truncated_chars,
+ }
+ return sanitized, stats
+
+
+def _extract_review_decision(data: object) -> str | None:
+ """Defensively pull the decision text out of the review service response.
+
+ Returns None when the response shape doesn't match the OpenAI chat-completion
+ contract (e.g. {"error": ...} with no choices). The caller treats None as
+ "undecided" and applies the configured fail-open policy.
+ """
+ if not isinstance(data, dict):
+ return None
+ choices = data.get("choices")
+ if not isinstance(choices, list) or not choices:
+ return None
+ first = choices[0]
+ if not isinstance(first, dict):
+ return None
+ message = first.get("message")
+ if not isinstance(message, dict):
+ return None
+ content = message.get("content")
+ if content is None:
+ return None
+ return str(content).strip().lower()
+
+
+def _is_allow_decision(decision: str) -> bool:
+ return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
+
+
+def _is_reject_decision(decision: str) -> bool:
+ return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
+
+
+def _resolve_fail_open(review: dict) -> bool:
+ """Resolve fail_open from review config. Defaults to True."""
+ value = review.get("fail_open")
+ if value is None:
+ return True
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+
+def check_request(text: str) -> None:
+ text = str(text or "")
+ if not text.strip():
+ return
+ # Local sensitive-word match runs on the raw text (cheap, no network).
+ for word in config.sensitive_words:
+ if word in text:
+ raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
+ review = config.ai_review
+ if not review.get("enabled"):
+ return
+ base_url = str(review.get("base_url") or "").strip().rstrip("/")
+ api_key = str(review.get("api_key") or "").strip()
+ model = str(review.get("model") or "").strip()
+ if not base_url or not api_key or not model:
+ raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
+
+ fail_open = _resolve_fail_open(review)
+
+ review_text, sanitize_stats = _sanitize_for_review(text)
+ if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
+ logger.info({
+ "event": "ai_review_text_sanitized",
+ "original_text_len": len(text),
+ "review_text_len": len(review_text),
+ **sanitize_stats,
+ })
+ prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
+ content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
+
+ # fail_open=True (default): on upstream failure or ambiguous reply, let the
+ # request through. The review is a soft safety net; one missed review is
+ # preferable to a 5xx storm when the review service is flaky. Set
+ # config.ai_review.fail_open=false for strict-compliance deployments.
+ def _on_failure(event_payload: dict) -> None:
+ logger.warning(event_payload)
+ if not fail_open:
+ raise HTTPException(
+ status_code=503,
+ detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
+ )
+
+ try:
+ response = requests.post(
+ f"{base_url}/v1/chat/completions",
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
+ timeout=60,
+ **proxy_settings.build_session_kwargs(),
+ )
+ except Exception as exc:
+ _on_failure({
+ "event": "ai_review_request_failed",
+ "error": str(exc),
+ "error_type": exc.__class__.__name__,
+ "review_text_len": len(review_text),
+ "original_text_len": len(text),
+ })
+ return
+
+ try:
+ data = response.json()
+ except Exception as exc:
+ _on_failure({
+ "event": "ai_review_response_not_json",
+ "status_code": response.status_code,
+ "body_preview": str(response.text or "")[:200],
+ "error": str(exc),
+ })
+ return
+
+ decision = _extract_review_decision(data)
+ if decision is None:
+ _on_failure({
+ "event": "ai_review_malformed_response",
+ "status_code": response.status_code,
+ "body_preview": str(data)[:300],
+ "review_text_len": len(review_text),
+ "original_text_len": len(text),
+ })
+ return
+
+ if _is_allow_decision(decision):
+ return
+ if _is_reject_decision(decision):
+ raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
+ # Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
+ _on_failure({
+ "event": "ai_review_ambiguous_decision",
+ "decision": decision[:100],
+ "review_text_len": len(review_text),
+ })
+ return
diff --git a/services/cpa_service.py b/services/cpa_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..2326426d1e50d2810318ae2653d4bfdc6ed8ac12
--- /dev/null
+++ b/services/cpa_service.py
@@ -0,0 +1,315 @@
+"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
+
+from __future__ import annotations
+
+import json
+import threading
+import uuid
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime, timezone
+from pathlib import Path
+from threading import Lock
+
+from curl_cffi.requests import Session
+
+from services.account_service import account_service
+from services.config import DATA_DIR
+from services.proxy_service import proxy_settings
+
+
+CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
+
+
+def _new_id() -> str:
+ return uuid.uuid4().hex[:12]
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
+ if not isinstance(raw, dict):
+ return None
+ status = str(raw.get("status") or "failed").strip() or "failed"
+ if fail_unfinished and status in {"pending", "running"}:
+ status = "failed"
+ return {
+ "job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
+ "status": status,
+ "created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
+ "updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
+ "total": int(raw.get("total") or 0),
+ "completed": int(raw.get("completed") or 0),
+ "added": int(raw.get("added") or 0),
+ "skipped": int(raw.get("skipped") or 0),
+ "refreshed": int(raw.get("refreshed") or 0),
+ "failed": int(raw.get("failed") or 0),
+ "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
+ }
+
+
+def _normalize_pool(raw: dict) -> dict:
+ return {
+ "id": str(raw.get("id") or _new_id()).strip(),
+ "name": str(raw.get("name") or "").strip(),
+ "base_url": str(raw.get("base_url") or "").strip(),
+ "secret_key": str(raw.get("secret_key") or "").strip(),
+ "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
+ }
+
+
+def _management_headers(secret_key: str) -> dict[str, str]:
+ return {
+ "Authorization": f"Bearer {secret_key}",
+ "Accept": "application/json",
+ }
+
+
+class CPAConfig:
+ def __init__(self, store_file: Path):
+ self._store_file = store_file
+ self._lock = Lock()
+ self._pools: list[dict] = self._load()
+
+ def _load(self) -> list[dict]:
+ if not self._store_file.exists():
+ return []
+ try:
+ raw = json.loads(self._store_file.read_text(encoding="utf-8"))
+ if isinstance(raw, dict) and "base_url" in raw:
+ pool = _normalize_pool(raw)
+ return [pool] if pool["base_url"] else []
+ if isinstance(raw, list):
+ return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
+ except Exception:
+ pass
+ return []
+
+ def _save(self) -> None:
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
+ self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+ def list_pools(self) -> list[dict]:
+ with self._lock:
+ return [dict(pool) for pool in self._pools]
+
+ def get_pool(self, pool_id: str) -> dict | None:
+ with self._lock:
+ for pool in self._pools:
+ if pool["id"] == pool_id:
+ return dict(pool)
+ return None
+
+ def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
+ pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
+ with self._lock:
+ self._pools.append(pool)
+ self._save()
+ return dict(pool)
+
+ def update_pool(self, pool_id: str, updates: dict) -> dict | None:
+ with self._lock:
+ for index, pool in enumerate(self._pools):
+ if pool["id"] != pool_id:
+ continue
+ merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
+ self._pools[index] = _normalize_pool(merged)
+ self._save()
+ return dict(self._pools[index])
+ return None
+
+ def delete_pool(self, pool_id: str) -> bool:
+ with self._lock:
+ before = len(self._pools)
+ self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
+ if len(self._pools) < before:
+ self._save()
+ return True
+ return False
+
+ def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
+ with self._lock:
+ for index, pool in enumerate(self._pools):
+ if pool["id"] != pool_id:
+ continue
+ next_pool = dict(pool)
+ next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
+ self._pools[index] = next_pool
+ self._save()
+ return dict(next_pool)
+ return None
+
+ def get_import_job(self, pool_id: str) -> dict | None:
+ with self._lock:
+ for pool in self._pools:
+ if pool["id"] == pool_id:
+ job = pool.get("import_job")
+ return dict(job) if isinstance(job, dict) else None
+ return None
+
+
+def list_remote_files(pool: dict) -> list[dict]:
+ base_url = str(pool.get("base_url") or "").strip()
+ secret_key = str(pool.get("secret_key") or "").strip()
+ if not base_url or not secret_key:
+ return []
+
+ url = f"{base_url.rstrip('/')}/v0/management/auth-files"
+ session = Session(**proxy_settings.build_session_kwargs(verify=True))
+ try:
+ response = session.get(url, headers=_management_headers(secret_key), timeout=30)
+ if not response.ok:
+ raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
+ payload = response.json()
+ finally:
+ session.close()
+
+ files = payload.get("files") if isinstance(payload, dict) else None
+ if not isinstance(files, list):
+ raise RuntimeError("remote list payload is invalid")
+
+ items: list[dict] = []
+ for item in files:
+ if not isinstance(item, dict):
+ continue
+ name = str(item.get("name") or "").strip()
+ email = str(item.get("email") or item.get("account") or "").strip()
+ if not name:
+ continue
+ items.append({"name": name, "email": email})
+ return items
+
+
+def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
+ base_url = str(pool.get("base_url") or "").strip()
+ secret_key = str(pool.get("secret_key") or "").strip()
+ file_name = str(file_name or "").strip()
+ if not base_url or not secret_key or not file_name:
+ return None, "invalid request"
+
+ url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
+ session = Session(**proxy_settings.build_session_kwargs(verify=True))
+ try:
+ response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
+ if not response.ok:
+ return None, f"HTTP {response.status_code}"
+ payload = response.json()
+ except Exception as exc:
+ return None, str(exc)
+ finally:
+ session.close()
+
+ if not isinstance(payload, dict):
+ return None, "invalid payload"
+
+ access_token = str(payload.get("access_token") or "").strip()
+ if not access_token:
+ return None, "missing access_token"
+ return access_token, None
+
+
+class CPAImportService:
+ def __init__(self, cpa_config: CPAConfig):
+ self._config = cpa_config
+
+ def start_import(self, pool: dict, selected_files: list[str]) -> dict:
+ names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
+ if not names:
+ raise ValueError("selected files is required")
+
+ pool_id = str(pool.get("id") or "").strip()
+ job = {
+ "job_id": uuid.uuid4().hex,
+ "status": "pending",
+ "created_at": _now_iso(),
+ "updated_at": _now_iso(),
+ "total": len(names),
+ "completed": 0,
+ "added": 0,
+ "skipped": 0,
+ "refreshed": 0,
+ "failed": 0,
+ "errors": [],
+ }
+ saved_pool = self._config.set_import_job(pool_id, job)
+ if saved_pool is None:
+ raise ValueError("pool not found")
+
+ thread = threading.Thread(
+ target=self._run_import,
+ args=(pool_id, pool, names),
+ name=f"cpa-import-{pool_id}",
+ daemon=True,
+ )
+ thread.start()
+ return dict(saved_pool.get("import_job") or job)
+
+ def _update_job(self, pool_id: str, **updates) -> dict | None:
+ current = self._config.get_import_job(pool_id)
+ if current is None:
+ return None
+ next_job = {**current, **updates, "updated_at": _now_iso()}
+ pool = self._config.set_import_job(pool_id, next_job)
+ if pool is None:
+ return None
+ job = pool.get("import_job")
+ return dict(job) if isinstance(job, dict) else None
+
+ def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
+ current = self._config.get_import_job(pool_id)
+ if current is None:
+ return
+ errors = list(current.get("errors") or [])
+ errors.append({"name": file_name, "error": message})
+ self._update_job(pool_id, errors=errors, failed=len(errors))
+
+ def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
+ self._update_job(pool_id, status="running")
+
+ tokens: list[str] = []
+ max_workers = min(16, max(1, len(names)))
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
+ for future in as_completed(future_map):
+ file_name = future_map[future]
+ try:
+ token, error = future.result()
+ except Exception as exc:
+ token, error = None, str(exc)
+
+ if token:
+ tokens.append(token)
+ else:
+ self._append_error(pool_id, file_name, error or "unknown error")
+
+ current = self._config.get_import_job(pool_id) or {}
+ failed = len(current.get("errors") or [])
+ self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
+
+ if not tokens:
+ current = self._config.get_import_job(pool_id) or {}
+ self._update_job(
+ pool_id,
+ status="failed",
+ completed=int(current.get("total") or 0),
+ failed=len(current.get("errors") or []),
+ )
+ return
+
+ add_result = account_service.add_accounts(tokens, source_type="codex")
+ refresh_result = account_service.refresh_accounts(tokens)
+ current = self._config.get_import_job(pool_id) or {}
+ self._update_job(
+ pool_id,
+ status="completed",
+ completed=len(names),
+ added=int(add_result.get("added") or 0),
+ skipped=int(add_result.get("skipped") or 0),
+ refreshed=int(refresh_result.get("refreshed") or 0),
+ failed=len(current.get("errors") or []),
+ )
+
+
+cpa_config = CPAConfig(CPA_CONFIG_FILE)
+cpa_import_service = CPAImportService(cpa_config)
diff --git a/services/editable_file_task_service.py b/services/editable_file_task_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..77fe552ada470c38e5406388a64dc53a68fe53ed
--- /dev/null
+++ b/services/editable_file_task_service.py
@@ -0,0 +1,256 @@
+from __future__ import annotations
+
+import json
+import threading
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote
+
+from services.account_service import account_service
+from services.config import DATA_DIR
+from services.content_filter import request_text
+from services.log_service import LOG_TYPE_CALL, log_service
+from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
+from utils.helper import new_uuid
+
+TASK_STATUS_QUEUED = "queued"
+TASK_STATUS_RUNNING = "running"
+TASK_STATUS_SUCCESS = "success"
+TASK_STATUS_ERROR = "error"
+UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
+EDITABLE_FILE_PLAN_TYPES = ("Plus", "Team", "Pro", "Enterprise")
+EDITABLE_FILE_ROOT = DATA_DIR / "files"
+EDITABLE_FILE_TASKS_PATH = DATA_DIR / "editable_file_tasks.json"
+
+
+def _now_iso() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _clean(value: object, default: str = "") -> str:
+ return str(value or default).strip()
+
+
+def _owner_id(identity: dict[str, object]) -> str:
+ return _clean(identity.get("id")) or "anonymous"
+
+
+def _task_key(owner_id: str, task_id: str) -> str:
+ return f"{owner_id}:{task_id}"
+
+
+def _elapsed_seconds(task: dict[str, Any]) -> int:
+ start = float(task.get("started_ts") or task.get("created_ts") or 0)
+ end = float(task.get("ended_ts") or time.time())
+ return max(0, int(end - start)) if start else 0
+
+
+def _file_url(path: Path, base_url: str) -> str:
+ rel = path.resolve().relative_to(EDITABLE_FILE_ROOT.resolve()).as_posix()
+ prefix = str(base_url or "").strip().rstrip("/")
+ return f"{prefix}/files/{quote(rel, safe='/')}" if prefix else f"/files/{quote(rel, safe='/')}"
+
+
+def _editable_access_token() -> str:
+ accounts = [
+ item for item in account_service.list_accounts()
+ if _clean(item.get("access_token"))
+ and item.get("status") not in {"禁用", "异常"}
+ and account_service._account_matches_any_plan_type(item, EDITABLE_FILE_PLAN_TYPES)
+ ]
+ if not accounts:
+ raise RuntimeError("no available plus/team/pro account")
+ accounts.sort(key=lambda item: _clean(item.get("last_used_at")))
+ token = _clean(accounts[0].get("access_token"))
+ return account_service.refresh_access_token(token, event="editable_file_task") or token
+
+
+def _public_task(task: dict[str, Any]) -> dict[str, Any]:
+ item = {
+ "id": task.get("id"),
+ "taskId": task.get("id"),
+ "status": task.get("status"),
+ "kind": task.get("kind"),
+ "created_at": task.get("created_at"),
+ "updated_at": task.get("updated_at"),
+ "elapsed_seconds": _elapsed_seconds(task),
+ }
+ for key in ("result", "error"):
+ if task.get(key):
+ item[key] = task[key]
+ return item
+
+
+class EditableFileTaskService:
+ def __init__(self, path: Path = EDITABLE_FILE_TASKS_PATH) -> None:
+ self.path = path
+ self._lock = threading.RLock()
+ self._tasks: dict[str, dict[str, Any]] = {}
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with self._lock:
+ self._tasks = self._load_locked()
+ if self._recover_unfinished_locked():
+ self._save_locked()
+
+ def submit_ppt(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
+ return self._submit(identity, client_task_id=client_task_id, kind="ppt", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
+
+ def submit_psd(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
+ return self._submit(identity, client_task_id=client_task_id, kind="psd", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
+
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
+ owner = _owner_id(identity)
+ requested = [_clean(item) for item in task_ids if _clean(item)]
+ with self._lock:
+ if requested:
+ items = [task for task_id in requested if (task := self._tasks.get(_task_key(owner, task_id)))]
+ return {"items": [_public_task(item) for item in items], "missing_ids": [task_id for task_id in requested if _task_key(owner, task_id) not in self._tasks]}
+ items = [task for task in self._tasks.values() if task.get("owner_id") == owner]
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
+ return {"items": [_public_task(item) for item in items], "missing_ids": []}
+
+ def _submit(self, identity: dict[str, object], *, client_task_id: str, kind: str, prompt: str, base64_images: list[str], base_url: str) -> dict[str, Any]:
+ task_id = _clean(client_task_id) or new_uuid()
+ owner = _owner_id(identity)
+ key = _task_key(owner, task_id)
+ now = _now_iso()
+ with self._lock:
+ if key in self._tasks:
+ return _public_task(self._tasks[key])
+ ts = time.time()
+ self._tasks[key] = {"id": task_id, "owner_id": owner, "status": TASK_STATUS_QUEUED, "kind": kind, "model": EDITABLE_FILE_MODEL, "created_at": now, "updated_at": now, "created_ts": ts, "updated_ts": ts}
+ task = dict(self._tasks[key])
+ self._save_locked()
+ threading.Thread(target=self._run_task, args=(key, kind, prompt, base64_images, dict(identity), base_url), name=f"{kind}-file-task-{task_id[:16]}", daemon=True).start()
+ return _public_task(task)
+
+ def _run_task(self, key: str, kind: str, prompt: str, base64_images: list[str], identity: dict[str, object], base_url: str) -> None:
+ started = time.time()
+ token = ""
+ account_email = ""
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="", started_ts=started)
+ try:
+ if kind == "psd" and not base64_images:
+ raise ValueError("base64_images is empty")
+ token = _editable_access_token()
+ account = account_service.get_account(token) or {}
+ account_email = _clean(account.get("email"))
+ backend = OpenAIBackendAPI(token)
+ output_dir = EDITABLE_FILE_ROOT / kind / key.rsplit(":", 1)[-1]
+ result = backend.export_psd_zip(base64_images, prompt, output_dir) if kind == "psd" else backend.export_ppt_zip(base64_images, prompt, output_dir)
+ account_service.mark_text_used(token)
+ data = {"conversation_id": result.conversation_id, "primary_url": _file_url(result.primary_path, base_url), "zip_url": _file_url(result.zip_path, base_url)}
+ self._update_task(key, status=TASK_STATUS_SUCCESS, result=data, account_email=account_email, error="", ended_ts=time.time())
+ self._log_call(identity, kind, started, request_text(prompt), account_email=account_email, result=data)
+ except Exception as exc:
+ error = str(exc) or "editable file task failed"
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error, account_email=account_email, ended_ts=time.time())
+ self._log_call(identity, kind, started, request_text(prompt), status="failed", error=error, account_email=account_email)
+
+ def public_file_path(self, relative_path: str) -> Path:
+ raw = str(relative_path or "").replace("\\", "/").lstrip("/")
+ path = (EDITABLE_FILE_ROOT / raw).resolve()
+ path.relative_to(EDITABLE_FILE_ROOT.resolve())
+ if not path.is_file():
+ raise FileNotFoundError(raw)
+ return path
+
+ def _update_task(self, key: str, **updates: Any) -> None:
+ with self._lock:
+ task = self._tasks.get(key)
+ if task is None:
+ return
+ task.update(updates)
+ task["updated_at"] = _now_iso()
+ task["updated_ts"] = time.time()
+ self._save_locked()
+
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
+ if not self.path.exists():
+ return {}
+ try:
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ tasks: dict[str, dict[str, Any]] = {}
+ for item in (raw.get("tasks") if isinstance(raw, dict) else raw) or []:
+ if not isinstance(item, dict):
+ continue
+ task_id = _clean(item.get("id"))
+ owner = _clean(item.get("owner_id"))
+ if not task_id or not owner:
+ continue
+ task = {
+ "id": task_id,
+ "owner_id": owner,
+ "status": _clean(item.get("status"), TASK_STATUS_ERROR),
+ "kind": "psd" if item.get("kind") == "psd" else "ppt",
+ "created_at": _clean(item.get("created_at"), _now_iso()),
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
+ "created_ts": float(item.get("created_ts") or 0),
+ "updated_ts": float(item.get("updated_ts") or 0),
+ }
+ for field in ("result", "error", "started_ts", "ended_ts"):
+ if item.get(field):
+ task[field] = item[field]
+ tasks[_task_key(owner, task_id)] = task
+ return tasks
+
+ def _save_locked(self) -> None:
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ tmp_path.replace(self.path)
+
+ def _recover_unfinished_locked(self) -> bool:
+ changed = False
+ for task in self._tasks.values():
+ if task.get("status") in UNFINISHED_STATUSES:
+ task["status"] = TASK_STATUS_ERROR
+ task["error"] = "服务已重启,未完成的任务已中断"
+ task["ended_ts"] = time.time()
+ task["updated_at"] = _now_iso()
+ task["updated_ts"] = time.time()
+ changed = True
+ return changed
+
+ def _log_call(
+ self,
+ identity: dict[str, object],
+ kind: str,
+ started: float,
+ request_preview: str,
+ *,
+ status: str = "success",
+ error: str = "",
+ account_email: str = "",
+ result: dict[str, str] | None = None,
+ ) -> None:
+ detail = {
+ "key_id": identity.get("id"),
+ "key_name": identity.get("name"),
+ "role": identity.get("role"),
+ "endpoint": f"/v1/{kind}/generations",
+ "model": EDITABLE_FILE_MODEL,
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
+ "ended_at": _now_iso(),
+ "duration_ms": int((time.time() - started) * 1000),
+ "status": status,
+ }
+ if request_preview:
+ detail["request_text"] = request_preview
+ if account_email:
+ detail["account_email"] = account_email
+ if error:
+ detail["error"] = error
+ if result:
+ detail["result"] = result
+ try:
+ log_service.add(LOG_TYPE_CALL, f"{kind.upper()}生成任务{'失败' if status == 'failed' else '完成'}", detail)
+ except Exception:
+ pass
+
+
+editable_file_task_service = EditableFileTaskService()
diff --git a/services/image_service.py b/services/image_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..ebe627f8b26811d70c7c188acc3dd34f5ea42075
--- /dev/null
+++ b/services/image_service.py
@@ -0,0 +1,376 @@
+from __future__ import annotations
+
+import io
+import shutil
+import threading
+import time
+import zipfile
+from pathlib import Path
+
+from fastapi import HTTPException
+from fastapi.responses import FileResponse, Response
+from PIL import Image, ImageOps
+
+from services.config import config
+from services.image_storage_service import image_storage_service
+from services.image_tags_service import load_tags, remove_tags
+from utils.log import logger
+
+THUMBNAIL_SIZE = (320, 320)
+
+
+def _cleanup_empty_dirs(root: Path) -> None:
+ for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
+ try:
+ path.rmdir()
+ except OSError:
+ pass
+
+
+def _safe_relative_path(path: str) -> str:
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
+ if not value:
+ raise HTTPException(status_code=404, detail="image not found")
+ parts = Path(value).parts
+ if any(part in {"", ".", ".."} for part in parts):
+ raise HTTPException(status_code=404, detail="image not found")
+ return Path(*parts).as_posix()
+
+
+def _safe_image_path(relative_path: str) -> Path:
+ rel = _safe_relative_path(relative_path)
+ root = config.images_dir.resolve()
+ path = (root / rel).resolve()
+ try:
+ path.relative_to(root)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail="image not found") from exc
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="image not found")
+ return path
+
+
+def get_image_response(relative_path: str) -> FileResponse | Response:
+ headers = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ }
+ if image_storage_service.has_local(relative_path):
+ return FileResponse(_safe_image_path(relative_path), headers=headers)
+ return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png", headers=headers)
+
+
+def _thumbnail_path(relative_path: str) -> Path:
+ rel = _safe_relative_path(relative_path)
+ return config.image_thumbnails_dir / f"{rel}.png"
+
+
+def thumbnail_url(base_url: str, relative_path: str) -> str:
+ return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
+
+
+def _image_dimensions(path: Path) -> tuple[int, int] | None:
+ try:
+ with Image.open(path) as image:
+ return image.size
+ except Exception:
+ return None
+
+
+def ensure_thumbnail(relative_path: str) -> Path:
+ target = _thumbnail_path(relative_path)
+ source_mtime = 0.0
+ source: Path | None = None
+ if image_storage_service.has_local(relative_path):
+ source = _safe_image_path(relative_path)
+ source_mtime = source.stat().st_mtime
+ if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
+ return target
+
+ target.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
+ with Image.open(image_source) as image:
+ image = ImageOps.exif_transpose(image)
+ if image.mode not in {"RGB", "RGBA"}:
+ image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
+ image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
+ image.save(target, format="PNG", optimize=True)
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
+ return target
+
+
+def get_thumbnail_response(relative_path: str) -> FileResponse:
+ headers = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ }
+ return FileResponse(ensure_thumbnail(relative_path), headers=headers)
+
+
+def get_image_download_response(relative_path: str) -> FileResponse:
+ cors_headers = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ }
+ if image_storage_service.has_local(relative_path):
+ path = _safe_image_path(relative_path)
+ headers = {**cors_headers, "Content-Disposition": f'attachment; filename="{path.name}"'}
+ return FileResponse(path, filename=path.name, headers=headers)
+ rel = _safe_relative_path(relative_path)
+ headers = {
+ **cors_headers,
+ "Content-Disposition": f'attachment; filename="{Path(rel).name}"',
+ }
+ return Response(
+ content=image_storage_service.get_bytes(rel),
+ media_type="image/png",
+ headers=headers,
+ )
+
+
+def cleanup_image_thumbnails() -> int:
+ thumbnails_root = config.image_thumbnails_dir
+ removed = 0
+ for path in thumbnails_root.rglob("*"):
+ if not path.is_file():
+ continue
+ rel = path.relative_to(thumbnails_root).as_posix()
+ if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
+ path.unlink()
+ removed += 1
+ _cleanup_empty_dirs(thumbnails_root)
+ return removed
+
+def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
+ config.cleanup_old_images()
+ cleanup_image_thumbnails()
+ all_tags = load_tags()
+ items = [
+ {
+ **item,
+ "url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
+ "thumbnail_url": thumbnail_url(base_url, str(item["path"])),
+ "tags": all_tags.get(str(item["path"]), []),
+ }
+ for item in image_storage_service.list_items(base_url, start_date, end_date)
+ ]
+ groups: dict[str, list[dict[str, object]]] = {}
+ for item in items:
+ groups.setdefault(str(item["date"]), []).append(item)
+ return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
+
+
+def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
+ root = config.images_dir.resolve()
+ targets = [
+ str(item["path"])
+ for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
+ ] if all_matching else (paths or [])
+ removed = 0
+ for item in targets:
+ path = (root / item).resolve()
+ try:
+ path.relative_to(root)
+ except ValueError:
+ continue
+ if image_storage_service.delete(item):
+ removed += 1
+ for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
+ if thumbnail.is_file():
+ thumbnail.unlink()
+ remove_tags(item)
+ _cleanup_empty_dirs(root)
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
+ return {"removed": removed}
+
+
+def download_images_zip(paths: list[str]) -> io.BytesIO:
+ root = config.images_dir.resolve()
+ buf = io.BytesIO()
+ added = 0
+ used_names: set[str] = set()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for item in paths:
+ rel = _safe_relative_path(item)
+ path = (root / rel).resolve()
+ payload: bytes | None = None
+ try:
+ path.relative_to(root)
+ except ValueError:
+ continue
+ if path.is_file():
+ payload = path.read_bytes()
+ else:
+ try:
+ payload = image_storage_service.get_bytes(rel)
+ except Exception:
+ continue
+ name = path.name
+ if name in used_names:
+ stem = path.stem
+ suffix = path.suffix
+ counter = 2
+ while f"{stem}_{counter}{suffix}" in used_names:
+ counter += 1
+ name = f"{stem}_{counter}{suffix}"
+ used_names.add(name)
+ zf.writestr(name, payload)
+ added += 1
+ if added == 0:
+ raise HTTPException(status_code=404, detail="no images found")
+ buf.seek(0)
+ return buf
+def storage_stats() -> dict:
+ import shutil
+ usage = shutil.disk_usage(config.images_dir)
+ total_mb = usage.total // (1024 * 1024)
+ used_mb = usage.used // (1024 * 1024)
+ free_mb = usage.free // (1024 * 1024)
+
+ image_count = 0
+ image_size = 0
+ for p in config.images_dir.rglob("*"):
+ if p.is_file():
+ image_count += 1
+ image_size += p.stat().st_size
+
+ return {
+ "disk_total_mb": total_mb,
+ "disk_used_mb": used_mb,
+ "disk_free_mb": free_mb,
+ "image_count": image_count,
+ "image_size_mb": image_size // (1024 * 1024),
+ "image_size_bytes": image_size,
+ }
+
+
+def compress_images(quality: int = 60) -> dict:
+ """重新压缩所有图片,返回节省的空间"""
+ saved = 0
+ count = 0
+ for p in sorted(config.images_dir.rglob("*.png")):
+ if not p.is_file():
+ continue
+ try:
+ orig = p.stat().st_size
+ with Image.open(p) as img:
+ img = ImageOps.exif_transpose(img)
+ img.save(str(p) + ".tmp", format="PNG", optimize=True)
+ new_size = Path(str(p) + ".tmp").stat().st_size
+ if new_size < orig:
+ Path(str(p) + ".tmp").replace(p)
+ saved += orig - new_size
+ count += 1
+ else:
+ Path(str(p) + ".tmp").unlink()
+ except Exception:
+ pass
+ return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
+
+
+def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
+ """删除最旧的图片直到剩余空间达到 target_free_mb"""
+ import shutil
+ usage = shutil.disk_usage(config.images_dir)
+ current_free = usage.free // (1024 * 1024)
+ if current_free >= target_free_mb and not dry_run:
+ return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
+
+ files = sorted(
+ (p for p in config.images_dir.rglob("*.png") if p.is_file()),
+ key=lambda p: p.stat().st_mtime,
+ )
+ removed = 0
+ freed = 0
+ for p in files:
+ if current_free + freed // (1024 * 1024) >= target_free_mb:
+ break
+ size = p.stat().st_size
+ if not dry_run:
+ rel = p.relative_to(config.images_dir).as_posix()
+ for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
+ if tp.is_file():
+ tp.unlink()
+ remove_tags(rel)
+ p.unlink()
+ freed += size
+ removed += 1
+
+ if not dry_run:
+ _cleanup_empty_dirs(config.images_dir)
+ _cleanup_empty_dirs(config.image_thumbnails_dir)
+
+ return {
+ "removed": removed,
+ "freed_mb": freed // (1024 * 1024),
+ "target_free_mb": target_free_mb,
+ "current_free_mb": current_free + (freed // (1024 * 1024)),
+ "done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
+ "dry_run": dry_run,
+ }
+
+
+def download_images_zip(paths: list[str]) -> io.BytesIO:
+ root = config.images_dir.resolve()
+ buf = io.BytesIO()
+ added = 0
+ used_names: set[str] = set()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for item in paths:
+ rel = _safe_relative_path(item)
+ path = (root / rel).resolve()
+ try:
+ path.relative_to(root)
+ except ValueError:
+ continue
+ if not path.is_file():
+ continue
+ name = path.name
+ if name in used_names:
+ stem = path.stem
+ suffix = path.suffix
+ counter = 2
+ while f"{stem}_{counter}{suffix}" in used_names:
+ counter += 1
+ name = f"{stem}_{counter}{suffix}"
+ used_names.add(name)
+ zf.write(path, name)
+ added += 1
+ if added == 0:
+ raise HTTPException(status_code=404, detail="no images found")
+ buf.seek(0)
+ return buf
+
+
+def _auto_cleanup_worker(stop_event: threading.Event) -> None:
+ """后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
+ import shutil
+ min_free_mb = getattr(config, "image_min_free_mb", None)
+ if min_free_mb is None:
+ min_free_mb = 500
+
+ while not stop_event.wait(1800): # 每30分钟
+ try:
+ config.cleanup_old_images()
+ cleanup_image_thumbnails()
+ usage = shutil.disk_usage(config.images_dir)
+ free_mb = usage.free // (1024 * 1024)
+ if free_mb < min_free_mb:
+ logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
+ result = delete_to_target(min_free_mb)
+ logger.info({"event": "image_auto_cleanup_done", **result})
+ except Exception:
+ pass
+
+
+def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
+ t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
+ t.start()
+ return t
diff --git a/services/image_storage_service.py b/services/image_storage_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b4216d9aab593fb31f1fb5dbe030f190f76f178
--- /dev/null
+++ b/services/image_storage_service.py
@@ -0,0 +1,405 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import time
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from threading import Lock
+from urllib.parse import quote, urlparse
+
+from curl_cffi import requests
+from fastapi import HTTPException
+from PIL import Image
+
+from services.config import DATA_DIR, config
+
+IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
+IMAGE_INDEX_LOCK = Lock()
+IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
+
+
+class ImageStorageError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class StoredImage:
+ rel: str
+ url: str
+ storage: str
+ size: int
+
+
+def _clean(value: object) -> str:
+ return str(value or "").strip()
+
+
+def _now_iso() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _safe_relative_path(path: str) -> str:
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
+ if not value:
+ raise HTTPException(status_code=404, detail="image not found")
+ parts = Path(value).parts
+ if any(part in {"", ".", ".."} for part in parts):
+ raise HTTPException(status_code=404, detail="image not found")
+ return Path(*parts).as_posix()
+
+
+def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
+ try:
+ with Image.open(io.BytesIO(payload)) as image:
+ return image.size
+ except Exception:
+ return None
+
+
+def _is_image_rel(path: str) -> bool:
+ try:
+ safe_rel = _safe_relative_path(path)
+ except HTTPException:
+ return False
+ return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
+
+
+def _local_image_path(relative_path: str) -> Path:
+ rel = _safe_relative_path(relative_path)
+ root = config.images_dir.resolve()
+ path = (root / rel).resolve()
+ try:
+ path.relative_to(root)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail="image not found") from exc
+ return path
+
+
+def _read_json_object(path: Path) -> dict[str, object]:
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _write_json_object(path: Path, data: dict[str, object]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
+ tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ tmp_path.replace(path)
+
+
+class WebDAVClient:
+ def __init__(self, settings: dict[str, object]):
+ self.url = _clean(settings.get("webdav_url")).rstrip("/")
+ self.username = _clean(settings.get("webdav_username"))
+ self.password = _clean(settings.get("webdav_password"))
+ self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
+ self.session = requests.Session()
+
+ def _auth_kwargs(self) -> dict[str, object]:
+ return {"auth": (self.username, self.password)} if self.username or self.password else {}
+
+ def _request(self, method: str, url: str, **kwargs):
+ response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
+ if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
+ raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
+ return response
+
+ def remote_url(self, rel: str = "") -> str:
+ parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
+ encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
+ return f"{self.url}/{encoded}" if encoded else self.url
+
+ def ensure_dirs(self, rel: str) -> None:
+ parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
+ current = self.url
+ for item in "/".join(parts).split("/"):
+ if not item:
+ continue
+ current = f"{current}/{quote(item, safe='')}"
+ response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
+ if response.status_code in {201, 405}:
+ continue
+ if response.status_code >= 400:
+ raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
+
+ def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
+ self.ensure_dirs(rel)
+ url = self.remote_url(rel)
+ self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
+ return url
+
+ def get(self, rel: str) -> bytes:
+ response = self._request("GET", self.remote_url(rel))
+ return bytes(response.content)
+
+ def delete(self, rel: str) -> bool:
+ response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
+ if response.status_code in {200, 202, 204, 404}:
+ return response.status_code != 404
+ raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
+
+ def test(self) -> dict[str, object]:
+ if not self.url:
+ return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
+ if urlparse(self.url).scheme not in {"http", "https"}:
+ return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
+ test_rel = ".chatgpt2api_webdav_test.txt"
+ try:
+ self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
+ self.delete(test_rel)
+ return {"ok": True, "status": 200, "error": None}
+ except ImageStorageError as exc:
+ return {"ok": False, "status": 0, "error": str(exc)}
+ except Exception as exc:
+ return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
+ finally:
+ self.session.close()
+
+
+class ImageStorageService:
+ def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
+ self.index_file = index_file
+ self._index_lock = IMAGE_INDEX_LOCK
+
+ def settings(self) -> dict[str, object]:
+ return config.get_image_storage_settings()
+
+ def mode(self) -> str:
+ return _clean(self.settings().get("mode")) or "local"
+
+ def _load_index(self) -> dict[str, dict[str, object]]:
+ raw = _read_json_object(self.index_file)
+ items = raw.get("items")
+ if not isinstance(items, dict):
+ return {}
+ return {str(key): value for key, value in items.items() if isinstance(value, dict)}
+
+ def _load_clean_index(self) -> dict[str, dict[str, object]]:
+ items = self._load_index()
+ return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
+
+ def _save_index(self, items: dict[str, dict[str, object]]) -> None:
+ _write_json_object(self.index_file, {"items": items})
+
+ def _public_url(self, rel: str, base_url: str | None = None) -> str:
+ settings = self.settings()
+ public_base_url = _clean(settings.get("public_base_url"))
+ if public_base_url:
+ return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
+ return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
+
+ def make_relative_path(self, image_data: bytes) -> str:
+ file_hash = hashlib.md5(image_data).hexdigest()
+ filename = f"{int(time.time())}_{file_hash}.png"
+ relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
+ return f"{relative_dir.as_posix()}/{filename}"
+
+ def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
+ config.cleanup_old_images()
+ rel = self.make_relative_path(image_data)
+ mode = self.mode()
+ if mode not in {"local", "webdav", "both"}:
+ mode = "local"
+ stored_local = False
+ stored_webdav = False
+ remote_url = ""
+
+ if mode in {"local", "both"}:
+ path = _local_image_path(rel)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(image_data)
+ stored_local = True
+
+ if mode in {"webdav", "both"}:
+ remote_url = WebDAVClient(self.settings()).put(rel, image_data)
+ stored_webdav = True
+
+ dimensions = _image_dimensions(image_data)
+ item = {
+ "rel": rel,
+ "path": rel,
+ "name": Path(rel).name,
+ "date": "-".join(rel.split("/")[:3]),
+ "size": len(image_data),
+ "created_at": _now_iso(),
+ "storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
+ "local": stored_local,
+ "webdav": stored_webdav,
+ "remote_url": remote_url,
+ }
+ if dimensions:
+ item["width"], item["height"] = dimensions
+ with self._index_lock:
+ items = self._load_clean_index()
+ items[rel] = item
+ self._save_index(items)
+ return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
+
+ def get_bytes(self, rel: str) -> bytes:
+ safe_rel = _safe_relative_path(rel)
+ if not _is_image_rel(safe_rel):
+ raise HTTPException(status_code=404, detail="image not found")
+ path = _local_image_path(safe_rel)
+ if path.is_file():
+ return path.read_bytes()
+ item = self._load_clean_index().get(safe_rel, {})
+ if item.get("webdav"):
+ return WebDAVClient(self.settings()).get(safe_rel)
+ raise HTTPException(status_code=404, detail="image not found")
+
+ def exists(self, rel: str) -> bool:
+ safe_rel = _safe_relative_path(rel)
+ if not _is_image_rel(safe_rel):
+ return False
+ if _local_image_path(safe_rel).is_file():
+ return True
+ item = self._load_clean_index().get(safe_rel, {})
+ return bool(item.get("webdav"))
+
+ def has_local(self, rel: str) -> bool:
+ safe_rel = _safe_relative_path(rel)
+ return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
+
+ def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
+ with self._index_lock:
+ indexed = self._load_clean_index()
+ root = config.images_dir
+ changed = False
+ for path in root.rglob("*"):
+ if not path.is_file() or not _is_image_rel(path.name):
+ continue
+ rel = path.relative_to(root).as_posix()
+ if rel in indexed:
+ continue
+ dimensions = None
+ try:
+ dimensions = _image_dimensions(path.read_bytes())
+ except Exception:
+ dimensions = None
+ indexed[rel] = {
+ "rel": rel,
+ "path": rel,
+ "name": path.name,
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
+ "size": path.stat().st_size,
+ "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
+ "storage": "local",
+ "local": True,
+ "webdav": False,
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
+ }
+ changed = True
+
+ items: list[dict[str, object]] = []
+ for rel, item in list(indexed.items()):
+ if not _is_image_rel(rel):
+ indexed.pop(rel, None)
+ changed = True
+ continue
+ local = _local_image_path(rel).is_file()
+ webdav = bool(item.get("webdav"))
+ if not local and not webdav:
+ indexed.pop(rel, None)
+ changed = True
+ continue
+ storage = "both" if local and webdav else ("webdav" if webdav else "local")
+ if item.get("local") != local or item.get("storage") != storage:
+ item = {
+ **item,
+ "local": local,
+ "storage": storage,
+ }
+ indexed[rel] = item
+ changed = True
+ day = str(item.get("date") or "")
+ if start_date and day < start_date:
+ continue
+ if end_date and day > end_date:
+ continue
+ items.append({
+ **item,
+ "rel": rel,
+ "path": rel,
+ "url": self._public_url(rel, base_url),
+ })
+ if changed:
+ self._save_index(indexed)
+ items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
+ return items
+
+ def delete(self, rel: str) -> bool:
+ safe_rel = _safe_relative_path(rel)
+ removed = False
+ path = _local_image_path(safe_rel)
+ if path.is_file():
+ path.unlink()
+ removed = True
+ with self._index_lock:
+ items = self._load_clean_index()
+ item = items.get(safe_rel, {})
+ if item.get("webdav"):
+ try:
+ removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
+ except ImageStorageError:
+ if not removed:
+ raise
+ if safe_rel in items:
+ items.pop(safe_rel, None)
+ self._save_index(items)
+ return removed
+
+ def sync_all(self) -> dict[str, int]:
+ settings = self.settings()
+ if self.mode() not in {"webdav", "both"}:
+ raise ImageStorageError("WebDAV 图片存储未启用")
+ uploaded = 0
+ skipped = 0
+ failed = 0
+ with self._index_lock:
+ items = self._load_clean_index()
+ client = WebDAVClient(settings)
+ for path in sorted(config.images_dir.rglob("*")):
+ if not path.is_file() or not _is_image_rel(path.name):
+ continue
+ rel = path.relative_to(config.images_dir).as_posix()
+ item = items.get(rel, {})
+ if item.get("webdav"):
+ skipped += 1
+ continue
+ try:
+ payload = path.read_bytes()
+ remote_url = client.put(rel, payload)
+ dimensions = _image_dimensions(payload)
+ items[rel] = {
+ **item,
+ "rel": rel,
+ "path": rel,
+ "name": path.name,
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
+ "size": len(payload),
+ "created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
+ "storage": "both",
+ "local": True,
+ "webdav": True,
+ "remote_url": remote_url,
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
+ }
+ uploaded += 1
+ except Exception:
+ failed += 1
+ self._save_index(items)
+ return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
+
+ def test_webdav(self) -> dict[str, object]:
+ return WebDAVClient(self.settings()).test()
+
+
+image_storage_service = ImageStorageService()
diff --git a/services/image_tags_service.py b/services/image_tags_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..21910ad54e0aa03433f31fa25a00a91e692a22b0
--- /dev/null
+++ b/services/image_tags_service.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from services.config import DATA_DIR
+
+TAGS_FILE = DATA_DIR / "image_tags.json"
+
+
+def _ensure_file() -> None:
+ TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
+ if not TAGS_FILE.exists():
+ TAGS_FILE.write_text("{}", encoding="utf-8")
+
+
+def load_tags() -> dict[str, list[str]]:
+ _ensure_file()
+ try:
+ data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def save_tags(data: dict[str, list[str]]) -> None:
+ _ensure_file()
+ TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+
+def get_tags(image_rel: str) -> list[str]:
+ return load_tags().get(image_rel, [])
+
+
+def set_tags(image_rel: str, tags: list[str]) -> list[str]:
+ data = load_tags()
+ cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
+ if cleaned:
+ data[image_rel] = cleaned
+ else:
+ data.pop(image_rel, None)
+ save_tags(data)
+ return cleaned
+
+
+def remove_tags(image_rel: str) -> None:
+ data = load_tags()
+ if data.pop(image_rel, None) is not None:
+ save_tags(data)
+
+
+def delete_tag(tag: str) -> int:
+ """从所有图片中删除指定标签,返回受影响的图片数。"""
+ data = load_tags()
+ count = 0
+ for rel in list(data):
+ if tag in data[rel]:
+ data[rel] = [t for t in data[rel] if t != tag]
+ if not data[rel]:
+ del data[rel]
+ count += 1
+ if count > 0:
+ save_tags(data)
+ return count
+
+
+def get_all_tags() -> list[str]:
+ data = load_tags()
+ seen: set[str] = set()
+ result: list[str] = []
+ for tags in data.values():
+ for t in tags:
+ if t not in seen:
+ seen.add(t)
+ result.append(t)
+ return result
diff --git a/services/image_task_service.py b/services/image_task_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..43e1e3dd227ef661e5f6f7b0c260285469f70d40
--- /dev/null
+++ b/services/image_task_service.py
@@ -0,0 +1,544 @@
+from __future__ import annotations
+
+import json
+import threading
+import time
+from collections.abc import Callable
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from services.config import DATA_DIR, config
+from services.content_filter import request_text
+from services.log_service import LOG_TYPE_CALL, log_service
+from services.protocol import openai_v1_image_edit, openai_v1_image_generations
+
+TASK_STATUS_QUEUED = "queued"
+TASK_STATUS_RUNNING = "running"
+TASK_STATUS_SUCCESS = "success"
+TASK_STATUS_ERROR = "error"
+TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
+UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
+
+
+def _now_iso() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _timestamp(value: object) -> float:
+ if not isinstance(value, str) or not value.strip():
+ return 0.0
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
+ try:
+ return datetime.strptime(value[:26], fmt).timestamp()
+ except ValueError:
+ continue
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
+ except Exception:
+ return 0.0
+
+
+def _clean(value: object, default: str = "") -> str:
+ return str(value or default).strip()
+
+
+def _owner_id(identity: dict[str, object]) -> str:
+ return _clean(identity.get("id")) or "anonymous"
+
+
+def _task_key(owner_id: str, task_id: str) -> str:
+ return f"{owner_id}:{task_id}"
+
+
+def _collect_image_urls(data: list[Any]) -> list[str]:
+ urls: list[str] = []
+ for item in data:
+ if isinstance(item, dict):
+ url = item.get("url")
+ if isinstance(url, str) and url:
+ urls.append(url)
+ return urls
+
+
+def _public_task(task: dict[str, Any]) -> dict[str, Any]:
+ item = {
+ "id": task.get("id"),
+ "status": task.get("status"),
+ "mode": task.get("mode"),
+ "model": task.get("model"),
+ "size": task.get("size"),
+ "quality": task.get("quality"),
+ "created_at": task.get("created_at"),
+ "updated_at": task.get("updated_at"),
+ }
+ if task.get("conversation_id"):
+ item["conversation_id"] = task.get("conversation_id")
+ if task.get("data") is not None:
+ item["data"] = task.get("data")
+ if task.get("usage") is not None:
+ item["usage"] = task.get("usage")
+ if task.get("error"):
+ item["error"] = task.get("error")
+ if task.get("progress"):
+ item["progress"] = task.get("progress")
+ if task.get("duration_ms") is not None:
+ item["duration_ms"] = task.get("duration_ms")
+ if task.get("status") in (TASK_STATUS_RUNNING, TASK_STATUS_QUEUED):
+ if task.get("status") == TASK_STATUS_RUNNING:
+ # RUNNING 状态仅在 started_ts 被设置后(image_stream_resolve_start)才计时
+ base_ts = task.get("started_ts")
+ else:
+ # QUEUED 状态从 created_ts 开始计时(排队等待中)
+ base_ts = task.get("created_ts") or task.get("updated_ts")
+ if base_ts:
+ item["elapsed_secs"] = round(time.time() - base_ts, 1)
+ return item
+
+
+class ImageTaskService:
+ def __init__(
+ self,
+ path: Path,
+ *,
+ generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
+ edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
+ retention_days_getter: Callable[[], int] | None = None,
+ ):
+ self.path = path
+ self.generation_handler = generation_handler
+ self.edit_handler = edit_handler
+ self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
+ self._lock = threading.RLock()
+ self._tasks: dict[str, dict[str, Any]] = {}
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with self._lock:
+ self._tasks = self._load_locked()
+ changed = self._recover_unfinished_locked()
+ changed = self._cleanup_locked() or changed
+ if changed:
+ self._save_locked()
+
+ def submit_generation(
+ self,
+ identity: dict[str, object],
+ *,
+ client_task_id: str,
+ prompt: str,
+ model: str,
+ size: str | None,
+ quality: str = "auto",
+ base_url: str = "",
+ ) -> dict[str, Any]:
+ payload = {
+ "prompt": prompt,
+ "model": model,
+ "n": 1,
+ "size": size,
+ "quality": quality,
+ "response_format": "url",
+ "base_url": base_url,
+ }
+ return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
+
+ def submit_edit(
+ self,
+ identity: dict[str, object],
+ *,
+ client_task_id: str,
+ prompt: str,
+ model: str,
+ size: str | None,
+ quality: str = "auto",
+ base_url: str = "",
+ images: list[tuple[bytes, str, str]] | None = None,
+ ) -> dict[str, Any]:
+ payload = {
+ "prompt": prompt,
+ "images": images or [],
+ "model": model,
+ "n": 1,
+ "size": size,
+ "quality": quality,
+ "response_format": "url",
+ "base_url": base_url,
+ }
+ return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
+
+ def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
+ owner = _owner_id(identity)
+ requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
+ with self._lock:
+ if self._cleanup_locked():
+ self._save_locked()
+ items = []
+ missing_ids = []
+ for task_id in requested_ids:
+ task = self._tasks.get(_task_key(owner, task_id))
+ if task is None:
+ missing_ids.append(task_id)
+ else:
+ items.append(_public_task(task))
+ if not requested_ids:
+ items = [
+ _public_task(task)
+ for task in self._tasks.values()
+ if task.get("owner_id") == owner
+ ]
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
+ missing_ids = []
+ return {"items": items, "missing_ids": missing_ids}
+
+ def _submit(
+ self,
+ identity: dict[str, object],
+ *,
+ client_task_id: str,
+ mode: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ task_id = _clean(client_task_id)
+ if not task_id:
+ raise ValueError("client_task_id is required")
+ owner = _owner_id(identity)
+ key = _task_key(owner, task_id)
+ now = _now_iso()
+ should_start = False
+ with self._lock:
+ cleaned = self._cleanup_locked()
+ task = self._tasks.get(key)
+ if task is not None:
+ if cleaned:
+ self._save_locked()
+ return _public_task(task)
+ task = {
+ "id": task_id,
+ "owner_id": owner,
+ "status": TASK_STATUS_QUEUED,
+ "mode": mode,
+ "model": _clean(payload.get("model"), "gpt-image-2"),
+ "size": _clean(payload.get("size")),
+ "quality": _clean(payload.get("quality"), "auto"),
+ "created_at": now,
+ "updated_at": now,
+ "created_ts": time.time(),
+ }
+ self._tasks[key] = task
+ self._save_locked()
+ should_start = True
+
+ if should_start:
+ thread = threading.Thread(
+ target=self._run_task,
+ args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
+ name=f"image-task-{task_id[:16]}",
+ daemon=True,
+ )
+ thread.start()
+ return _public_task(task)
+
+ def _run_task(
+ self,
+ key: str,
+ mode: str,
+ payload: dict[str, Any],
+ identity: dict[str, object],
+ model: str,
+ ) -> None:
+ started = time.time()
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
+ # 创建进度回调,每个步骤完成后更新任务状态
+ def progress_callback(step: str) -> None:
+ if step == "image_stream_resolve_start":
+ self._update_task(key, started_ts=time.time())
+ self._update_task(key, progress=step)
+ # 将进度回调添加到 payload 中(handler 会提取并传递给 ConversationRequest)
+ payload_with_progress = {**payload, "progress_callback": progress_callback}
+ try:
+ handler = self.edit_handler if mode == "edit" else self.generation_handler
+ result = handler(payload_with_progress)
+ if not isinstance(result, dict):
+ raise RuntimeError("image task returned streaming result unexpectedly")
+ data = result.get("data")
+ account_email = _clean(result.get("_account_email") or result.get("account_email"))
+ if not isinstance(data, list) or not data:
+ upstream = _clean(result.get("message"))
+ if upstream:
+ message = upstream
+ else:
+ message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
+ error = RuntimeError(message)
+ if account_email:
+ setattr(error, "account_email", account_email)
+ raise error
+ usage = result.get("usage")
+ duration_ms = int((time.time() - started) * 1000)
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="", duration_ms=duration_ms)
+ self._log_call(
+ identity,
+ mode,
+ model,
+ started,
+ "调用完成",
+ request_preview=request_text(payload.get("prompt")),
+ urls=_collect_image_urls(data),
+ account_email=account_email,
+ )
+ except Exception as exc:
+ error_message = str(exc) or "image task failed"
+ account_email = _clean(getattr(exc, "account_email", ""))
+ conversation_id = _clean(getattr(exc, "conversation_id", ""))
+ duration_ms = int((time.time() - started) * 1000)
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[],
+ duration_ms=duration_ms,
+ **({"conversation_id": conversation_id} if conversation_id else {}))
+ self._log_call(
+ identity,
+ mode,
+ model,
+ started,
+ "调用失败",
+ request_preview=request_text(payload.get("prompt")),
+ status="failed",
+ error=error_message,
+ account_email=account_email,
+ )
+
+ def _log_call(
+ self,
+ identity: dict[str, object],
+ mode: str,
+ model: str,
+ started: float,
+ suffix: str,
+ *,
+ request_preview: str = "",
+ status: str = "success",
+ error: str = "",
+ urls: list[str] | None = None,
+ account_email: str = "",
+ ) -> None:
+ endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
+ summary_prefix = "图生图" if mode == "edit" else "文生图"
+ detail = {
+ "key_id": identity.get("id"),
+ "key_name": identity.get("name"),
+ "role": identity.get("role"),
+ "endpoint": endpoint,
+ "model": model,
+ "started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
+ "ended_at": _now_iso(),
+ "duration_ms": int((time.time() - started) * 1000),
+ "status": status,
+ }
+ if request_preview:
+ detail["request_text"] = request_preview
+ if error:
+ detail["error"] = error
+ if account_email:
+ detail["account_email"] = account_email
+ if urls:
+ detail["urls"] = list(dict.fromkeys(urls))
+ try:
+ log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
+ except Exception:
+ pass
+
+ def _update_task(self, key: str, **updates: Any) -> None:
+ with self._lock:
+ task = self._tasks.get(key)
+ if task is None:
+ return
+ task.update(updates)
+ task["updated_at"] = _now_iso()
+ task["updated_ts"] = time.time()
+ self._save_locked()
+
+ def _load_locked(self) -> dict[str, dict[str, Any]]:
+ if not self.path.exists():
+ return {}
+ try:
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
+ if not isinstance(raw_items, list):
+ return {}
+ tasks: dict[str, dict[str, Any]] = {}
+ for item in raw_items:
+ if not isinstance(item, dict):
+ continue
+ task_id = _clean(item.get("id"))
+ owner = _clean(item.get("owner_id"))
+ if not task_id or not owner:
+ continue
+ status = _clean(item.get("status"))
+ if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
+ status = TASK_STATUS_ERROR
+ task = {
+ "id": task_id,
+ "owner_id": owner,
+ "status": status,
+ "mode": "edit" if item.get("mode") == "edit" else "generate",
+ "model": _clean(item.get("model"), "gpt-image-2"),
+ "size": _clean(item.get("size")),
+ "quality": _clean(item.get("quality"), "auto"),
+ "created_at": _clean(item.get("created_at"), _now_iso()),
+ "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
+ "created_ts": item.get("created_ts"),
+ "updated_ts": item.get("updated_ts"),
+ "started_ts": item.get("started_ts"),
+ "duration_ms": item.get("duration_ms"),
+ }
+ data = item.get("data")
+ if isinstance(data, list):
+ task["data"] = data
+ usage = item.get("usage")
+ if isinstance(usage, dict):
+ task["usage"] = usage
+ error = _clean(item.get("error"))
+ if error:
+ task["error"] = error
+ tasks[_task_key(owner, task_id)] = task
+ return tasks
+
+ def _save_locked(self) -> None:
+ items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
+ tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
+ tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ tmp_path.replace(self.path)
+
+ def _recover_unfinished_locked(self) -> bool:
+ changed = False
+ for task in self._tasks.values():
+ if task.get("status") in UNFINISHED_STATUSES:
+ task["status"] = TASK_STATUS_ERROR
+ task["error"] = "服务已重启,未完成的图片任务已中断"
+ task["updated_at"] = _now_iso()
+ changed = True
+ return changed
+
+ def _cleanup_locked(self) -> bool:
+ try:
+ retention_days = max(1, int(self.retention_days_getter()))
+ except Exception:
+ retention_days = 30
+ cutoff = time.time() - retention_days * 86400
+ removed_keys = [
+ key
+ for key, task in self._tasks.items()
+ if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
+ ]
+ for key in removed_keys:
+ self._tasks.pop(key, None)
+ return bool(removed_keys)
+
+ def resume_poll(
+ self,
+ identity: dict[str, object],
+ task_id: str,
+ extra_timeout_secs: float = 30.0,
+ ) -> dict[str, Any]:
+ """恢复对已超时任务的轮询,额外等待 extra_timeout_secs 秒。"""
+ owner = _owner_id(identity)
+ key = _task_key(owner, _clean(task_id))
+ with self._lock:
+ task = self._tasks.get(key)
+ if task is None:
+ raise ValueError("task not found")
+ if task.get("status") != TASK_STATUS_ERROR:
+ raise ValueError("task is not in error state")
+ error_msg = _clean(task.get("error"))
+ if "超时" not in error_msg:
+ raise ValueError("task error is not a timeout error")
+ conversation_id = _clean(task.get("conversation_id"))
+ if not conversation_id:
+ raise ValueError("task has no conversation_id")
+ mode = task.get("mode", "generate")
+ model = task.get("model", "gpt-image-2")
+ # 将任务状态重置为 running
+ self._update_task(key, status=TASK_STATUS_RUNNING, error="")
+
+ # 启动新线程继续轮询
+ thread = threading.Thread(
+ target=self._run_resume_poll,
+ args=(key, conversation_id, extra_timeout_secs, dict(identity), mode, model),
+ name=f"image-resume-{_clean(task_id)[:16]}",
+ daemon=True,
+ )
+ thread.start()
+ return _public_task(task)
+
+ def _run_resume_poll(
+ self,
+ key: str,
+ conversation_id: str,
+ extra_timeout_secs: float,
+ identity: dict[str, object],
+ mode: str,
+ model: str,
+ ) -> None:
+ """后台线程:继续轮询已有 conversation_id 的图片结果。"""
+ started = time.time()
+ try:
+ from services.openai_backend_api import OpenAIBackendAPI
+ from services.protocol.conversation import format_image_result
+
+ backend = OpenAIBackendAPI(proxy_url=config.proxy_url or None)
+ file_ids, sediment_ids = backend._poll_image_results(
+ conversation_id,
+ extra_timeout_secs,
+ )
+ if not file_ids and not sediment_ids:
+ raise RuntimeError(
+ f"继续等待 {extra_timeout_secs} 秒后仍未找到图片结果。"
+ )
+
+ image_urls = backend.resolve_conversation_image_urls(
+ conversation_id, file_ids, sediment_ids, poll=False,
+ )
+ if not image_urls:
+ raise RuntimeError("图片 URL 解析失败")
+
+ image_items = [
+ {"b64_json": __import__("base64").b64encode(image_data).decode("ascii")}
+ for image_data in backend.download_image_bytes(image_urls)
+ ]
+ # 获取 task 的原始 prompt(从 _public_task 的 mode 判断)
+ with self._lock:
+ task = self._tasks.get(key)
+ quality = _clean(task.get("quality"), "auto") if task else "auto"
+ size = _clean(task.get("size")) if task else None
+ data = format_image_result(
+ image_items,
+ "", # prompt 已不重要,结果已经拿到了
+ "b64_json",
+ "",
+ int(time.time()),
+ )["data"]
+ self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="", duration_ms=int((time.time() - started) * 1000))
+ self._log_call(
+ identity,
+ mode,
+ model,
+ started,
+ "调用完成(续轮询)",
+ status="success",
+ urls=_collect_image_urls(data),
+ )
+ except Exception as exc:
+ error_message = str(exc) or "resume poll failed"
+ duration_ms = int((time.time() - started) * 1000)
+ self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[], duration_ms=duration_ms)
+ self._log_call(
+ identity,
+ mode,
+ model,
+ started,
+ "调用失败(续轮询)",
+ status="failed",
+ error=error_message,
+ )
+
+
+image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
diff --git a/services/log_service.py b/services/log_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..a420e1108f649522f1b002d98cb8099670678449
--- /dev/null
+++ b/services/log_service.py
@@ -0,0 +1,337 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import itertools
+import time
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from fastapi import HTTPException
+from fastapi.concurrency import run_in_threadpool
+from fastapi.responses import JSONResponse, StreamingResponse
+
+from services.config import DATA_DIR
+from services.protocol.error_response import anthropic_error_response, openai_error_response
+from utils.helper import anthropic_sse_stream, sse_json_stream
+
+LOG_TYPE_CALL = "call"
+LOG_TYPE_ACCOUNT = "account"
+INTERNAL_RESPONSE_KEYS = {"_account_email", "_conversation_id"}
+
+
+class LogService:
+ def __init__(self, path: Path):
+ self.path = path
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+
+ @staticmethod
+ def _legacy_id(raw_line: str, line_number: int) -> str:
+ payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
+ return hashlib.sha1(payload).hexdigest()[:24]
+
+ def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
+ try:
+ item = json.loads(raw_line)
+ except Exception:
+ return None
+ if not isinstance(item, dict):
+ return None
+ parsed = dict(item)
+ parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
+ return parsed
+
+ @staticmethod
+ def _serialize_item(item: dict[str, Any]) -> str:
+ return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
+
+ @staticmethod
+ def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
+ t = str(item.get("time") or "")
+ day = t[:10]
+ if type and item.get("type") != type:
+ return False
+ if start_date and day < start_date:
+ return False
+ if end_date and day > end_date:
+ return False
+ return True
+
+ def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
+ item = {
+ "id": uuid4().hex,
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "type": type,
+ "summary": summary,
+ "detail": detail or data,
+ }
+ with self.path.open("a", encoding="utf-8") as file:
+ file.write(self._serialize_item(item) + "\n")
+
+ def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
+ if not self.path.exists():
+ return []
+ items: list[dict[str, Any]] = []
+ lines = self.path.read_text(encoding="utf-8").splitlines()
+ for line_number in range(len(lines) - 1, -1, -1):
+ item = self._parse_line(lines[line_number], line_number)
+ if item is None:
+ continue
+ if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
+ continue
+ items.append(item)
+ if len(items) >= limit:
+ break
+ return items
+
+ def delete(self, ids: list[str]) -> dict[str, int]:
+ target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
+ if not self.path.exists() or not target_ids:
+ return {"removed": 0}
+ lines = self.path.read_text(encoding="utf-8").splitlines()
+ kept_lines: list[str] = []
+ removed = 0
+ for line_number, raw_line in enumerate(lines):
+ item = self._parse_line(raw_line, line_number)
+ if item is None:
+ kept_lines.append(raw_line)
+ continue
+ if str(item.get("id") or "") in target_ids:
+ removed += 1
+ continue
+ kept_lines.append(self._serialize_item(item))
+ content = "\n".join(kept_lines)
+ if content:
+ content += "\n"
+ self.path.write_text(content, encoding="utf-8")
+ return {"removed": removed}
+
+
+log_service = LogService(DATA_DIR / "logs.jsonl")
+
+
+def _collect_urls(value: object) -> list[str]:
+ urls: list[str] = []
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if key == "url" and isinstance(item, str):
+ urls.append(item)
+ elif key == "urls" and isinstance(item, list):
+ urls.extend(str(url) for url in item if isinstance(url, str))
+ else:
+ urls.extend(_collect_urls(item))
+ elif isinstance(value, list):
+ for item in value:
+ urls.extend(_collect_urls(item))
+ return urls
+
+
+def _collect_account_emails(value: object) -> list[str]:
+ emails: list[str] = []
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
+ emails.append(item.strip())
+ else:
+ emails.extend(_collect_account_emails(item))
+ elif isinstance(value, list):
+ for item in value:
+ emails.extend(_collect_account_emails(item))
+ return emails
+
+
+def _collect_conversation_ids(value: object) -> list[str]:
+ ids: list[str] = []
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if key == "_conversation_id" and isinstance(item, str) and item.strip():
+ ids.append(item.strip())
+ else:
+ ids.extend(_collect_conversation_ids(item))
+ elif isinstance(value, list):
+ for item in value:
+ ids.extend(_collect_conversation_ids(item))
+ return ids
+
+
+def _strip_internal_response_fields(value: object) -> object:
+ if isinstance(value, dict):
+ return {
+ key: _strip_internal_response_fields(item)
+ for key, item in value.items()
+ if key not in INTERNAL_RESPONSE_KEYS
+ }
+ if isinstance(value, list):
+ return [_strip_internal_response_fields(item) for item in value]
+ return value
+
+
+def _request_excerpt(text: object, limit: int = 1000) -> str:
+ value = str(text or "").strip()
+ if not value:
+ return ""
+ normalized = " ".join(value.split())
+ if len(normalized) <= limit:
+ return normalized
+ return normalized[: limit - 1].rstrip() + "…"
+
+
+def _image_error_response(exc: Exception) -> JSONResponse:
+ from services.protocol.conversation import public_image_error_message
+
+ message = public_image_error_message(str(exc))
+ if "no available image quota" in message.lower():
+ return openai_error_response(
+ {
+ "error": {
+ "message": "no available image quota",
+ "type": "insufficient_quota",
+ "param": None,
+ "code": "insufficient_quota",
+ }
+ },
+ 429,
+ )
+ if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
+ return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
+ return openai_error_response(message, 502)
+
+
+def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
+ message = str(exc)
+ if sse == "anthropic":
+ return anthropic_error_response(message, status_code)
+ return openai_error_response(message, status_code)
+
+
+def _next_item(items):
+ try:
+ return True, next(items)
+ except StopIteration:
+ return False, None
+
+
+@dataclass
+class LoggedCall:
+ identity: dict[str, object]
+ endpoint: str
+ model: str
+ summary: str
+ started: float = field(default_factory=time.time)
+ request_text: str = ""
+ request_shape: dict[str, int] | None = None
+
+ async def run(self, handler, *args, sse: str = "openai"):
+ from services.protocol.conversation import ImageGenerationError
+
+ try:
+ result = await run_in_threadpool(handler, *args)
+ except ImageGenerationError as exc:
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
+ conversation_id=getattr(exc, "conversation_id", ""))
+ return _image_error_response(exc)
+ except HTTPException as exc:
+ self.log("调用失败", status="failed", error=str(exc.detail))
+ raise
+ except Exception as exc:
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
+ if self.endpoint.startswith("/v1/images"):
+ return _image_error_response(exc)
+ return _protocol_error_response(exc, 502, sse)
+
+ if isinstance(result, dict):
+ self.log("调用完成", result)
+ response = dict(result)
+ response.pop("_account_email", None)
+ return response
+
+ sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
+ try:
+ has_first, first = await run_in_threadpool(_next_item, result)
+ except ImageGenerationError as exc:
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
+ conversation_id=getattr(exc, "conversation_id", ""))
+ return _image_error_response(exc)
+ except HTTPException as exc:
+ self.log("调用失败", status="failed", error=str(exc.detail))
+ raise
+ except Exception as exc:
+ self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
+ if self.endpoint.startswith("/v1/images"):
+ return _image_error_response(exc)
+ return _protocol_error_response(exc, 502, sse)
+ if not has_first:
+ self.log("流式调用结束")
+ return StreamingResponse(sender(()), media_type="text/event-stream")
+ return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
+
+ def stream(self, items):
+ urls: list[str] = []
+ account_emails: list[str] = []
+ conversation_ids: list[str] = []
+ failed = False
+ try:
+ for item in items:
+ urls.extend(_collect_urls(item))
+ account_emails.extend(_collect_account_emails(item))
+ conversation_ids.extend(_collect_conversation_ids(item))
+ yield _strip_internal_response_fields(item)
+ except Exception as exc:
+ failed = True
+ self.log(
+ "流式调用失败",
+ status="failed",
+ error=str(exc),
+ urls=urls,
+ account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
+ conversation_id=(conversation_ids[0] if conversation_ids else getattr(exc, "conversation_id", "")),
+ )
+ if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
+ from services.protocol.conversation import ImageGenerationError, public_image_error_message
+
+ raise ImageGenerationError(public_image_error_message(str(exc))) from exc
+ raise
+ finally:
+ if not failed:
+ self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "",
+ conversation_id=conversation_ids[0] if conversation_ids else "")
+
+ def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
+ urls: list[str] | None = None, account_email: str = "", conversation_id: str = "") -> None:
+ detail = {
+ "key_id": self.identity.get("id"),
+ "key_name": self.identity.get("name"),
+ "role": self.identity.get("role"),
+ "endpoint": self.endpoint,
+ "model": self.model,
+ "started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
+ "ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ "duration_ms": int((time.time() - self.started) * 1000),
+ "status": status,
+ }
+ request_excerpt = _request_excerpt(self.request_text)
+ if request_excerpt:
+ detail["request_text"] = request_excerpt
+ if self.request_shape:
+ detail["request_shape"] = self.request_shape
+ if error:
+ detail["error"] = error
+ email = str(account_email or "").strip()
+ if not email:
+ emails = _collect_account_emails(result)
+ email = emails[0] if emails else ""
+ if email:
+ detail["account_email"] = email
+ conv_id = str(conversation_id or "").strip()
+ if not conv_id:
+ conv_ids = _collect_conversation_ids(result)
+ conv_id = conv_ids[0] if conv_ids else ""
+ if conv_id:
+ detail["conversation_id"] = conv_id
+ collected_urls = [*(urls or []), *_collect_urls(result)]
+ if collected_urls and not self.endpoint.startswith("/v1/search"):
+ detail["urls"] = list(dict.fromkeys(collected_urls))
+ log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
diff --git a/services/oauth_login_service.py b/services/oauth_login_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..71dff6b5094ca0b2cd8a8aa9ed38dbe7e372c64c
--- /dev/null
+++ b/services/oauth_login_service.py
@@ -0,0 +1,267 @@
+"""手动 OAuth 桥服务
+
+让用户用自己浏览器走一遍 OpenAI 的标准 OAuth + PKCE 授权码流程:
+ 1. 后端生成 code_verifier / code_challenge / state,构造 authorize URL。
+ 2. 用户在浏览器登录,浏览器最终被 OpenAI 重定向到 platform.openai.com 的
+ callback 地址;用户从地址栏或 devtools 抓出 code,回填到前端。
+ 3. 后端拿之前存好的 code_verifier + 回填的 code 调用 /api/accounts/oauth/token
+ 得到 {access_token, refresh_token, id_token}。
+
+得到的 refresh_token 跟 account_service 自动刷新机制用的 client_id 是同一个
+(app_2SKx67EdpoN0G6j64rFvigXD),所以落盘后能直接进入 keepalive 周期。
+"""
+from __future__ import annotations
+
+import secrets
+import threading
+import time
+import uuid
+from typing import Any
+from urllib.parse import parse_qs, urlencode, urlparse
+
+from curl_cffi import requests
+
+from services.proxy_service import proxy_settings
+from services.register.openai_register import (
+ auth_base,
+ common_headers,
+ platform_auth0_client,
+ platform_base,
+ platform_oauth_audience,
+ platform_oauth_client_id,
+ platform_oauth_redirect_uri,
+ sec_ch_ua,
+ user_agent,
+)
+
+
+class OAuthLoginError(Exception):
+ """OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。"""
+
+
+class OAuthLoginService:
+ """维护 PKCE 临时会话,并完成 code → token 的兑换。"""
+
+ _SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限
+ _MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._sessions: dict[str, dict[str, Any]] = {}
+
+ @staticmethod
+ def _generate_pkce() -> tuple[str, str]:
+ """生成 PKCE code_verifier 与对应的 code_challenge(S256)。"""
+ from utils.pkce import generate_pkce
+ return generate_pkce()
+
+ def _purge_expired_locked(self) -> None:
+ """清理过期或溢出容量的会话,必须在持锁状态下调用。"""
+ now = time.time()
+ expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS]
+ for sid in expired:
+ self._sessions.pop(sid, None)
+ if len(self._sessions) > self._MAX_SESSIONS:
+ ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"])
+ for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]:
+ self._sessions.pop(sid, None)
+
+ def start(self, email_hint: str = "") -> dict[str, str]:
+ """登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。
+
+ state 形如 ".",让 callback URL 自带 session_id,
+ finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。
+ """
+ verifier, challenge = self._generate_pkce()
+ nonce = secrets.token_urlsafe(32)
+ device_id = str(uuid.uuid4())
+ session_id = uuid.uuid4().hex
+ state = f"{session_id}.{secrets.token_urlsafe(16)}"
+
+ params = {
+ "issuer": auth_base,
+ "client_id": platform_oauth_client_id,
+ "audience": platform_oauth_audience,
+ "redirect_uri": platform_oauth_redirect_uri,
+ "device_id": device_id,
+ "screen_hint": "login_or_signup",
+ "max_age": "0",
+ "scope": "openid profile email offline_access",
+ "response_type": "code",
+ "response_mode": "query",
+ "state": state,
+ "nonce": nonce,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "auth0Client": platform_auth0_client,
+ }
+ email_hint = str(email_hint or "").strip()
+ if email_hint:
+ params["login_hint"] = email_hint
+
+ authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
+
+ with self._lock:
+ self._purge_expired_locked()
+ self._sessions[session_id] = {
+ "code_verifier": verifier,
+ "state": state,
+ "created_at": time.time(),
+ "redirect_uri": platform_oauth_redirect_uri,
+ }
+
+ return {
+ "session_id": session_id,
+ "authorize_url": authorize_url,
+ "expires_in": str(self._SESSION_TTL_SECONDS),
+ "redirect_uri_prefix": platform_oauth_redirect_uri,
+ }
+
+ @staticmethod
+ def _extract_code_from_callback(value: str) -> tuple[str, str]:
+ """从 callback URL 或 raw code 中提取 (code, state)。
+
+ 既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL,
+ 也允许只粘 code 本身。
+ """
+ raw = str(value or "").strip()
+ if not raw:
+ return "", ""
+ if raw.startswith("http://") or raw.startswith("https://"):
+ try:
+ parsed = parse_qs(urlparse(raw).query)
+ except Exception as exc:
+ raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc
+ code = str((parsed.get("code") or [""])[0]).strip()
+ state = str((parsed.get("state") or [""])[0]).strip()
+ if not code:
+ err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip()
+ raise OAuthLoginError(err or "callback URL 中没有 code 参数")
+ return code, state
+ # 用户可能直接粘了 code 字符串
+ return raw, ""
+
+ def finish(self, session_id: str, callback: str) -> dict[str, str]:
+ """用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。
+
+ - 优先用 callback URL 自带 state 里的 session_id(更可靠),
+ 找不到才用前端传来的 session_id;
+ - 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code),
+ 只有成功兑换才 pop,便于用户用同一 verifier 重试。
+ """
+ body_sid = str(session_id or "").strip()
+ code, state = self._extract_code_from_callback(callback)
+ if not code:
+ raise OAuthLoginError("缺少 code 或 callback URL")
+
+ # state 里嵌的 session_id 优先级最高
+ state_sid = state.split(".", 1)[0] if state else ""
+ candidate_sids = [sid for sid in (state_sid, body_sid) if sid]
+ if not candidate_sids:
+ raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state")
+
+ with self._lock:
+ self._purge_expired_locked()
+ session = None
+ picked_sid = ""
+ for sid in candidate_sids:
+ cur = self._sessions.get(sid)
+ if cur is not None:
+ session = cur
+ picked_sid = sid
+ break
+ if session is None:
+ raise OAuthLoginError(
+ "OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次"
+ )
+
+ if state and session.get("state") and state != session["state"]:
+ raise OAuthLoginError(
+ "state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。"
+ )
+
+ tokens = self._exchange_code(
+ code,
+ session["code_verifier"],
+ session.get("redirect_uri") or platform_oauth_redirect_uri,
+ )
+ # 仅在成功兑换之后才消耗 session
+ with self._lock:
+ self._sessions.pop(picked_sid, None)
+ return tokens
+
+ @staticmethod
+ def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]:
+ """调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。"""
+ kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False)
+ session = requests.Session(**kwargs)
+ try:
+ response = session.post(
+ f"{auth_base}/api/accounts/oauth/token",
+ headers={
+ **common_headers,
+ "referer": f"{platform_base}/",
+ "origin": platform_base,
+ "auth0-client": platform_auth0_client,
+ "sec-ch-ua": sec_ch_ua,
+ "user-agent": user_agent,
+ },
+ json={
+ "client_id": platform_oauth_client_id,
+ "code_verifier": code_verifier,
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_uri,
+ },
+ timeout=60,
+ )
+ except Exception as exc:
+ raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc
+ finally:
+ session.close()
+
+ try:
+ data = response.json() if response.text else {}
+ except Exception:
+ data = {}
+
+ if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
+ detail = ""
+ if isinstance(data, dict):
+ detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
+ if not detail:
+ try:
+ detail = str(response.text or "")[:300]
+ except Exception:
+ detail = ""
+ # 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到
+ print(
+ f"[oauth-login] /api/accounts/oauth/token rejected: "
+ f"status={response.status_code} detail={detail!r} "
+ f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}",
+ flush=True,
+ )
+ raise OAuthLoginError(
+ f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}"
+ )
+
+ access_token = str(data.get("access_token") or "").strip()
+ refresh_token = str(data.get("refresh_token") or "").strip()
+ id_token = str(data.get("id_token") or "").strip()
+
+ if not access_token:
+ raise OAuthLoginError("OpenAI 返回的 access_token 为空")
+ if not refresh_token:
+ # scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示
+ raise OAuthLoginError(
+ "OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)"
+ )
+
+ return {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "id_token": id_token,
+ }
+
+
+oauth_login_service = OAuthLoginService()
diff --git a/services/openai_backend_api.py b/services/openai_backend_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..90fe75638051574f71e2674b5a3669a68050892b
--- /dev/null
+++ b/services/openai_backend_api.py
@@ -0,0 +1,2606 @@
+import base64
+import json
+import mimetypes
+import os
+import random
+import re
+import time
+
+import urllib.error
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from io import BytesIO
+from pathlib import Path
+from collections.abc import Callable
+from typing import Any, Dict, Iterator, Optional
+from urllib.parse import unquote, urlparse
+
+from curl_cffi import requests
+from PIL import Image
+
+from services.account_service import account_service
+from services.config import config
+from services.proxy_service import proxy_settings
+from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model
+from utils.log import logger
+from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
+from utils.turnstile import solve_turnstile_token
+
+
+class InvalidAccessTokenError(RuntimeError):
+ pass
+
+
+class ImagePollTimeoutError(RuntimeError):
+ pass
+
+
+class ImageContentPolicyError(RuntimeError):
+ """Raised when image generation is blocked by content policy moderation."""
+ pass
+
+
+@dataclass
+class ChatRequirements:
+ """保存一次对话请求所需的 sentinel token。"""
+ token: str
+ proof_token: str = ""
+ turnstile_token: str = ""
+ so_token: str = ""
+ raw_finalize: Optional[Dict[str, Any]] = None
+
+
+DEFAULT_CLIENT_VERSION = "prod-a194cd50d4416d3c0b47c740f206b12ce60f5887"
+DEFAULT_CLIENT_BUILD_NUMBER = "6708908"
+DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
+CODEX_IMAGE_MODEL = "codex-gpt-image-2"
+CODEX_RESPONSES_MODEL = "gpt-5.5"
+SEARCH_MODEL = "gpt-5-5"
+SEARCH_TIMEOUT_SECS = 300.0
+SEARCH_POLL_INTERVAL_SECS = 3.0
+SEARCH_DONE_STATUS = {"finished_successfully", "finished_partial_completion"}
+SEARCH_CONVERSATION_ID_RE = re.compile(r'"conversation_id"\s*:\s*"([^"]+)"')
+SEARCH_URL_RE = re.compile(r"https?://[^\s\"'<>))\]}]+")
+EDITABLE_FILE_MODEL = "gpt-5-5-thinking"
+EDITABLE_FILE_THINKING_EFFORT = "extended"
+EDITABLE_FILE_TIMEOUT_SECS = 1200.0
+EDITABLE_FILE_POLL_INTERVAL_SECS = 5.0
+EDITABLE_FILE_CLIENT_VERSION = "prod-bede35f9dcd856d080e012478f0c1031faa2588e"
+EDITABLE_FILE_CLIENT_BUILD_NUMBER = "6631702"
+EDITABLE_FILE_PSD_OUTPUT_DIR = "data/files/psd"
+EDITABLE_FILE_PPT_OUTPUT_DIR = "data/files/ppt"
+EDITABLE_FILE_PPT_PROMPT = """我需要你根据用户的需求,来制作一个可以编辑的PPT,你可以使用Agent来做,你不要再继续询问用户问题,内容风格、版式、配色、内容结构和页面信息你可以自行补充并直接执行。整体的流程如下:
+1. 用生图的方式,帮我生成一个精美的产品介绍ppt,5-6个页面
+2. 帮我把以上涉及到的所有图像和形状素材拆分成单独png,每个素材单独一张图片,不要有遗漏,让我可以直接在ppt里拼接素材还原,不要文字
+3. 利用以上所有图片和形状素材,帮我还原你第一次生成的展示ppt,我需要是可编辑的ppt格式,主要部分需要你单独还原插入,文字需要可以编辑
+最后只需要给我生成一个PPT文件,以及生成中遇到的各种素材压缩包zip文件就行。"""
+EDITABLE_FILE_PSD_PROMPT = "帮我生成这个图像,把这张海报分成若干图像,包括背景图,每个元素不要改位置,这样子我可以直接在 平时里无需拖动,底色为白色,不要伪透明底。再帮我将以上拆分的图像拼合成一个psd文件,去除白色底,不要改变每个图层的相应位置,保留每个元素所在图层的相应位置,保留每个元素的图层,最后只需要给我输出psd文件,以及每个图层的zip文件"
+EDITABLE_ASSET_POINTER_RE = re.compile(r"(?:file-service|sediment)://([A-Za-z0-9_-]+)")
+EDITABLE_ZIP_MIME_TYPES = {"application/zip", "application/x-zip-compressed"}
+EDITABLE_PSD_MIME_TYPES = {"image/vnd.adobe.photoshop", "application/vnd.adobe.photoshop"}
+EDITABLE_PPT_MIME_TYPES = {
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.ms-powerpoint",
+}
+EDITABLE_PSD_EXPORT_FILE_RE = re.compile(r"(?:sandbox:)?(/mnt/data/[^\s\"'\)\]]+\.(?:psd|zip))", re.IGNORECASE)
+EDITABLE_PPT_EXPORT_FILE_RE = re.compile(r"(?:sandbox:)?(/mnt/data/[^\s\"'\)\]]+\.(?:pptx?|zip))", re.IGNORECASE)
+FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
+FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
+# 真正的图片文件 ID 格式:file_00000000 + 24位十六进制字符(共32字符)
+REAL_IMAGE_FILE_ID_RE = re.compile(r"\bfile_00000000[a-f0-9]{24}\b")
+SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
+IMAGE_POLL_SETTLE_SECS = 2.0
+CODEX_RESPONSES_INSTRUCTIONS = (
+ "Use the image_generation tool to create exactly one image for the user's request. "
+ "Return the generated image result."
+)
+
+# 内容政策违规错误关键词(上游拒绝生成图片的各种表述)
+_CONTENT_POLICY_KEYWORDS = (
+ # 明确的内容政策违规
+ "内容政策", "防护限制", "违反", "moderation", "policy", "blocked",
+ # 拒绝生成类
+ "不能生成", "无法生成", "不能帮助", "无法帮助",
+ # 敏感内容类
+ "裸体", "裸露", "色情", "性内容", "未成年",
+ # 通用拒绝
+ "抱歉,我不能",
+)
+
+
+def _is_content_policy_error(error_msg: str) -> bool:
+ """检查错误消息是否为内容政策违规。"""
+ if not error_msg:
+ return False
+ msg_lower = error_msg.lower()
+ return any(keyword in msg_lower for keyword in _CONTENT_POLICY_KEYWORDS)
+
+
+@dataclass
+class EditableFileArtifact:
+ attachment_id: str = ""
+ file_id: str = ""
+ name: str = ""
+ mime_type: str = ""
+ create_time: float = 0.0
+ author_role: str = ""
+ sandbox_path: str = ""
+ message_id: str = ""
+
+
+@dataclass
+class EditableFileExportResult:
+ conversation_id: str
+ primary_path: Path
+ zip_path: Path
+
+
+class OpenAIBackendAPI:
+ """ChatGPT Web 后端封装。
+
+ 说明:
+ - 传入 `access_token` 时,聊天和模型列表都会走已登录链路
+ 例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
+ - 不传 `access_token` 时,会走未登录链路
+ 例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
+ - `stream_conversation()` 是底层统一流式入口
+ - 协议兼容转换放在 `services.protocol`
+ """
+
+ def __init__(self, access_token: str = "") -> None:
+ """初始化后端客户端。
+
+ 参数:
+ - `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。
+ """
+ self.base_url = "https://chatgpt.com"
+ self.client_version = DEFAULT_CLIENT_VERSION
+ self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER
+ self.access_token = access_token
+ self.account = account_service.get_account(self.access_token) if self.access_token else {}
+ self.account = self.account if isinstance(self.account, dict) else {}
+ self.fp = self._build_fp()
+ self.user_agent = self.fp["user-agent"]
+ self.device_id = self.fp["oai-device-id"]
+ self.session_id = self.fp["oai-session-id"]
+ self.pow_script_sources: list[str] = []
+ self.pow_data_build = ""
+ self.progress_callback: Callable[[str], None] | None = None
+ self.session = requests.Session(**proxy_settings.build_session_kwargs(
+ account=self.account,
+ impersonate=self.fp["impersonate"],
+ verify=True,
+ ))
+ self.session.headers.update({
+ "User-Agent": self.user_agent,
+ "Origin": self.base_url,
+ "Referer": self.base_url + "/",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
+ "Cache-Control": "no-cache",
+ "Pragma": "no-cache",
+ "Priority": "u=1, i",
+ "Sec-Ch-Ua": self.fp["sec-ch-ua"],
+ "Sec-Ch-Ua-Arch": '"x86"',
+ "Sec-Ch-Ua-Bitness": '"64"',
+ "Sec-Ch-Ua-Full-Version": '"143.0.3650.96"',
+ "Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"',
+ "Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"],
+ "Sec-Ch-Ua-Model": '""',
+ "Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"],
+ "Sec-Ch-Ua-Platform-Version": '"19.0.0"',
+ "Sec-Fetch-Dest": "empty",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Site": "same-origin",
+ "OAI-Device-Id": self.device_id,
+ "OAI-Session-Id": self.session_id,
+ "OAI-Language": "zh-CN",
+ "OAI-Client-Version": self.client_version,
+ "OAI-Client-Build-Number": self.client_build_number,
+ })
+ if self.access_token:
+ self.session.headers["Authorization"] = f"Bearer {self.access_token}"
+
+ def _build_fp(self) -> Dict[str, str]:
+ account = self.account
+ raw_fp = account.get("fp")
+ fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {}
+ for key in (
+ "user-agent",
+ "impersonate",
+ "oai-device-id",
+ "oai-session-id",
+ "sec-ch-ua",
+ "sec-ch-ua-mobile",
+ "sec-ch-ua-platform",
+ ):
+ value = str(account.get(key) or "").strip()
+ if value:
+ fp[key] = value
+ fp.setdefault(
+ "user-agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
+ )
+ fp.setdefault("impersonate", "edge101")
+ fp.setdefault("oai-device-id", new_uuid())
+ fp.setdefault("oai-session-id", new_uuid())
+ fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"')
+ fp.setdefault("sec-ch-ua-mobile", "?0")
+ fp.setdefault("sec-ch-ua-platform", '"Windows"')
+ return fp
+
+ def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
+ """构造请求头,并补上 web 端要求的 target path/route。"""
+ headers = dict(self.session.headers)
+ headers["X-OpenAI-Target-Path"] = path
+ headers["X-OpenAI-Target-Route"] = path
+ if extra:
+ headers.update(extra)
+ return headers
+
+ @staticmethod
+ def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
+ for item in limits_progress:
+ if isinstance(item, dict) and item.get("feature_name") == "image_gen":
+ return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False
+ return 0, None, True
+
+ def _raise_on_error(self, response: Any, path: str) -> None:
+ if response.status_code == 401:
+ raise InvalidAccessTokenError(f"token invalidated ({path})")
+ raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
+
+ def _get_me(self) -> Dict[str, Any]:
+ path = "/backend-api/me"
+ response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
+ if response.status_code != 200:
+ self._raise_on_error(response, path)
+ return response.json()
+
+ def _get_conversation_init(self) -> Dict[str, Any]:
+ path = "/backend-api/conversation/init"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Content-Type": "application/json"}),
+ json={
+ "gizmo_id": None,
+ "requested_default_model": None,
+ "conversation_id": None,
+ "timezone_offset_min": -480,
+ },
+ timeout=20,
+ )
+ if response.status_code != 200:
+ self._raise_on_error(response, path)
+ return response.json()
+
+ def _get_default_account(self) -> Dict[str, Any]:
+ path = "/backend-api/accounts/check/v4-2023-04-27"
+ response = self.session.get(self.base_url + path + "?timezone_offset_min=-480", headers=self._headers(path),
+ timeout=20)
+ if response.status_code != 200:
+ self._raise_on_error(response, path)
+ payload = response.json()
+ default_account = ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
+ logger.debug({
+ "event": "backend_user_info_account_payload",
+ "plan_type": default_account.get("plan_type"),
+ "account_user_role": default_account.get("account_user_role"),
+ "account_id": default_account.get("account_id"),
+ "is_deactivated": default_account.get("is_deactivated"),
+ "has_active_subscription": (payload.get("accounts") or {}).get("default", {}).get("entitlement", {}).get("has_active_subscription"),
+ "subscription_plan": (payload.get("accounts") or {}).get("default", {}).get("entitlement", {}).get("subscription_plan"),
+ })
+ return default_account
+
+ def get_user_info(self) -> Dict[str, Any]:
+ """获取当前 token 的账号信息。"""
+ if not self.access_token:
+ raise RuntimeError("access_token is required")
+ executor = ThreadPoolExecutor(max_workers=3)
+ try:
+ me_future = executor.submit(self._get_me)
+ init_future = executor.submit(self._get_conversation_init)
+ account_future = executor.submit(self._get_default_account)
+ me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
+ except (KeyboardInterrupt, SystemExit):
+ executor.shutdown(wait=False, cancel_futures=True)
+ raise
+ except BaseException:
+ executor.shutdown(wait=False, cancel_futures=True)
+ raise
+ else:
+ executor.shutdown(wait=True, cancel_futures=True)
+
+ plan_type = str(default_account.get("plan_type") or "free")
+
+ limits_progress = init_payload.get("limits_progress")
+ limits_progress = limits_progress if isinstance(limits_progress, list) else []
+ quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
+ result = {
+ "email": me_payload.get("email"),
+ "user_id": me_payload.get("id"),
+ "type": plan_type,
+ "quota": quota,
+ "image_quota_unknown": image_quota_unknown,
+ "limits_progress": limits_progress,
+ "default_model_slug": init_payload.get("default_model_slug"),
+ "restore_at": restore_at,
+ "status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"),
+ }
+ logger.debug({
+ "event": "backend_user_info_result",
+ "email": result.get("email"),
+ "user_id": result.get("user_id"),
+ "type": result.get("type"),
+ "quota": result.get("quota"),
+ "image_quota_unknown": result.get("image_quota_unknown"),
+ "default_model_slug": result.get("default_model_slug"),
+ "restore_at": result.get("restore_at"),
+ "status": result.get("status"),
+ })
+ return result
+
+ def _bootstrap_headers(self) -> Dict[str, str]:
+ """构造首页预热请求头。"""
+ return {
+ "User-Agent": self.user_agent,
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"],
+ "Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"],
+ "Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"],
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Upgrade-Insecure-Requests": "1",
+ }
+
+ def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
+ """把 sentinel 响应整理成后续对话需要的 token 集合。"""
+ if (data.get("arkose") or {}).get("required"):
+ raise RuntimeError("chat requirements requires arkose token, which is not implemented")
+
+ proof_token = ""
+ proof_info = data.get("proofofwork") or {}
+ if proof_info.get("required"):
+ proof_token = build_proof_token(
+ proof_info.get("seed", ""),
+ proof_info.get("difficulty", ""),
+ self.user_agent,
+ script_sources=self.pow_script_sources,
+ data_build=self.pow_data_build,
+ )
+
+ turnstile_token = ""
+ turnstile_info = data.get("turnstile") or {}
+ if turnstile_info.get("required") and turnstile_info.get("dx"):
+ turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or ""
+
+ return ChatRequirements(
+ token=data.get("token", ""),
+ proof_token=proof_token,
+ turnstile_token=turnstile_token,
+ so_token=data.get("so_token", ""),
+ raw_finalize=data,
+ )
+
+ def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]:
+ """根据当前 requirements 构造对话 SSE 请求头。"""
+ headers = {
+ "Accept": "text/event-stream",
+ "Content-Type": "application/json",
+ "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
+ }
+ if requirements.proof_token:
+ headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
+ if requirements.turnstile_token:
+ headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token
+ if requirements.so_token:
+ headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token
+ return self._headers(path, headers)
+
+ def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
+ """把标准 chat messages 转成 web conversation 所需的 messages。"""
+ conversation_messages = []
+ for item in messages:
+ role = item.get("role", "user")
+ content = item.get("content", "")
+ if isinstance(content, str):
+ conversation_messages.append({
+ "id": new_uuid(),
+ "author": {"role": role},
+ "content": {"content_type": "text", "parts": [content]},
+ })
+ continue
+ if not isinstance(content, list):
+ raise RuntimeError("only string or list message content is supported")
+ text_parts: list[str] = []
+ image_inputs: list[tuple[bytes, str]] = []
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+ part_type = str(part.get("type") or "")
+ if part_type == "text":
+ text_parts.append(str(part.get("text") or ""))
+ elif part_type == "image":
+ data = part.get("data")
+ mime = str(part.get("mime") or "image/png")
+ if isinstance(data, (bytes, bytearray)):
+ image_inputs.append((bytes(data), mime))
+ if not image_inputs:
+ conversation_messages.append({
+ "id": new_uuid(),
+ "author": {"role": role},
+ "content": {"content_type": "text", "parts": ["".join(text_parts)]},
+ })
+ continue
+ if not self.access_token:
+ raise RuntimeError("authenticated upstream account required for image input")
+ uploaded: list[Dict[str, Any]] = []
+ for idx, (data, mime) in enumerate(image_inputs, start=1):
+ ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png"
+ extension = "jpg" if ext_part == "jpeg" else (ext_part or "png")
+ b64 = base64.b64encode(data).decode("ascii")
+ uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}"))
+ parts: list[Any] = []
+ for ref in uploaded:
+ parts.append({
+ "content_type": "image_asset_pointer",
+ "asset_pointer": f"file-service://{ref['file_id']}",
+ "width": ref["width"],
+ "height": ref["height"],
+ "size_bytes": ref["file_size"],
+ })
+ text = "".join(text_parts)
+ if text:
+ parts.append(text)
+ conversation_messages.append({
+ "id": new_uuid(),
+ "author": {"role": role},
+ "content": {"content_type": "multimodal_text", "parts": parts},
+ "metadata": {
+ "attachments": [{
+ "id": ref["file_id"],
+ "mimeType": ref["mime_type"],
+ "name": ref["file_name"],
+ "size": ref["file_size"],
+ "width": ref["width"],
+ "height": ref["height"],
+ } for ref in uploaded],
+ },
+ })
+ return conversation_messages
+
+ def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]:
+ """把标准 messages 构造成 web 对话请求体。"""
+ return {
+ "action": "next",
+ "messages": self._api_messages_to_conversation_messages(messages),
+ "model": model,
+ "parent_message_id": new_uuid(),
+ "conversation_mode": {"kind": "primary_assistant"},
+ "conversation_origin": None,
+ "force_paragen": False,
+ "force_paragen_model_slug": "",
+ "force_rate_limit": False,
+ "force_use_sse": True,
+ "history_and_training_disabled": True,
+ "reset_rate_limits": False,
+ "suggestions": [],
+ "supported_encodings": [],
+ "system_hints": [],
+ "timezone": timezone,
+ "timezone_offset_min": -480,
+ "variant_purpose": "comparison_implicit",
+ "websocket_request_id": new_uuid(),
+ "client_contextual_info": {
+ "is_dark_mode": False,
+ "time_since_loaded": 120,
+ "page_height": 900,
+ "page_width": 1400,
+ "pixel_ratio": 2,
+ "screen_height": 1440,
+ "screen_width": 2560,
+ },
+ }
+
+ def _image_model_slug(self, model: str) -> str:
+ """把标准图片模型名映射到底层 model slug。"""
+ _, base_model = split_image_model(model)
+ if not base_model:
+ return "auto"
+ if base_model == "gpt-image-2":
+ return "gpt-5-3"
+ if base_model == CODEX_IMAGE_MODEL:
+ return base_model
+ return "auto"
+
+ def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
+ Dict[str, str]:
+ """构造图片链路请求头。"""
+ headers = {
+ "Content-Type": "application/json",
+ "Accept": accept,
+ "OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
+ }
+ if requirements.proof_token:
+ headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
+ if conduit_token:
+ headers["X-Conduit-Token"] = conduit_token
+ if accept == "text/event-stream":
+ headers["X-Oai-Turn-Trace-Id"] = new_uuid()
+ return self._headers(path, headers)
+
+ def _codex_responses_headers(self) -> Dict[str, str]:
+ return {
+ "Authorization": f"Bearer {self.access_token}",
+ "Content-Type": "application/json",
+ }
+
+ def _ensure_codex_source_account(self) -> None:
+ account = account_service.get_account(self.access_token)
+ source_type = str((account or {}).get("source_type") or "web").strip().lower()
+ if source_type != "codex":
+ raise RuntimeError("codex responses endpoint requires a codex source account")
+
+ @staticmethod
+ def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]:
+ content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
+ for image in images:
+ payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}"
+ content.append({"type": "input_image", "image_url": payload})
+ return [{"role": "user", "content": content}]
+
+ @staticmethod
+ def _codex_body_preview(body: Any, limit: int = 4000) -> str:
+ if isinstance(body, (dict, list)):
+ try:
+ text = json.dumps(body, ensure_ascii=False)
+ except Exception:
+ text = repr(body)
+ else:
+ text = str(body or "")
+ return text if len(text) <= limit else text[:limit] + "...[truncated]"
+
+ @staticmethod
+ def _codex_event_image_result_lengths(value: Any) -> list[int]:
+ if isinstance(value, dict):
+ lengths: list[int] = []
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
+ lengths.append(len(value["result"]))
+ for item in value.values():
+ lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
+ return lengths
+ if isinstance(value, list):
+ lengths: list[int] = []
+ for item in value:
+ lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
+ return lengths
+ return []
+
+ @staticmethod
+ def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]:
+ summary: Dict[str, Any] = {
+ "type": str(event.get("type") or ""),
+ "keys": list(event.keys())[:30],
+ }
+ for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"):
+ value = event.get(key)
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ summary[key] = value
+ for key in ("response", "item", "output"):
+ value = event.get(key)
+ if isinstance(value, dict):
+ summary[f"{key}_type"] = value.get("type")
+ summary[f"{key}_status"] = value.get("status")
+ summary[f"{key}_keys"] = list(value.keys())[:30]
+ elif isinstance(value, list):
+ summary[f"{key}_len"] = len(value)
+ summary[f"{key}_types"] = [
+ item.get("type") for item in value[:10] if isinstance(item, dict)
+ ]
+ error = event.get("error")
+ if isinstance(error, dict):
+ summary["error"] = {
+ key: error.get(key)
+ for key in ("type", "code", "message")
+ if error.get(key) is not None
+ }
+ delta = event.get("delta")
+ if isinstance(delta, str):
+ summary["delta_len"] = len(delta)
+ summary["delta_preview"] = delta[:200]
+ result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event)
+ if result_lengths:
+ summary["image_result_lengths"] = result_lengths[:10]
+ return summary
+
+ def _log_codex_response_failure(
+ self,
+ path: str,
+ status_code: int,
+ headers: Any,
+ payload: Dict[str, Any],
+ body: Any,
+ ) -> None:
+ request_headers = self._codex_responses_headers()
+ safe_request_headers = {
+ key: value for key, value in request_headers.items() if key.lower() != "authorization"
+ }
+ response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {})
+ tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
+ logger.warning({
+ "event": "codex_responses_http_error",
+ "path": path,
+ "status_code": status_code,
+ "request": {
+ "model": payload.get("model"),
+ "tool_model": tool.get("model"),
+ "tool_action": tool.get("action"),
+ "size": tool.get("size"),
+ "quality": tool.get("quality"),
+ "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
+ "prompt_preview": self._codex_body_preview(
+ (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
+ 500,
+ ),
+ "headers": safe_request_headers,
+ },
+ "response": {
+ "headers": response_headers,
+ "body_preview": self._codex_body_preview(body),
+ },
+ })
+
+ @staticmethod
+ def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]:
+ content_type = str(raw.headers.get("content-type") or "").lower()
+ text = raw.read().decode("utf-8", "replace")
+ status_code = getattr(raw, "status", None)
+ parse_errors: list[str] = []
+ events: list[Dict[str, Any]] = []
+ if "application/json" in content_type:
+ try:
+ data = json.loads(text)
+ if isinstance(data, dict):
+ events.append(data)
+ except Exception as exc:
+ parse_errors.append(str(exc))
+ else:
+ lines: list[str] = []
+ for line in text.splitlines() + [""]:
+ if not line:
+ if lines:
+ payload_text = "\n".join(lines).strip()
+ if payload_text and payload_text != "[DONE]":
+ try:
+ data = json.loads(payload_text)
+ except Exception as exc:
+ parse_errors.append(str(exc))
+ data = None
+ if isinstance(data, dict):
+ events.append(data)
+ lines = []
+ elif line.startswith("data:"):
+ lines.append(line[5:].lstrip())
+
+ event_types: Dict[str, int] = {}
+ image_result_lengths: list[int] = []
+ for event in events:
+ event_type = str(event.get("type") or "")
+ event_types[event_type] = event_types.get(event_type, 0) + 1
+ image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event))
+ logger.info({
+ "event": "codex_responses_response_debug",
+ "status_code": status_code,
+ "content_type": content_type,
+ "response_text_len": len(text),
+ "event_count": len(events),
+ "event_types": event_types,
+ "image_result_lengths": image_result_lengths[:10],
+ "parse_error_count": len(parse_errors),
+ "parse_errors": parse_errors[:5],
+ "event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]],
+ "event_previews": [
+ OpenAIBackendAPI._codex_body_preview(event, 1500)
+ for event in events[:10]
+ ] if not image_result_lengths else [],
+ "body_preview": text[:1000] if not events else "",
+ })
+ for event in events:
+ yield event
+
+ def iter_codex_image_response_events(
+ self,
+ prompt: str,
+ images: list[str] | None = None,
+ size: str | None = None,
+ quality: str = "auto",
+ ) -> Iterator[Dict[str, Any]]:
+ if not self.access_token:
+ raise RuntimeError("access_token is required for codex image endpoints")
+ self._ensure_codex_source_account()
+ path = "/backend-api/codex/responses"
+ payload = {
+ "model": CODEX_RESPONSES_MODEL,
+ "instructions": CODEX_RESPONSES_INSTRUCTIONS,
+ "store": False,
+ "input": self._codex_image_input(prompt, images or []),
+ "tools": [{
+ "type": "image_generation",
+ "model": "gpt-image-2",
+ "action": "edit" if images else "generate",
+ "size": str(size or "1024x1024"),
+ "quality": str(quality or "auto"),
+ "output_format": "png",
+ }],
+ "tool_choice": {"type": "image_generation"},
+ "stream": True,
+ }
+ request = urllib.request.Request(
+ self.base_url + path,
+ json.dumps(payload).encode(),
+ self._codex_responses_headers(),
+ method="POST",
+ )
+ account = account_service.get_account(self.access_token) or {}
+ token_payload = account_service._decode_jwt_payload(self.access_token)
+ auth_claim = token_payload.get("https://api.openai.com/auth")
+ auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
+ tool = payload["tools"][0]
+ logger.info({
+ "event": "codex_responses_request_debug",
+ "url": self.base_url + path,
+ "transport": "urllib.request",
+ "timeout_secs": 1200,
+ "account_email": str(account.get("email") or "").strip(),
+ "source_type": str(account.get("source_type") or "").strip(),
+ "account_type": str(account.get("type") or "").strip(),
+ "token_claims": {
+ "jti": token_payload.get("jti"),
+ "iat": token_payload.get("iat"),
+ "exp": token_payload.get("exp"),
+ "client_id": token_payload.get("client_id"),
+ "chatgpt_account_id": auth_claim.get("chatgpt_account_id"),
+ "chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"),
+ "localhost": auth_claim.get("localhost"),
+ },
+ "request": {
+ "model": payload.get("model"),
+ "tool_model": tool.get("model"),
+ "tool_action": tool.get("action"),
+ "size": tool.get("size"),
+ "quality": tool.get("quality"),
+ "output_format": tool.get("output_format"),
+ "stream": payload.get("stream"),
+ "image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
+ "prompt_preview": self._codex_body_preview(
+ (((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
+ 500,
+ ),
+ },
+ "headers": {
+ key: value for key, value in self._codex_responses_headers().items()
+ if key.lower() != "authorization"
+ },
+ })
+ try:
+ with urllib.request.urlopen(request, timeout=1200) as raw:
+ yield from self._iter_codex_response_events(raw)
+ except urllib.error.HTTPError as error:
+ body_text = error.read().decode("utf-8", "replace")
+ body: Any = body_text
+ try:
+ body = json.loads(body_text)
+ except Exception:
+ pass
+ self._log_codex_response_failure(path, error.code, error.headers, payload, body)
+ retry_after_header = error.headers.get("Retry-After") if error.headers else None
+ retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
+ raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error
+
+ def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
+ """为图片生成准备 conduit token。"""
+ path = "/backend-api/f/conversation/prepare"
+ payload = {
+ "action": "next",
+ "fork_from_shared_post": False,
+ "parent_message_id": new_uuid(),
+ "model": self._image_model_slug(model),
+ "client_prepare_state": "success",
+ "timezone_offset_min": -480,
+ "timezone": "Asia/Shanghai",
+ "conversation_mode": {"kind": "primary_assistant"},
+ "system_hints": ["picture_v2"],
+ "partial_query": {
+ "id": new_uuid(),
+ "author": {"role": "user"},
+ "content": {"content_type": "text", "parts": [prompt]},
+ },
+ "supports_buffering": True,
+ "supported_encodings": ["v1"],
+ "client_contextual_info": {"app_name": "chatgpt.com"},
+ }
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._image_headers(path, requirements),
+ json=payload,
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ return response.json().get("conduit_token", "")
+
+ def _decode_image_base64(self, image: str) -> bytes:
+ """把 base64 图片字符串或本地路径解码成二进制。"""
+ if (
+ image
+ and len(image) < 512
+ and not image.startswith("data:")
+ and "\n" not in image
+ and "\r" not in image
+ ):
+ file_path = Path(os.path.expanduser(image))
+ if file_path.exists() and file_path.is_file():
+ return file_path.read_bytes()
+ payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
+ return base64.b64decode(payload)
+
+ def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
+ """上传一张 base64 图片,返回底层文件元数据。"""
+ data = self._decode_image_base64(image)
+ if (
+ image
+ and len(image) < 512
+ and not image.startswith("data:")
+ and "\n" not in image
+ and "\r" not in image
+ ):
+ candidate_path = Path(os.path.expanduser(image))
+ if candidate_path.exists() and candidate_path.is_file():
+ file_name = candidate_path.name
+ image = Image.open(BytesIO(data))
+ width, height = image.size
+ mime_type = Image.MIME.get(image.format, "image/png")
+ path = "/backend-api/files"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
+ json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width,
+ "height": height},
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ upload_meta = response.json()
+ response = self.session.put(
+ upload_meta["upload_url"],
+ headers={
+ "Content-Type": mime_type,
+ "x-ms-blob-type": "BlockBlob",
+ "x-ms-version": "2020-04-08",
+ "Origin": self.base_url,
+ "Referer": self.base_url + "/",
+ "User-Agent": self.user_agent,
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "en-US,en;q=0.8",
+ },
+ data=data,
+ timeout=120,
+ )
+ ensure_ok(response, "image_upload")
+ path = f"/backend-api/files/{upload_meta['file_id']}/uploaded"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
+ data="{}",
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ return {
+ "file_id": upload_meta["file_id"],
+ "file_name": file_name,
+ "file_size": len(data),
+ "mime_type": mime_type,
+ "width": width,
+ "height": height,
+ }
+
+ def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str,
+ references: Optional[list[Dict[str, Any]]] = None) -> requests.Response:
+ """启动图片生成或编辑的 SSE 请求。"""
+ references = references or []
+ parts = [{
+ "content_type": "image_asset_pointer",
+ "asset_pointer": f"file-service://{item['file_id']}",
+ "width": item["width"],
+ "height": item["height"],
+ "size_bytes": item["file_size"],
+ } for item in references]
+ parts.append(prompt)
+ content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text",
+ "parts": [prompt]}
+ metadata = {
+ "developer_mode_connector_ids": [],
+ "selected_github_repos": [],
+ "selected_all_github_repos": False,
+ "system_hints": ["picture_v2"],
+ "serialization_metadata": {"custom_symbol_offsets": []},
+ }
+ if references:
+ metadata["attachments"] = [{
+ "id": item["file_id"],
+ "mimeType": item["mime_type"],
+ "name": item["file_name"],
+ "size": item["file_size"],
+ "width": item["width"],
+ "height": item["height"],
+ } for item in references]
+ payload = {
+ "action": "next",
+ "messages": [{
+ "id": new_uuid(),
+ "author": {"role": "user"},
+ "create_time": time.time(),
+ "content": content,
+ "metadata": metadata,
+ }],
+ "parent_message_id": new_uuid(),
+ "model": self._image_model_slug(model),
+ "client_prepare_state": "sent",
+ "timezone_offset_min": -480,
+ "timezone": "Asia/Shanghai",
+ "conversation_mode": {"kind": "primary_assistant"},
+ "enable_message_followups": True,
+ "system_hints": ["picture_v2"],
+ "supports_buffering": True,
+ "supported_encodings": ["v1"],
+ "client_contextual_info": {
+ "is_dark_mode": False,
+ "time_since_loaded": 1200,
+ "page_height": 1072,
+ "page_width": 1724,
+ "pixel_ratio": 1.2,
+ "screen_height": 1440,
+ "screen_width": 2560,
+ "app_name": "chatgpt.com",
+ },
+ "paragen_cot_summary_display_override": "allow",
+ "force_parallel_switch": "auto",
+ }
+ path = "/backend-api/f/conversation"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
+ json=payload,
+ timeout=300,
+ stream=True,
+ )
+ ensure_ok(response, path)
+ return response
+
+ def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
+ """获取完整 conversation 详情。"""
+ path = f"/backend-api/conversation/{conversation_id}"
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
+ timeout=60)
+ ensure_ok(response, path)
+ return response.json()
+
+ def _list_recent_conversations(self, limit: int = 5, timeout_secs: float = 10.0) -> list[Dict[str, Any]]:
+ """列出最近的对话列表,按更新时间倒序。
+
+ 当 SSE 流太短导致 conversation_id 丢失时,可以通过此方法
+ 查找最近创建的对话来恢复 conversation_id。
+ """
+ path = f"/backend-api/conversations?offset=0&limit={limit}&order=updated&conversation_filter=all"
+ try:
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._headers(path, {"Accept": "application/json"}),
+ timeout=timeout_secs,
+ )
+ ensure_ok(response, path)
+ data = response.json()
+ return data.get("items") or data.get("conversations") or []
+ except Exception as exc:
+ logger.debug({"event": "list_conversations_failed", "error": str(exc)})
+ return []
+
+ def find_conversation_by_prompt(self, prompt: str, started_at: float, timeout_secs: float = 10.0) -> str:
+ """根据 prompt 和开始时间,从最近对话列表中查找匹配的 conversation_id。
+
+ 当 SSE 流太短导致 conversation_id 丢失时,使用此方法恢复。
+ 通过对比 prompt 关键词和时间戳来匹配最可能的对话。
+
+ 参数:
+ prompt: 用户输入的 prompt 文本
+ started_at: 请求开始的时间戳(epoch seconds)
+ timeout_secs: 请求超时秒数
+
+ 返回:
+ 匹配的 conversation_id,如果未找到返回空字符串
+ """
+ items = self._list_recent_conversations(limit=10, timeout_secs=timeout_secs)
+ if not items:
+ return ""
+ # 筛选在 started_at 之前或附近创建的对话(最多往前 5 分钟)
+ # ChatGPT 的 updated_at 通常晚于实际请求时间
+ prompt_lower = str(prompt or "").lower().strip()
+ best_match = ""
+ best_score = 0.0
+ for item in items:
+ # item 可能是完整的 conversation 对象或摘要
+ conv_id = str(item.get("id") or item.get("conversation_id") or "")
+ if not conv_id:
+ continue
+ # 检查时间范围:对话的 updated_at 应该在请求开始时间之后(或附近)
+ updated_at = float(item.get("update_time") or item.get("updated_at") or 0)
+ if updated_at and started_at and (updated_at < started_at - 30 or updated_at > started_at + 600):
+ continue
+ # 匹配 prompt 关键词
+ title = str(item.get("title") or "").lower()
+ # 计算匹配分数
+ score = 0.0
+ if prompt_lower and title:
+ # 简单的关键词匹配
+ prompt_words = set(prompt_lower.split())
+ title_words = set(title.split())
+ common = prompt_words & title_words
+ if common:
+ score = len(common) / max(len(prompt_words), 1)
+ # 图生图通常标题为 "Image" 开头
+ if title.startswith("image"):
+ score += 0.3
+ if score > best_score:
+ best_score = score
+ best_match = conv_id
+ if best_match and best_score > 0.1:
+ logger.info({
+ "event": "conversation_prompt_match_found",
+ "conversation_id": best_match,
+ "match_score": round(best_score, 2),
+ })
+ return best_match
+ # 如果没有标题匹配,返回最新的对话(时间最近的)
+ for item in items:
+ conv_id = str(item.get("id") or item.get("conversation_id") or "")
+ updated_at = float(item.get("update_time") or item.get("updated_at") or 0)
+ if conv_id and updated_at and started_at and updated_at >= started_at - 30:
+ logger.info({
+ "event": "conversation_latest_match",
+ "conversation_id": conv_id,
+ "updated_at": updated_at,
+ })
+ return conv_id
+ return ""
+
+ @staticmethod
+ def _editable_prompt(fixed_prompt: str, user_prompt_text: str) -> str:
+ extra = str(user_prompt_text or "").strip()
+ return fixed_prompt if not extra else fixed_prompt + "\n\n以下是用户补充需求,请直接结合执行:\n" + extra
+
+ def export_ppt_zip(
+ self,
+ base64_images: list[str] | None,
+ prompt: str,
+ output_dir: str | Path = EDITABLE_FILE_PPT_OUTPUT_DIR,
+ timeout_secs: float = EDITABLE_FILE_TIMEOUT_SECS,
+ poll_interval_secs: float = EDITABLE_FILE_POLL_INTERVAL_SECS,
+ ) -> EditableFileExportResult:
+ return self._export_editable_file_zip(
+ base64_images or [],
+ self._editable_prompt(EDITABLE_FILE_PPT_PROMPT, prompt),
+ output_dir,
+ primary_label="ppt",
+ primary_suffixes=(".ppt", ".pptx"),
+ primary_mime_types=EDITABLE_PPT_MIME_TYPES,
+ primary_mime_keywords=("presentationml.presentation", "ms-powerpoint"),
+ primary_default_extension=".pptx",
+ export_file_re=EDITABLE_PPT_EXPORT_FILE_RE,
+ timeout_secs=timeout_secs,
+ poll_interval_secs=poll_interval_secs,
+ )
+
+ def export_psd_zip(
+ self,
+ base64_images: list[str],
+ prompt: str,
+ output_dir: str | Path = EDITABLE_FILE_PSD_OUTPUT_DIR,
+ timeout_secs: float = EDITABLE_FILE_TIMEOUT_SECS,
+ poll_interval_secs: float = EDITABLE_FILE_POLL_INTERVAL_SECS,
+ ) -> EditableFileExportResult:
+ if not base64_images:
+ raise ValueError("base64_images is empty")
+ return self._export_editable_file_zip(
+ base64_images,
+ self._editable_prompt(EDITABLE_FILE_PSD_PROMPT, prompt),
+ output_dir,
+ primary_label="psd",
+ primary_suffixes=(".psd",),
+ primary_mime_types=EDITABLE_PSD_MIME_TYPES,
+ primary_mime_keywords=("photoshop",),
+ primary_default_extension=".psd",
+ export_file_re=EDITABLE_PSD_EXPORT_FILE_RE,
+ timeout_secs=timeout_secs,
+ poll_interval_secs=poll_interval_secs,
+ )
+
+ def _export_editable_file_zip(
+ self,
+ base64_images: list[str],
+ prompt: str,
+ output_dir: str | Path,
+ *,
+ primary_label: str,
+ primary_suffixes: tuple[str, ...],
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ primary_default_extension: str,
+ export_file_re: re.Pattern[str],
+ timeout_secs: float,
+ poll_interval_secs: float,
+ ) -> EditableFileExportResult:
+ if not self.access_token:
+ raise RuntimeError("access_token is required for editable file export")
+ self.client_version = EDITABLE_FILE_CLIENT_VERSION
+ self.client_build_number = EDITABLE_FILE_CLIENT_BUILD_NUMBER
+ self.session.headers["OAI-Client-Version"] = EDITABLE_FILE_CLIENT_VERSION
+ self.session.headers["OAI-Client-Build-Number"] = EDITABLE_FILE_CLIENT_BUILD_NUMBER
+ output_path = Path(output_dir).expanduser().resolve()
+ output_path.mkdir(parents=True, exist_ok=True)
+ uploaded = [self._upload_editable_base64_image(item, index) for index, item in enumerate(base64_images, start=1)]
+ conduit_token = self._prepare_editable_conversation(prompt, [item["mime_type"] for item in uploaded])
+ conversation_id = self._run_editable_conversation(prompt, uploaded, conduit_token)
+ artifacts = self._wait_editable_output_artifacts(
+ conversation_id,
+ primary_label,
+ primary_suffixes,
+ primary_mime_types,
+ primary_mime_keywords,
+ export_file_re,
+ timeout_secs,
+ poll_interval_secs,
+ )
+ downloaded = [self._download_editable_artifact(
+ conversation_id,
+ item,
+ output_path,
+ primary_mime_types,
+ primary_mime_keywords,
+ primary_default_extension,
+ ) for item in artifacts]
+ primary_path = next((item for item in downloaded if item.suffix.lower() in primary_suffixes), None)
+ zip_path = next((item for item in downloaded if item.suffix.lower() == ".zip"), None)
+ if not primary_path or not zip_path:
+ raise RuntimeError(f"download finished but did not get both {primary_label} and zip files: {downloaded}")
+ return EditableFileExportResult(conversation_id=conversation_id, primary_path=primary_path, zip_path=zip_path)
+
+ def _upload_editable_base64_image(self, base64_image: str, index: int) -> Dict[str, Any]:
+ data, file_name, mime_type, width, height = self._decode_editable_base64_image(base64_image, index)
+ path = "/backend-api/files"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json"}),
+ json={
+ "file_name": file_name,
+ "file_size": len(data),
+ "use_case": "multimodal",
+ "timezone_offset_min": -480,
+ "reset_rate_limits": False,
+ "store_in_library": True,
+ "library_persistence_mode": "opportunistic",
+ },
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ payload = response.json()
+ upload_url = str(payload.get("upload_url") or "")
+ file_id = str(payload.get("file_id") or "")
+ if not upload_url or not file_id:
+ raise RuntimeError(f"invalid upload response: {payload}")
+ response = self.session.put(
+ upload_url,
+ headers={
+ "Content-Type": mime_type,
+ "x-ms-blob-type": "BlockBlob",
+ "x-ms-version": "2020-04-08",
+ "Origin": self.base_url,
+ "Referer": self.base_url + "/",
+ "User-Agent": self.user_agent,
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "en-US,en;q=0.8",
+ },
+ data=data,
+ timeout=120,
+ )
+ ensure_ok(response, "image_upload")
+ path = f"/backend-api/files/{file_id}/uploaded"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json"}),
+ data="{}",
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ return {
+ "file_id": file_id,
+ "library_file_id": str(payload.get("library_file_id") or ""),
+ "file_name": file_name,
+ "file_size": len(data),
+ "mime_type": mime_type,
+ "width": width,
+ "height": height,
+ }
+
+ def _decode_editable_base64_image(self, base64_image: str, index: int) -> tuple[bytes, str, str, int, int]:
+ raw = str(base64_image or "").strip()
+ if not raw:
+ raise ValueError("base64 image is empty")
+ mime_type = ""
+ payload = raw
+ match = re.match(r"^data:([^;]+);base64,(.*)$", raw, re.IGNORECASE | re.DOTALL)
+ if match:
+ mime_type = str(match.group(1) or "").strip().lower()
+ payload = str(match.group(2) or "").strip()
+ data = base64.b64decode(payload)
+ image = Image.open(BytesIO(data))
+ image.load()
+ width, height = image.size
+ mime_type = Image.MIME.get(image.format, mime_type or "image/png")
+ extension = mimetypes.guess_extension(mime_type) or ".png"
+ return data, f"image_{index}{extension}", mime_type, width, height
+
+ def _prepare_editable_conversation(self, prompt: str, attachment_mime_types: list[str]) -> str:
+ path = "/backend-api/f/conversation/prepare"
+ payload: Dict[str, Any] = {
+ "action": "next",
+ "fork_from_shared_post": False,
+ "parent_message_id": "client-created-root",
+ "model": EDITABLE_FILE_MODEL,
+ "client_prepare_state": "success",
+ "timezone_offset_min": -480,
+ "timezone": "Asia/Shanghai",
+ "conversation_mode": {"kind": "primary_assistant"},
+ "system_hints": [],
+ "partial_query": {"id": new_uuid(), "author": {"role": "user"}, "content": {"content_type": "text", "parts": [prompt]}},
+ "supports_buffering": True,
+ "supported_encodings": ["v1"],
+ "client_contextual_info": {"app_name": "chatgpt.com"},
+ "thinking_effort": EDITABLE_FILE_THINKING_EFFORT,
+ }
+ if attachment_mime_types:
+ payload["attachment_mime_types"] = attachment_mime_types
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json", "X-Conduit-Token": "no-token"}),
+ json=payload,
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ conduit_token = str(response.json().get("conduit_token") or "")
+ if not conduit_token:
+ raise RuntimeError(f"missing conduit_token: {response.text}")
+ return conduit_token
+
+ def _run_editable_conversation(self, prompt: str, uploaded: list[Dict[str, Any]], conduit_token: str) -> str:
+ self._bootstrap()
+ requirements = self._get_chat_requirements()
+ message: Dict[str, Any] = {"id": new_uuid(), "author": {"role": "user"}, "create_time": time.time()}
+ if uploaded:
+ parts = [{
+ "content_type": "image_asset_pointer",
+ "asset_pointer": f"sediment://{item['file_id']}",
+ "size_bytes": item["file_size"],
+ "width": item["width"],
+ "height": item["height"],
+ } for item in uploaded]
+ parts.append(prompt)
+ message["content"] = {"content_type": "multimodal_text", "parts": parts}
+ message["metadata"] = {
+ "attachments": [{
+ "id": item["file_id"],
+ "size": item["file_size"],
+ "name": item["file_name"],
+ "mime_type": item["mime_type"],
+ "width": item["width"],
+ "height": item["height"],
+ "source": "library",
+ "library_file_id": item["library_file_id"],
+ "is_big_paste": False,
+ } for item in uploaded],
+ "developer_mode_connector_ids": [],
+ "selected_sources": [],
+ "selected_github_repos": [],
+ "selected_all_github_repos": False,
+ "serialization_metadata": {"custom_symbol_offsets": []},
+ }
+ else:
+ message["content"] = {"content_type": "text", "parts": [prompt]}
+ path = "/backend-api/f/conversation"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
+ json={
+ "action": "next",
+ "messages": [message],
+ "parent_message_id": "client-created-root",
+ "model": EDITABLE_FILE_MODEL,
+ "client_prepare_state": "sent",
+ "timezone_offset_min": -480,
+ "timezone": "Asia/Shanghai",
+ "conversation_mode": {"kind": "primary_assistant"},
+ "enable_message_followups": True,
+ "system_hints": [],
+ "supports_buffering": True,
+ "supported_encodings": ["v1"],
+ "client_contextual_info": {
+ "is_dark_mode": False,
+ "time_since_loaded": 401,
+ "page_height": 1138,
+ "page_width": 803,
+ "pixel_ratio": 2,
+ "screen_height": 1440,
+ "screen_width": 2560,
+ "app_name": "chatgpt.com",
+ },
+ "paragen_cot_summary_display_override": "allow",
+ "force_parallel_switch": "auto",
+ "thinking_effort": EDITABLE_FILE_THINKING_EFFORT,
+ },
+ timeout=300,
+ stream=True,
+ )
+ ensure_ok(response, path)
+ conversation_id = ""
+ try:
+ for payload in iter_sse_payloads(response):
+ if payload == "[DONE]":
+ break
+ conversation_id = conversation_id or self._find_editable_value(payload, "conversation_id")
+ finally:
+ response.close()
+ if not conversation_id:
+ raise RuntimeError("conversation_id not found in stream")
+ return conversation_id
+
+ def _wait_editable_output_artifacts(
+ self,
+ conversation_id: str,
+ primary_label: str,
+ primary_suffixes: tuple[str, ...],
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ export_file_re: re.Pattern[str],
+ timeout_secs: float,
+ poll_interval_secs: float,
+ ) -> list[EditableFileArtifact]:
+ deadline = time.time() + timeout_secs
+ while time.time() < deadline:
+ try:
+ conversation = self._get_editable_conversation_detail(conversation_id)
+ except UpstreamHTTPError as exc:
+ if exc.status_code in {404, 409, 423, 429, 500, 502, 503, 504}:
+ time.sleep(poll_interval_secs)
+ continue
+ raise
+ targeted = self._pick_editable_target_artifacts(
+ self._extract_editable_artifacts(conversation, export_file_re),
+ primary_suffixes,
+ primary_mime_types,
+ primary_mime_keywords,
+ )
+ if targeted:
+ return targeted
+ time.sleep(poll_interval_secs)
+ raise RuntimeError(f"timed out waiting for {primary_label}/zip outputs")
+
+ def _get_editable_conversation_detail(self, conversation_id: str) -> Dict[str, Any]:
+ path = f"/backend-api/conversation/{conversation_id}"
+ response = self.session.get(self.base_url + path, headers=self._editable_conversation_document_headers(path, conversation_id), timeout=60)
+ ensure_ok(response, path)
+ return response.json()
+
+ def _editable_browser_headers(self, path: str, conversation_id: str) -> Dict[str, str]:
+ headers = self._headers(path, {"Accept": "*/*"})
+ headers["Referer"] = f"{self.base_url}/c/{conversation_id}"
+ return headers
+
+ def _editable_conversation_document_headers(self, path: str, conversation_id: str) -> Dict[str, str]:
+ headers = self._editable_browser_headers(path, conversation_id)
+ headers["X-OpenAI-Target-Route"] = "/backend-api/conversation/{conversation_id}"
+ return headers
+
+ def _extract_editable_artifacts(self, conversation: Dict[str, Any], export_file_re: re.Pattern[str]) -> list[EditableFileArtifact]:
+ artifacts: dict[str, EditableFileArtifact] = {}
+ for node in sorted((conversation.get("mapping") or {}).values(), key=lambda item: float(((item or {}).get("message") or {}).get("create_time") or 0.0)):
+ message = (node or {}).get("message") or {}
+ message_id = str(message.get("id") or "")
+ author_role = str(((message.get("author") or {}).get("role") or "")).strip()
+ if author_role not in {"assistant", "tool"}:
+ continue
+ create_time = float(message.get("create_time") or 0.0)
+ message_text = self._editable_message_text(message)
+ for artifact in self._extract_editable_message_artifacts(message, message_id, author_role, create_time, export_file_re):
+ key = artifact.attachment_id or artifact.file_id or artifact.name or artifact.sandbox_path
+ if key:
+ artifacts[key] = self._merge_editable_artifact(artifacts.get(key), artifact)
+ for export_path in self._extract_editable_export_paths(message_text, export_file_re):
+ inferred = EditableFileArtifact(name=Path(export_path).name, create_time=create_time, author_role=author_role, sandbox_path=export_path, message_id=message_id)
+ artifacts[export_path] = self._merge_editable_artifact(artifacts.get(export_path), inferred)
+ return sorted(artifacts.values(), key=lambda item: item.create_time)
+
+ def _extract_editable_message_artifacts(
+ self,
+ message: Dict[str, Any],
+ message_id: str,
+ author_role: str,
+ create_time: float,
+ export_file_re: re.Pattern[str],
+ ) -> list[EditableFileArtifact]:
+ artifacts: list[EditableFileArtifact] = []
+ for item in (message.get("metadata") or {}).get("attachments") or []:
+ artifact = self._editable_artifact_from_dict(item, message_id, author_role, create_time, export_file_re)
+ if artifact:
+ artifacts.append(artifact)
+ for obj in self._walk_search_dicts(message):
+ artifact = self._editable_artifact_from_dict(obj, message_id, author_role, create_time, export_file_re)
+ if artifact:
+ artifacts.append(artifact)
+ return artifacts
+
+ def _editable_artifact_from_dict(
+ self,
+ payload: Dict[str, Any],
+ message_id: str,
+ author_role: str,
+ create_time: float,
+ export_file_re: re.Pattern[str],
+ ) -> EditableFileArtifact | None:
+ if not ({"id", "file_id", "asset_pointer", "name", "file_name", "filename", "mime_type", "mimeType"} & set(payload.keys())):
+ return None
+ attachment_id = self._match_editable_file_id(str(payload.get("id") or ""))
+ file_id = self._match_editable_file_id(str(payload.get("file_id") or ""))
+ name = self._sanitize_editable_filename(str(payload.get("name") or payload.get("file_name") or payload.get("filename") or payload.get("title") or "").strip())
+ mime_type = self._clean_editable_mime_type(payload.get("mime_type") or payload.get("mimeType") or "")
+ for asset_id in EDITABLE_ASSET_POINTER_RE.findall(str(payload.get("asset_pointer") or "")):
+ attachment_id = attachment_id or asset_id
+ file_id = file_id or asset_id
+ if not attachment_id or not file_id:
+ ids = self._extract_editable_file_ids(json.dumps(payload, ensure_ascii=False))
+ attachment_id = attachment_id or (ids[0] if ids else "")
+ file_id = file_id or (ids[0] if ids else "")
+ if not attachment_id and not file_id:
+ return None
+ return EditableFileArtifact(
+ attachment_id=attachment_id,
+ file_id=file_id,
+ name=name,
+ mime_type=mime_type,
+ create_time=create_time,
+ author_role=author_role,
+ sandbox_path=(self._extract_editable_export_paths(payload, export_file_re) or [""])[0],
+ message_id=message_id,
+ )
+
+ def _pick_editable_target_artifacts(
+ self,
+ artifacts: list[EditableFileArtifact],
+ primary_suffixes: tuple[str, ...],
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ ) -> list[EditableFileArtifact]:
+ primary = next((item for item in reversed(artifacts) if self._looks_like_editable_primary(item, primary_suffixes, primary_mime_types, primary_mime_keywords)), None)
+ zip_item = next((item for item in reversed(artifacts) if self._looks_like_editable_zip(item)), None)
+ return [primary, zip_item] if primary and zip_item else []
+
+ def _download_editable_artifact(
+ self,
+ conversation_id: str,
+ artifact: EditableFileArtifact,
+ output_dir: Path,
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ primary_default_extension: str,
+ ) -> Path:
+ download_url = self._resolve_editable_download_url(conversation_id, artifact)
+ if not download_url:
+ raise RuntimeError(f"download url not found for artifact: {artifact}")
+ response = self.session.get(download_url, timeout=300)
+ ensure_ok(response, "artifact_download")
+ content_type = self._clean_editable_mime_type(response.headers.get("Content-Type") or artifact.mime_type)
+ file_name = self._resolve_editable_output_name(artifact, response.url, response.headers.get("Content-Disposition"), content_type, primary_mime_types, primary_mime_keywords, primary_default_extension)
+ target_path = self._unique_editable_path(output_dir / file_name)
+ target_path.write_bytes(response.content)
+ return target_path
+
+ def _resolve_editable_download_url(self, conversation_id: str, artifact: EditableFileArtifact) -> str:
+ ids: list[str] = []
+ for item in (artifact.attachment_id, artifact.file_id):
+ if item and item not in ids:
+ ids.append(item)
+ if artifact.sandbox_path and artifact.message_id:
+ path = f"/backend-api/conversation/{conversation_id}/interpreter/download"
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._editable_download_headers(path, conversation_id, "/backend-api/conversation/{conversation_id}/interpreter/download"),
+ params={"message_id": artifact.message_id, "sandbox_path": artifact.sandbox_path},
+ timeout=60,
+ )
+ if 200 <= response.status_code < 300:
+ url = self._download_url_from_response(response)
+ if url:
+ return url
+ for attachment_id in ids:
+ path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._editable_download_headers(path, conversation_id, "/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"),
+ timeout=60,
+ )
+ if 200 <= response.status_code < 300:
+ url = self._download_url_from_response(response)
+ if url:
+ return url
+ for file_id in ids:
+ path = f"/backend-api/files/download/{file_id}"
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._editable_download_headers(path, conversation_id, "/backend-api/files/download/{file_id}"),
+ params={"post_id": "", "inline": "false"},
+ timeout=60,
+ )
+ if 200 <= response.status_code < 300:
+ url = self._download_url_from_response(response)
+ if url:
+ return url
+ for file_id in ids:
+ path = f"/backend-api/files/{file_id}/download"
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._editable_download_headers(path, conversation_id, "/backend-api/files/download/{file_id}"),
+ timeout=60,
+ )
+ if 200 <= response.status_code < 300:
+ url = self._download_url_from_response(response)
+ if url:
+ return url
+ return ""
+
+ def _editable_download_headers(self, path: str, conversation_id: str, route: str) -> Dict[str, str]:
+ headers = self._editable_browser_headers(path, conversation_id)
+ headers["X-OpenAI-Target-Route"] = route
+ return headers
+
+ @staticmethod
+ def _download_url_from_response(response: Any) -> str:
+ try:
+ payload = response.json()
+ except Exception:
+ payload = {}
+ return str(payload.get("download_url") or payload.get("url") or "")
+
+ def _resolve_editable_output_name(
+ self,
+ artifact: EditableFileArtifact,
+ final_url: str,
+ content_disposition: str | None,
+ content_type: str,
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ primary_default_extension: str,
+ ) -> str:
+ file_name = self._sanitize_editable_filename(artifact.name)
+ if not file_name and artifact.sandbox_path:
+ file_name = self._sanitize_editable_filename(Path(artifact.sandbox_path).name)
+ if not file_name:
+ file_name = self._sanitize_editable_filename(self._editable_filename_from_content_disposition(content_disposition or ""))
+ if not file_name:
+ file_name = self._sanitize_editable_filename(Path(urlparse(final_url).path).name)
+ extension = self._editable_extension_from_mime_type(content_type, primary_mime_types, primary_mime_keywords, primary_default_extension)
+ return f"artifact{extension}" if not file_name else (file_name if Path(file_name).suffix else file_name + extension)
+
+ def _find_editable_value(self, payload: Any, key: str) -> str:
+ if isinstance(payload, str):
+ match = SEARCH_CONVERSATION_ID_RE.search(payload) if key == "conversation_id" else None
+ if match:
+ return match.group(1)
+ try:
+ payload = json.loads(payload)
+ except json.JSONDecodeError:
+ return ""
+ if isinstance(payload, dict):
+ value = payload.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return next((found for item in payload.values() if (found := self._find_editable_value(item, key))), "")
+ if isinstance(payload, list):
+ return next((found for item in payload if (found := self._find_editable_value(item, key))), "")
+ return ""
+
+ def _extract_editable_file_ids(self, text: str) -> list[str]:
+ values: list[str] = []
+ for item in EDITABLE_ASSET_POINTER_RE.findall(text):
+ if item not in values:
+ values.append(item)
+ for item in FILE_ID_RE.findall(text):
+ if item not in values:
+ values.append(item)
+ return values
+
+ @staticmethod
+ def _match_editable_file_id(value: str) -> str:
+ match = FILE_ID_RE.search(value)
+ return match.group(1) if match else ""
+
+ @staticmethod
+ def _clean_editable_mime_type(value: Any) -> str:
+ text = str(value or "").strip().lower()
+ return text.split(";", 1)[0] if "/" in text else ""
+
+ def _looks_like_editable_primary(
+ self,
+ artifact: EditableFileArtifact,
+ primary_suffixes: tuple[str, ...],
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ ) -> bool:
+ path, name, mime = artifact.sandbox_path.lower(), artifact.name.lower(), artifact.mime_type
+ return name.endswith(primary_suffixes) or path.endswith(primary_suffixes) or mime in primary_mime_types or any(keyword in mime for keyword in primary_mime_keywords)
+
+ @staticmethod
+ def _looks_like_editable_zip(artifact: EditableFileArtifact) -> bool:
+ path, name, mime = artifact.sandbox_path.lower(), artifact.name.lower(), artifact.mime_type
+ return name.endswith(".zip") or path.endswith(".zip") or mime in EDITABLE_ZIP_MIME_TYPES or mime.endswith("/zip")
+
+ @staticmethod
+ def _editable_extension_from_mime_type(
+ mime_type: str,
+ primary_mime_types: set[str],
+ primary_mime_keywords: tuple[str, ...],
+ primary_default_extension: str,
+ ) -> str:
+ if mime_type in primary_mime_types or any(keyword in mime_type for keyword in primary_mime_keywords):
+ return primary_default_extension
+ if mime_type in EDITABLE_ZIP_MIME_TYPES or mime_type.endswith("/zip"):
+ return ".zip"
+ return mimetypes.guess_extension(mime_type) or ""
+
+ @staticmethod
+ def _editable_filename_from_content_disposition(content_disposition: str) -> str:
+ extended_match = re.search(r"filename\*=UTF-8''([^;]+)", content_disposition, re.IGNORECASE)
+ if extended_match:
+ return unquote(extended_match.group(1)).strip()
+ plain_match = re.search(r'filename="([^"]+)"', content_disposition, re.IGNORECASE)
+ return plain_match.group(1).strip() if plain_match else ""
+
+ @staticmethod
+ def _sanitize_editable_filename(value: str) -> str:
+ return Path(str(value or "").strip()).name.replace("\x00", "").strip()
+
+ @staticmethod
+ def _unique_editable_path(path: Path) -> Path:
+ if not path.exists():
+ return path
+ for index in range(1, 1000):
+ candidate = path.with_name(f"{path.stem}_{index}{path.suffix}")
+ if not candidate.exists():
+ return candidate
+ raise RuntimeError(f"failed to allocate output path for {path}")
+
+ @staticmethod
+ def _merge_editable_artifact(current: EditableFileArtifact | None, latest: EditableFileArtifact) -> EditableFileArtifact:
+ if current is None:
+ return latest
+ return EditableFileArtifact(
+ attachment_id=latest.attachment_id or current.attachment_id,
+ file_id=latest.file_id or current.file_id,
+ name=latest.name or current.name,
+ mime_type=latest.mime_type or current.mime_type,
+ create_time=max(current.create_time, latest.create_time),
+ author_role=latest.author_role or current.author_role,
+ sandbox_path=latest.sandbox_path or current.sandbox_path,
+ message_id=latest.message_id or current.message_id,
+ )
+
+ @staticmethod
+ def _editable_message_text(message: Any) -> str:
+ if not isinstance(message, dict):
+ return ""
+ content = message.get("content") or {}
+ parts: list[str] = []
+ if isinstance(content, dict):
+ if isinstance(content.get("text"), str):
+ parts.append(content["text"])
+ for part in content.get("parts") or []:
+ if isinstance(part, str):
+ parts.append(part)
+ elif isinstance(part, dict):
+ parts.extend(str(part.get(key) or "") for key in ("text", "asset_pointer", "model_set_context") if part.get(key))
+ if isinstance(message.get("content"), str):
+ parts.append(str(message["content"]))
+ return "\n".join(part for part in parts if part).strip()
+
+ @staticmethod
+ def _extract_editable_export_paths(payload: Any, export_file_re: re.Pattern[str]) -> list[str]:
+ if isinstance(payload, str):
+ text = payload
+ else:
+ try:
+ text = json.dumps(payload, ensure_ascii=False)
+ except Exception:
+ text = str(payload)
+ values: list[str] = []
+ for item in export_file_re.findall(text):
+ path = str(item or "").strip()
+ if path and path not in values:
+ values.append(path)
+ return values
+
+ def search(self, prompt: str, model: str = SEARCH_MODEL, timeout_secs: float = SEARCH_TIMEOUT_SECS,
+ poll_interval_secs: float = SEARCH_POLL_INTERVAL_SECS) -> Dict[str, Any]:
+ if not self.access_token:
+ raise RuntimeError("access_token is required for search")
+ conduit_token = self._prepare_search_conversation(prompt, model)
+ self._bootstrap()
+ conversation_id = self._run_search_conversation(prompt, conduit_token, model)
+ return self._wait_search_result(conversation_id, timeout_secs, poll_interval_secs)
+
+ def _prepare_search_conversation(self, prompt: str, model: str) -> str:
+ path = "/backend-api/f/conversation/prepare"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Accept": "*/*", "Content-Type": "application/json", "X-Conduit-Token": "no-token"}),
+ json={
+ "action": "next",
+ "fork_from_shared_post": False,
+ "parent_message_id": "client-created-root",
+ "model": model,
+ "client_prepare_state": "success",
+ "timezone_offset_min": -480,
+ "timezone": "Asia/Shanghai",
+ "conversation_mode": {"kind": "primary_assistant"},
+ "system_hints": ["search"],
+ "partial_query": {"id": new_uuid(), "author": {"role": "user"}, "content": {"content_type": "text", "parts": [prompt]}},
+ "supports_buffering": True,
+ "supported_encodings": ["v1"],
+ "client_contextual_info": {"app_name": "chatgpt.com"},
+ },
+ timeout=60,
+ )
+ ensure_ok(response, path)
+ token = str(response.json().get("conduit_token") or "")
+ if not token:
+ raise RuntimeError("missing conduit_token")
+ return token
+
+ def _run_search_conversation(self, prompt: str, conduit_token: str, model: str) -> str:
+ requirements = self._get_chat_requirements()
+ path = "/backend-api/f/conversation"
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
+ json={
+ "action": "next",
+ "messages": [{
+ "id": new_uuid(),
+ "author": {"role": "user"},
+ "create_time": time.time(),
+ "content": {"content_type": "text", "parts": [prompt]},
+ "metadata": {
+ "developer_mode_connector_ids": [],
+ "selected_github_repos": [],
+ "selected_all_github_repos": False,
+ "system_hints": ["search"],
+ "serialization_metadata": {"custom_symbol_offsets": []},
+ },
+ }],
+ "parent_message_id": "client-created-root",
+ "model": model,
+ "client_prepare_state": "success",
+ "timezone_offset_min": -480,
+ "timezone": "Asia/Shanghai",
+ "conversation_mode": {"kind": "primary_assistant"},
+ "enable_message_followups": True,
+ "system_hints": [],
+ "supports_buffering": True,
+ "supported_encodings": ["v1"],
+ "force_use_search": True,
+ "client_reported_search_source": "conversation_composer_web_icon",
+ "client_contextual_info": {"is_dark_mode": False, "time_since_loaded": 36, "page_height": 925, "page_width": 886, "pixel_ratio": 2, "screen_height": 1440, "screen_width": 2560, "app_name": "chatgpt.com"},
+ "paragen_cot_summary_display_override": "allow",
+ "force_parallel_switch": "auto",
+ },
+ timeout=300,
+ stream=True,
+ )
+ ensure_ok(response, path)
+ conversation_id = ""
+ try:
+ for payload in iter_sse_payloads(response):
+ conversation_id = conversation_id or self._find_search_value(payload, "conversation_id")
+ if payload == "[DONE]":
+ break
+ finally:
+ response.close()
+ if not conversation_id:
+ raise RuntimeError("conversation_id not found in stream")
+ return conversation_id
+
+ def _wait_search_result(self, conversation_id: str, timeout_secs: float, poll_interval_secs: float) -> Dict[str, Any]:
+ deadline = time.time() + timeout_secs
+ last_result: Dict[str, Any] | None = None
+ last_answer = ""
+ stable_hits = 0
+ while time.time() < deadline:
+ try:
+ last_result = self._extract_search_result(conversation_id, self._get_search_conversation(conversation_id))
+ except UpstreamHTTPError as exc:
+ if exc.status_code not in {404, 409, 423, 429, 500, 502, 503, 504}:
+ raise
+ if last_result and last_result.get("answer"):
+ if last_result.get("status") in SEARCH_DONE_STATUS:
+ return last_result
+ answer = str(last_result.get("answer") or "")
+ stable_hits = stable_hits + 1 if answer == last_answer else 0
+ last_answer = answer
+ if stable_hits >= 2:
+ return last_result
+ time.sleep(poll_interval_secs)
+ if last_result:
+ return last_result
+ raise RuntimeError(f"timed out waiting for search result: {conversation_id}")
+
+ def _get_search_conversation(self, conversation_id: str) -> Dict[str, Any]:
+ path = f"/backend-api/conversation/{conversation_id}"
+ headers = self._headers(path, {"Accept": "*/*"})
+ headers["Referer"] = f"{self.base_url}/c/{conversation_id}"
+ headers["X-OpenAI-Target-Route"] = "/backend-api/conversation/{conversation_id}"
+ response = self.session.get(self.base_url + path, headers=headers, timeout=60)
+ ensure_ok(response, path)
+ return response.json()
+
+ def _extract_search_result(self, conversation_id: str, conversation: Dict[str, Any]) -> Dict[str, Any]:
+ messages = []
+ for node in (conversation.get("mapping") or {}).values():
+ message = (node or {}).get("message") or {}
+ if ((message.get("author") or {}).get("role") or "") == "assistant":
+ messages.append(message)
+ message = max(messages, key=lambda item: float(item.get("create_time") or 0.0)) if messages else {}
+ metadata = message.get("metadata") if isinstance(message.get("metadata"), dict) else {}
+ finish_details = metadata.get("finish_details") if isinstance(metadata.get("finish_details"), dict) else {}
+ answer = self._search_message_text(message)
+ sources = self._extract_search_sources(message)
+ for url in SEARCH_URL_RE.findall(answer):
+ url = self._clean_search_url(url)
+ if url and all(item["url"] != url for item in sources):
+ sources.append({"title": "", "url": url, "snippet": "", "source_type": ""})
+ return {
+ "conversation_id": conversation_id,
+ "status": str(finish_details.get("type") or metadata.get("status") or self._find_search_value(message, "status") or "").strip(),
+ "answer": answer,
+ "sources": sources,
+ "assistant_message_id": str(message.get("id") or ""),
+ "create_time": float(message.get("create_time") or 0.0),
+ }
+
+ def _extract_search_sources(self, payload: Any) -> list[Dict[str, str]]:
+ sources: list[Dict[str, str]] = []
+ for obj in self._walk_search_dicts(payload):
+ metadata = obj.get("metadata") if isinstance(obj.get("metadata"), dict) else {}
+ url = self._clean_search_url(obj.get("url") or obj.get("link") or obj.get("source_url") or metadata.get("url"))
+ if url and all(item["url"] != url for item in sources):
+ sources.append({
+ "title": str(obj.get("title") or obj.get("name") or obj.get("source") or "").strip(),
+ "url": url,
+ "snippet": str(obj.get("snippet") or obj.get("text") or obj.get("description") or "").strip(),
+ "source_type": str(obj.get("type") or obj.get("source_type") or "").strip(),
+ })
+ return sources
+
+ def _search_message_text(self, message: Any) -> str:
+ content = message.get("content") if isinstance(message, dict) else {}
+ parts = []
+ if isinstance(content, dict):
+ if isinstance(content.get("text"), str):
+ parts.append(content["text"])
+ for part in content.get("parts") or []:
+ if isinstance(part, str):
+ parts.append(part)
+ elif isinstance(part, dict):
+ parts.extend(str(part.get(key) or "") for key in ("text", "summary", "content") if part.get(key))
+ elif isinstance(content, str):
+ parts.append(content)
+ return "\n".join(part.strip() for part in parts if str(part).strip()).strip()
+
+ def _find_search_value(self, payload: Any, key: str) -> str:
+ if isinstance(payload, str):
+ match = SEARCH_CONVERSATION_ID_RE.search(payload) if key == "conversation_id" else None
+ if match:
+ return match.group(1)
+ try:
+ payload = json.loads(payload)
+ except json.JSONDecodeError:
+ return ""
+ if isinstance(payload, dict):
+ value = payload.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return next((found for item in payload.values() if (found := self._find_search_value(item, key))), "")
+ if isinstance(payload, list):
+ return next((found for item in payload if (found := self._find_search_value(item, key))), "")
+ return ""
+
+ def _walk_search_dicts(self, payload: Any) -> list[Dict[str, Any]]:
+ if isinstance(payload, dict):
+ return [payload, *(item for value in payload.values() for item in self._walk_search_dicts(value))]
+ if isinstance(payload, list):
+ return [item for value in payload for item in self._walk_search_dicts(value)]
+ return []
+
+ def _clean_search_url(self, value: Any) -> str:
+ return str(value or "").strip().rstrip(".,;,。;")
+
+ @staticmethod
+ def _add_unique(values: list[str], candidates: list[str]) -> None:
+ for candidate in candidates:
+ if candidate and candidate not in values:
+ values.append(candidate)
+
+ @classmethod
+ def _extract_image_reference_ids(cls, payload: Any) -> tuple[list[str], list[str]]:
+ file_ids: list[str] = []
+ sediment_ids: list[str] = []
+
+ def walk(value: Any) -> None:
+ if isinstance(value, str):
+ # 只提取真正的图片文件 ID(file_00000000... 格式)和 file-service:// URI
+ cls._add_unique(file_ids, FILE_SERVICE_ID_RE.findall(value))
+ cls._add_unique(file_ids, REAL_IMAGE_FILE_ID_RE.findall(value))
+ cls._add_unique(sediment_ids, SEDIMENT_ID_RE.findall(value))
+ return
+ if isinstance(value, dict):
+ for item in value.values():
+ walk(item)
+ return
+ if isinstance(value, list):
+ for item in value:
+ walk(item)
+
+ walk(payload)
+ return file_ids, sediment_ids
+
+ @classmethod
+ def _has_image_asset_pointer(cls, payload: Any) -> bool:
+ if isinstance(payload, dict):
+ if str(payload.get("content_type") or "") == "image_asset_pointer":
+ return True
+ asset_pointer = str(payload.get("asset_pointer") or "")
+ if asset_pointer.startswith(("file-service://", "sediment://")):
+ return True
+ return any(cls._has_image_asset_pointer(item) for item in payload.values())
+ if isinstance(payload, list):
+ return any(cls._has_image_asset_pointer(item) for item in payload)
+ return False
+
+ def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
+ """从 conversation 明细里提取图片工具输出记录。"""
+ mapping = data.get("mapping") or {}
+ records = []
+ for message_id, node in mapping.items():
+ message = (node or {}).get("message") or {}
+ author = message.get("author") or {}
+ metadata = message.get("metadata") or {}
+ content = message.get("content") or {}
+ role = str(author.get("role") or "").strip().lower()
+ if role not in {"tool", "assistant"}:
+ continue
+ is_image_gen = metadata.get("async_task_type") == "image_gen"
+ has_asset_pointer = self._has_image_asset_pointer(content) or self._has_image_asset_pointer(metadata)
+ if role == "assistant" and not (is_image_gen or has_asset_pointer):
+ continue
+ file_ids, sediment_ids = self._extract_image_reference_ids({"content": content, "metadata": metadata})
+ if not is_image_gen and not has_asset_pointer and not file_ids and not sediment_ids:
+ continue
+ records.append(
+ {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
+ "sediment_ids": sediment_ids})
+ return sorted(records, key=lambda item: item["create_time"])
+
+ @staticmethod
+ def _find_content_policy_error_in_conversation(data: Dict[str, Any]) -> str:
+ """从对话文档中查找内容政策违规错误消息。
+
+ 上游拒绝生成图片时,错误消息会出现在 assistant 消息的文本中。
+ 本方法遍历所有 assistant/tool 消息,检查是否包含内容政策违规关键词,
+ 如果匹配则返回该消息文本(截断至 500 字符),否则返回空字符串。
+ """
+ mapping = data.get("mapping") or {}
+ for node in mapping.values():
+ message = (node or {}).get("message") or {}
+ author = message.get("author") or {}
+ role = str(author.get("role") or "").strip().lower()
+ if role not in {"assistant", "tool"}:
+ continue
+ content = message.get("content") or {}
+ # 提取消息文本
+ text_parts: list[str] = []
+ if isinstance(content, dict):
+ msg_parts = content.get("parts") or []
+ if isinstance(msg_parts, list):
+ for part in msg_parts:
+ if isinstance(part, str) and part.strip():
+ text_parts.append(part.strip())
+ text_field = str(content.get("text") or "")
+ if text_field.strip():
+ text_parts.append(text_field.strip())
+ elif isinstance(content, str) and content.strip():
+ text_parts.append(content.strip())
+ msg_text = "\n".join(text_parts)
+ if msg_text and _is_content_policy_error(msg_text):
+ return msg_text[:500]
+ return ""
+
+ def _poll_image_results(
+ self,
+ conversation_id: str,
+ timeout_secs: float = 120.0,
+ initial_file_ids: list[str] | None = None,
+ initial_sediment_ids: list[str] | None = None,
+ ) -> tuple[list[str], list[str]]:
+ """Poll the conversation document until image file ids appear or budget runs out.
+
+ - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
+ image generation takes ~30s; polling immediately wastes requests and trips
+ a transient 429 the upstream returns within ~200ms of the SSE stream
+ closing (the conversation document is not yet committed).
+ - Subsequent polls are image_poll_interval_secs apart (default 10s).
+ - On upstream 429 / 5xx or network errors, backs off exponentially
+ (capped at 16s, +jitter) honoring Retry-After when present.
+ - All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
+ """
+ start = time.time()
+ attempt = 0
+ interval = float(config.image_poll_interval_secs)
+ initial_wait = float(config.image_poll_initial_wait_secs)
+ file_ids: list[str] = []
+ sediment_ids: list[str] = []
+ self._add_unique(file_ids, initial_file_ids or [])
+ self._add_unique(sediment_ids, initial_sediment_ids or [])
+ has_initial_ids = bool(file_ids or sediment_ids)
+ last_hit_key: tuple[tuple[str, ...], tuple[str, ...]] | None = (
+ (tuple(file_ids), tuple(sediment_ids)) if has_initial_ids else None
+ )
+ logger.info({
+ "event": "image_poll_start",
+ "conversation_id": conversation_id,
+ "timeout_secs": timeout_secs,
+ "initial_wait_secs": initial_wait,
+ "interval_secs": interval,
+ "initial_file_ids": file_ids,
+ "initial_sediment_ids": sediment_ids,
+ })
+
+ def _remaining() -> float:
+ return timeout_secs - (time.time() - start)
+
+ if has_initial_ids and config.image_settle_enabled:
+ settle_for = min(config.image_settle_secs, max(0.0, _remaining()))
+ if settle_for > 0:
+ time.sleep(settle_for)
+ elif initial_wait > 0:
+ jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
+ sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
+ if sleep_for > 0:
+ time.sleep(sleep_for)
+
+ def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
+ # retry_after=0 means "retry immediately" — must not be coerced via falsy check.
+ base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
+ backoff = base + random.uniform(0, 0.5)
+ remaining = _remaining()
+ if remaining <= 0:
+ return False
+ sleep_for = min(backoff, remaining)
+ log_payload: Dict[str, Any] = {
+ "event": "image_poll_retry",
+ "conversation_id": conversation_id,
+ "attempt": attempt,
+ "reason": reason,
+ "sleep_secs": round(sleep_for, 2),
+ }
+ if status_code is not None:
+ log_payload["status_code"] = status_code
+ if error is not None:
+ log_payload["error"] = error
+ logger.warning(log_payload)
+ time.sleep(sleep_for)
+ return True
+
+ last_task_error = ""
+ while _remaining() > 0:
+ attempt += 1
+ # 在每次轮询时,检查 /backend-api/tasks/ 是否有错误(仅记录,不中断)
+ # 内容政策违规检测通过对话文本进行(在 _find_content_policy_error_in_conversation 中)
+ last_task_error = ""
+ try:
+ tasks = self._query_backend_tasks(conversation_id=conversation_id, timeout_secs=5.0)
+ for task in tasks:
+ is_error, error_msg, metadata = self.check_task_error(task)
+ if is_error and error_msg:
+ last_task_error = error_msg
+ logger.info({
+ "event": "image_poll_task_error_not_blocking",
+ "conversation_id": conversation_id,
+ "attempt": attempt,
+ "error_msg": error_msg,
+ "metadata": metadata,
+ })
+ except Exception as exc:
+ # tasks 查询失败不影响正常轮询流程
+ logger.debug({
+ "event": "image_poll_task_check_failed",
+ "conversation_id": conversation_id,
+ "attempt": attempt,
+ "error": str(exc),
+ })
+
+ try:
+ conversation = self._get_conversation(conversation_id)
+ except UpstreamHTTPError as exc:
+ if exc.status_code in (429, 500, 502, 503, 504):
+ if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
+ continue
+ break
+ raise
+ except requests.exceptions.RequestException as exc:
+ if _retry_sleep("network", None, str(exc), None):
+ continue
+ break
+
+ for record in self._extract_image_tool_records(conversation):
+ for file_id in record["file_ids"]:
+ if file_id not in file_ids:
+ file_ids.append(file_id)
+ for sediment_id in record["sediment_ids"]:
+ if sediment_id not in sediment_ids:
+ sediment_ids.append(sediment_id)
+
+ # 检查对话文本中是否包含内容政策违规错误
+ # 当上游拒绝生成图片时,错误消息会出现在对话文档的 assistant 消息中,
+ # 而非 /backend-api/tasks/ 的 task error 结构中。
+ # 如果在没有找到图片文件 ID 的同时检测到内容政策违规,立即中断轮询。
+ if not file_ids and not sediment_ids:
+ policy_msg = self._find_content_policy_error_in_conversation(conversation)
+ if policy_msg:
+ logger.warning({
+ "event": "image_poll_conversation_text_policy_violation",
+ "conversation_id": conversation_id,
+ "attempt": attempt,
+ "error_msg": policy_msg[:200],
+ })
+ raise ImageContentPolicyError(policy_msg)
+
+ logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
+ if file_ids or sediment_ids:
+ if not config.image_check_before_hit_enabled:
+ # 先check再hit 机制关闭:直接返回首次发现的 file_ids
+ logger.info({"event": "image_poll_hit_no_settle", "conversation_id": conversation_id,
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
+ return file_ids, sediment_ids
+ hit_key = (tuple(file_ids), tuple(sediment_ids))
+ if last_hit_key == hit_key:
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
+ "sediment_ids": sediment_ids})
+ return file_ids, sediment_ids
+ last_hit_key = hit_key
+ if not config.image_settle_enabled:
+ # 二次确认机制关闭:直接返回首次发现的 file_ids
+ logger.info({"event": "image_poll_hit_settle_disabled", "conversation_id": conversation_id,
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
+ return file_ids, sediment_ids
+ logger.info({"event": "image_poll_hit_pending_settle", "conversation_id": conversation_id,
+ "file_ids": file_ids, "sediment_ids": sediment_ids,
+ "settle_secs": config.image_settle_secs})
+ wait = min(config.image_settle_secs, max(0.0, _remaining()))
+ if wait > 0:
+ time.sleep(wait)
+ continue
+ return file_ids, sediment_ids
+ logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
+ "elapsed_secs": round(time.time() - start, 1)})
+ wait = min(interval, max(0.0, _remaining()))
+ if wait > 0:
+ time.sleep(wait)
+ logger.info({
+ "event": "image_poll_timeout",
+ "conversation_id": conversation_id,
+ "timeout_secs": timeout_secs,
+ "attempts_made": attempt,
+ # attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
+ "initial_wait_exhausted_budget": attempt == 0,
+ "last_task_error": last_task_error if last_task_error else None,
+ })
+ exc = ImagePollTimeoutError(
+ f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
+ f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
+ f"也可能是账号被限流或生图队列拥堵导致。"
+ )
+ if last_task_error:
+ setattr(exc, "task_error", last_task_error)
+ setattr(exc, "conversation_id", conversation_id or "")
+ raise exc
+
+ def _get_file_download_url(self, file_id: str) -> str:
+ """获取文件下载地址。"""
+ path = f"/backend-api/files/{file_id}/download"
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
+ timeout=60)
+ ensure_ok(response, path)
+ data = response.json()
+ return data.get("download_url") or data.get("url") or ""
+
+ def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
+ """通过 conversation 附件接口获取下载地址。"""
+ path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
+ response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
+ timeout=60)
+ ensure_ok(response, path)
+ data = response.json()
+ return data.get("download_url") or data.get("url") or ""
+
+ def _query_backend_tasks(
+ self,
+ conversation_id: str = "",
+ task_id: str = "",
+ timeout_secs: float = 30.0,
+ ) -> list[Dict[str, Any]]:
+ """查询 /backend-api/tasks/ 接口获取异步任务状态和错误信息。
+
+ 参数:
+ - `conversation_id`:可选。按 conversation_id 过滤任务。
+ - `task_id`:可选。按 task_id 过滤任务。
+ - `timeout_secs`:请求超时秒数。
+
+ 返回:
+ - 任务列表,每个任务包含 image_gen_message 等字段。
+ """
+ path = "/backend-api/tasks"
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._headers(path, {"Accept": "application/json"}),
+ timeout=timeout_secs,
+ )
+ ensure_ok(response, path)
+ data = response.json()
+ tasks = data.get("tasks", [])
+ if not isinstance(tasks, list):
+ return []
+
+ # 按 conversation_id 或 task_id 过滤
+ if conversation_id:
+ tasks = [
+ t for t in tasks
+ if isinstance(t, dict) and (
+ t.get("conversation_id") == conversation_id
+ or t.get("original_conversation_id") == conversation_id
+ )
+ ]
+ if task_id:
+ tasks = [t for t in tasks if isinstance(t, dict) and t.get("task_id") == task_id]
+ return tasks
+
+ def check_task_error(self, task: Dict[str, Any]) -> tuple[bool, str, Dict[str, Any]]:
+ """检查单个任务是否包含结构化错误。
+
+ 通过以下字段判断(不依赖文本匹配):
+ - image_gen_message.metadata.is_error == True
+ - image_gen_message.author.role == "assistant" (而非 "tool")
+ - image_gen_message.content.content_type == "text" (而非 "multimodal_text")
+
+ 返回:
+ - (is_error, error_msg, metadata)
+ """
+ img_msg = task.get("image_gen_message") or {}
+ if not img_msg:
+ return False, "", {}
+
+ metadata = img_msg.get("metadata") or {}
+ content = img_msg.get("content") or {}
+ author = img_msg.get("author") or {}
+
+ is_error = metadata.get("is_error", False)
+ is_text_only = content.get("content_type") == "text"
+ is_assistant_role = author.get("role") == "assistant"
+
+ # 提取错误文本
+ error_msg = ""
+ if is_error and is_text_only:
+ parts = content.get("parts", [])
+ error_msg = "".join(p for p in parts if isinstance(p, str))
+
+ return is_error, error_msg, metadata
+
+ def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
+ """把图片结果 id 解析成可下载 URL。"""
+ urls = []
+ skip_patterns = {"file_upload"}
+ for file_id in file_ids:
+ if file_id in skip_patterns:
+ logger.debug({
+ "event": "image_file_id_skipped",
+ "source": "file",
+ "conversation_id": conversation_id,
+ "id": file_id,
+ })
+ continue
+ try:
+ url = self._get_file_download_url(file_id)
+ except Exception as exc:
+ logger.debug({
+ "event": "image_download_url_failed",
+ "source": "file",
+ "conversation_id": conversation_id,
+ "id": file_id,
+ "error": repr(exc),
+ })
+ continue
+ if url:
+ if url not in urls:
+ urls.append(url)
+ else:
+ logger.debug({
+ "event": "image_download_url_empty",
+ "source": "file",
+ "conversation_id": conversation_id,
+ "id": file_id,
+ })
+ if not conversation_id or not sediment_ids:
+ logger.debug({
+ "event": "image_urls_resolved",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ "urls": urls,
+ })
+ return urls
+ for sediment_id in sediment_ids:
+ try:
+ url = self._get_attachment_download_url(conversation_id, sediment_id)
+ except Exception as exc:
+ logger.debug({
+ "event": "image_download_url_failed",
+ "source": "sediment",
+ "conversation_id": conversation_id,
+ "id": sediment_id,
+ "error": repr(exc),
+ })
+ continue
+ if url:
+ if url not in urls:
+ urls.append(url)
+ else:
+ logger.debug({
+ "event": "image_download_url_empty",
+ "source": "sediment",
+ "conversation_id": conversation_id,
+ "id": sediment_id,
+ })
+ logger.debug({
+ "event": "image_urls_resolved",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ "urls": urls,
+ })
+ return urls
+
+ def resolve_conversation_image_urls(
+ self,
+ conversation_id: str,
+ file_ids: list[str],
+ sediment_ids: list[str],
+ poll: bool = True,
+ poll_timeout_secs: float | None = None,
+ ) -> list[str]:
+ file_ids = [item for item in file_ids if item != "file_upload"]
+ sediment_ids = list(sediment_ids)
+ timeout = poll_timeout_secs if poll_timeout_secs is not None else config.image_poll_timeout_secs
+ # 当 check-before-hit 和 settle 均已关闭,且 SSE 已给出 file_ids 时,
+ # 跳过轮询直接解析 URL,省去 initial_wait + 轮询耗时。
+ if poll and conversation_id and (file_ids or sediment_ids):
+ if not config.image_check_before_hit_enabled and not config.image_settle_enabled:
+ logger.info({
+ "event": "image_resolve_skip_poll_direct_resolve",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ })
+ return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
+ if poll and conversation_id:
+ logger.info({
+ "event": "image_resolve_poll_needed",
+ "conversation_id": conversation_id,
+ "initial_file_ids": file_ids,
+ "initial_sediment_ids": sediment_ids,
+ "poll_timeout_secs": timeout,
+ })
+ try:
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(
+ conversation_id,
+ timeout,
+ file_ids,
+ sediment_ids,
+ )
+ except ImagePollTimeoutError as exc:
+ # 如果轮询超时且有 task error(如 moderation 拦截),抛出 ImageContentPolicyError
+ # 而非 ImagePollTimeoutError,让调用方能区分真正的超时和上游拒绝
+ task_error = getattr(exc, "task_error", "")
+ if not file_ids and not sediment_ids:
+ if task_error:
+ raise ImageContentPolicyError(task_error) from exc
+ raise
+ logger.warning({
+ "event": "image_resolve_poll_partial_timeout",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ })
+ except Exception as exc:
+ if not file_ids and not sediment_ids:
+ raise
+ logger.warning({
+ "event": "image_resolve_poll_partial_error",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ "error": repr(exc),
+ })
+ else:
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
+ return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
+
+ def download_image_bytes(self, urls: list[str]) -> list[bytes]:
+ images = []
+ for url in urls:
+ response = self.session.get(url, timeout=120)
+ ensure_ok(response, "image_download")
+ if response.content not in images:
+ images.append(response.content)
+ return images
+
+ def stream_conversation(
+ self,
+ messages: Optional[list[Dict[str, Any]]] = None,
+ model: str = "auto",
+ prompt: str = "",
+ images: Optional[list[str]] = None,
+ system_hints: Optional[list[str]] = None,
+ ) -> Iterator[str]:
+ system_hints = system_hints or []
+ if "picture_v2" in system_hints:
+ yield from self._stream_picture_conversation(prompt, model, images or [])
+ return
+
+ normalized = messages or [{"role": "user", "content": prompt}]
+ self._bootstrap()
+ requirements = self._get_chat_requirements()
+ path, timezone = self._chat_target()
+ payload = self._conversation_payload(normalized, model, timezone)
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._conversation_headers(path, requirements),
+ json=payload,
+ timeout=300,
+ stream=True,
+ )
+ ensure_ok(response, path)
+ try:
+ yield from iter_sse_payloads(response)
+ finally:
+ response.close()
+
+ def _report_progress(self, step: str) -> None:
+ """Report progress step to the callback if set."""
+ if self.progress_callback:
+ try:
+ self.progress_callback(step)
+ except Exception:
+ pass
+
+ def _stream_picture_conversation(
+ self,
+ prompt: str,
+ model: str,
+ images: list[str],
+ ) -> Iterator[str]:
+ if not self.access_token:
+ raise RuntimeError("access_token is required for image endpoints")
+ self._report_progress("uploading")
+ references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
+ self._report_progress("bootstrapping")
+ self._bootstrap()
+ self._report_progress("getting_token")
+ requirements = self._get_chat_requirements()
+ self._report_progress("preparing_conversation")
+ conduit_token = self._prepare_image_conversation(prompt, requirements, model)
+ self._report_progress("starting_generation")
+ response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
+ self._report_progress("generating")
+ try:
+ yield from iter_sse_payloads(response)
+ finally:
+ response.close()
+
+ def _bootstrap(self) -> None:
+ """预热首页,并提取 PoW 相关脚本引用。"""
+ response = self.session.get(
+ self.base_url + "/",
+ headers=self._bootstrap_headers(),
+ timeout=30,
+ )
+ ensure_ok(response, "bootstrap")
+ self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text)
+ if not self.pow_script_sources:
+ self.pow_script_sources = [DEFAULT_POW_SCRIPT]
+
+ def _get_chat_requirements(self) -> ChatRequirements:
+ """获取当前模式对话所需的 sentinel token。"""
+ path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
+ context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
+ body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
+ response = self.session.post(
+ self.base_url + path,
+ headers=self._headers(path, {"Content-Type": "application/json"}),
+ json=body,
+ timeout=30,
+ )
+ ensure_ok(response, context)
+ requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
+ if not requirements.token:
+ message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
+ raise RuntimeError(f"{message}: {requirements.raw_finalize}")
+ return requirements
+
+ def _chat_target(self) -> tuple[str, str]:
+ if self.access_token:
+ return "/backend-api/conversation", "Asia/Shanghai"
+ return "/backend-anon/conversation", "America/Los_Angeles"
+
+ def list_models(self) -> Dict[str, Any]:
+ """返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
+ self._bootstrap()
+ path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
+ "/backend-anon/models?iim=false&is_gizmo=false"
+ )
+ route = "/backend-api/models" if self.access_token else "/backend-anon/models"
+ context = "auth_models" if self.access_token else "anon_models"
+ response = self.session.get(
+ self.base_url + path,
+ headers=self._headers(route),
+ timeout=30,
+ )
+ ensure_ok(response, context)
+ data = []
+ seen = set()
+ for item in response.json().get("models", []):
+ if not isinstance(item, dict):
+ continue
+ slug = str(item.get("slug", "")).strip()
+ if not slug or slug in seen:
+ continue
+ seen.add(slug)
+ data.append({
+ "id": slug,
+ "object": "model",
+ "created": int(item.get("created") or 0),
+ "owned_by": str(item.get("owned_by") or "chatgpt"),
+ "permission": [],
+ "root": slug,
+ "parent": None,
+ })
+ data.sort(key=lambda item: item["id"])
+ return {"object": "list", "data": data}
diff --git a/services/protocol/__init__.py b/services/protocol/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b57d842a9b9267064a65e9216aa3a073beef0b6
--- /dev/null
+++ b/services/protocol/__init__.py
@@ -0,0 +1,2 @@
+"""Protocol converters for OpenAI-compatible endpoints."""
+
diff --git a/services/protocol/anthropic_v1_messages.py b/services/protocol/anthropic_v1_messages.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5113b0575eccb446d00b119067a90e271db67b5
--- /dev/null
+++ b/services/protocol/anthropic_v1_messages.py
@@ -0,0 +1,306 @@
+from __future__ import annotations
+
+import html
+import json
+import re
+import time
+import uuid
+from collections.abc import Callable, Iterable, Iterator
+from dataclasses import dataclass
+from typing import Any
+
+from services.account_service import account_service
+from services.openai_backend_api import OpenAIBackendAPI
+from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
+from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
+
+XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
+TOOL_NAME"""
+
+
+@dataclass
+class MessageRequest:
+ backend: OpenAIBackendAPI
+ messages: list[dict[str, Any]]
+ model: str
+ tools: Any = None
+
+
+def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
+ fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
+ name = str(tool.get("name") or fn.get("name") or "").strip()
+ desc = str(tool.get("description") or fn.get("description") or "").strip()
+ schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
+ return name, desc, schema
+
+
+def build_tool_prompt(tools: object) -> str:
+ if not isinstance(tools, list):
+ return ""
+ blocks = []
+ for tool in tools:
+ if not isinstance(tool, dict):
+ continue
+ name, desc, schema = _tool_meta(tool)
+ if name:
+ blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
+ if not blocks:
+ return ""
+ return "Available tools:\n" + "\n".join(blocks) + """
+
+Tool use rules:
+- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
+- To call tools, output ONLY XML and no prose/markdown:
+TOOL_NAME
+- Put parameters under using the exact schema names.
+""".strip()
+
+
+def merge_system(system: object, extra: str) -> object:
+ system = compact_system(system)
+ if _has_claude_code_system(system):
+ extra = XML_TOOL_RULE
+ if not extra:
+ return system
+ if isinstance(system, str) and system.strip():
+ return f"{system.strip()}\n\n{extra}"
+ if isinstance(system, list):
+ return [*system, {"type": "text", "text": extra}]
+ return extra
+
+
+def _has_claude_code_system(system: object) -> bool:
+ if isinstance(system, str):
+ return "You are Claude Code" in system
+ if isinstance(system, list):
+ return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
+ return False
+
+
+def compact_system(system: object) -> object:
+ if isinstance(system, str):
+ return _compact_system_text(system)
+ if isinstance(system, list):
+ result = []
+ for item in system:
+ if isinstance(item, dict) and str(item.get("type") or "") == "text":
+ copied = dict(item)
+ copied["text"] = _compact_system_text(str(item.get("text") or ""))
+ result.append(copied)
+ else:
+ result.append(item)
+ return result
+ return system
+
+
+def _compact_system_text(text: str) -> str:
+ return text or ""
+
+
+def _compact_message_text(text: str) -> str:
+ return text or ""
+
+
+def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
+ payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
+ payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
+ return payload
+
+
+def message_request(body: dict[str, Any]) -> MessageRequest:
+ payload = preprocess_payload(dict(body))
+ return MessageRequest(
+ backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()),
+ messages=normalize_messages(payload.get("messages"), payload.get("system")),
+ model=str(payload.get("model") or "auto").strip() or "auto",
+ tools=payload.get("tools"),
+ )
+
+
+def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
+ if not isinstance(messages, list):
+ return messages
+ mapper = text_mapper or (lambda text: text)
+ result = []
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ item = dict(message)
+ content = item.get("content")
+ if isinstance(content, str):
+ item["content"] = _compact_message_text(mapper(content))
+ elif isinstance(content, list):
+ item["content"] = [_preprocess_block(block, mapper) for block in content]
+ result.append(item)
+ return result
+
+
+def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
+ if not isinstance(block, dict):
+ return block
+ block_type = str(block.get("type") or "")
+ if block_type == "text":
+ item = dict(block)
+ item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
+ return item
+ if block_type == "tool_use":
+ return {"type": "text", "text": f"{block.get('name') or ''}{json.dumps(block.get('input') or {}, ensure_ascii=False)}"}
+ if block_type == "tool_result":
+ return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
+ return block
+
+
+def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
+ content, stop_reason = content_blocks(text, tools)
+ return {
+ "id": f"msg_{uuid.uuid4()}",
+ "type": "message",
+ "role": "assistant",
+ "model": model,
+ "content": content,
+ "stop_reason": stop_reason,
+ "stop_sequence": None,
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
+ }
+
+
+def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
+ calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
+ text = strip_tool_markup(text)
+ if calls:
+ content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
+ return content, "tool_use"
+ return [{"type": "text", "text": text}], "end_turn"
+
+
+def strip_tool_markup(text: str) -> str:
+ return re.sub(r"(?is)]*>.*?|]*>.*?|]*>.*?|]*>.*?", "", text or "").strip()
+
+
+def streamable_text(text: str) -> str:
+ text = text or ""
+ match = re.search(r"(?is) list[tuple[str, dict[str, object]]]:
+ text = re.sub(r"(?is)```.*?```", "", text or "").strip()
+ blocks = re.findall(r"(?is)]*>(.*?)|]*>(.*?)|]*>(.*?)", text)
+ result = []
+ for block in (next((part for part in match if part), "") for match in blocks):
+ name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
+ params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
+ if name:
+ result.append((name, parse_tool_params(params)))
+ return result
+
+
+def xml_value(text: str, tag: str) -> str:
+ match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?){tag}>", text)
+ if not match:
+ return ""
+ value = match.group(1).strip()
+ cdata = re.fullmatch(r"(?is)", value)
+ return html.unescape(cdata.group(1) if cdata else value).strip()
+
+
+def parse_tool_params(raw: str) -> dict[str, object]:
+ raw = raw.strip()
+ try:
+ parsed = json.loads(raw)
+ return parsed if isinstance(parsed, dict) else {}
+ except Exception:
+ return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)\1>", raw)}
+
+
+def parse_tool_value(raw: str) -> object:
+ value = xml_value(f"{raw}", "x")
+ try:
+ return json.loads(value)
+ except Exception:
+ return value
+
+
+def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
+ message_id = f"msg_{uuid.uuid4()}"
+ created = int(time.time())
+ current_text = ""
+ streamed_text = ""
+ tool_mode = isinstance(tools, list) and bool(tools)
+ tool_started = False
+ text_open = False
+ yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
+ if not tool_mode:
+ text_open = True
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
+ for chunk in chunks:
+ choice = (chunk.get("choices") or [{}])[0]
+ delta = choice.get("delta") or {}
+ text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
+ if text_delta:
+ current_text += text_delta
+ if not tool_started:
+ visible_text = current_text if not tool_mode else streamable_text(current_text)
+ if visible_text.startswith(streamed_text):
+ text_delta = visible_text[len(streamed_text):]
+ if text_delta:
+ if not text_open:
+ text_open = True
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
+ streamed_text = visible_text
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
+ tool_started = tool_mode and visible_text != current_text
+ if choice.get("finish_reason"):
+ content, stop_reason = content_blocks(current_text, tools)
+ if text_open:
+ yield {"type": "content_block_stop", "index": 0}
+ if stop_reason == "tool_use":
+ start_index = 1 if text_open else 0
+ if content and content[0]["type"] == "text":
+ remaining = str(content[0].get("text") or "")[len(streamed_text):]
+ if remaining:
+ if not text_open:
+ yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
+ yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
+ if not text_open:
+ yield {"type": "content_block_stop", "index": 0}
+ start_index = 1
+ content = content[1:]
+ yield from _stream_buffered_blocks(content, start_index)
+ yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
+ break
+ yield {"type": "message_stop", "created": created}
+
+
+def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
+ for offset, block in enumerate(content):
+ index = start_index + offset
+ if block["type"] == "tool_use":
+ start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
+ delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
+ else:
+ start = {"type": "text", "text": ""}
+ delta = {"type": "text_delta", "text": block.get("text") or ""}
+ yield {"type": "content_block_start", "index": index, "content_block": start}
+ yield {"type": "content_block_delta", "index": index, "delta": delta}
+ yield {"type": "content_block_stop", "index": index}
+
+
+def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
+ request = message_request(body)
+ if body.get("stream"):
+ return stream_events(
+ stream_text_chat_completion(request.backend, request.messages, request.model),
+ request.model,
+ count_message_tokens(request.messages, request.model),
+ lambda text: count_text_tokens(text, request.model),
+ request.tools,
+ )
+ text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
+ return message_response(
+ request.model,
+ text,
+ count_message_tokens(request.messages, request.model),
+ count_text_tokens(text, request.model),
+ request.tools,
+ )
diff --git a/services/protocol/chat_completion_cache.py b/services/protocol/chat_completion_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..f28cc9840b0a86ebbb5b751fadd937c28590eaa6
--- /dev/null
+++ b/services/protocol/chat_completion_cache.py
@@ -0,0 +1,236 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Any, Callable, Iterable, Iterator
+
+from services.config import config
+
+CACHEABLE_TEXT_KEYS = {
+ "frequency_penalty",
+ "max_completion_tokens",
+ "max_tokens",
+ "metadata",
+ "model",
+ "presence_penalty",
+ "reasoning_effort",
+ "response_format",
+ "seed",
+ "stop",
+ "temperature",
+ "tool_choice",
+ "tools",
+ "top_p",
+ "user",
+}
+
+
+@dataclass
+class CacheEntry:
+ expires_at: float
+ value: Any
+
+
+@dataclass
+class InflightCall:
+ condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock()))
+ done: bool = False
+ value: Any = None
+ error: BaseException | None = None
+
+
+def _json_safe(value: Any) -> Any:
+ if isinstance(value, bytes):
+ return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)}
+ if isinstance(value, bytearray):
+ data = bytes(value)
+ return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)}
+ if isinstance(value, dict):
+ return {str(key): _json_safe(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_json_safe(item) for item in value]
+ return value
+
+
+def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
+ payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body}
+ payload["messages"] = messages
+ payload["stream"] = bool(stream)
+ return payload
+
+
+def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str:
+ encoded = json.dumps(
+ _json_safe(canonical_body(body, messages, stream=stream)),
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _message_signature(message: dict[str, Any]) -> str:
+ return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+
+
+def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ settings = config.get_chat_completion_cache_settings()
+ if not settings.get("normalize_messages"):
+ return messages
+
+ normalized: list[dict[str, Any]] = []
+ previous_signature = ""
+ for message in messages:
+ if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant":
+ continue
+ signature = _message_signature(message)
+ if settings.get("drop_adjacent_duplicates") and signature == previous_signature:
+ continue
+ normalized.append(message)
+ previous_signature = signature
+ return normalized
+
+
+class ChatCompletionCache:
+ def __init__(self) -> None:
+ self._lock = threading.RLock()
+ self._entries: dict[str, CacheEntry] = {}
+ self._inflight: dict[str, InflightCall] = {}
+
+ def clear(self) -> None:
+ with self._lock:
+ self._entries.clear()
+ self._inflight.clear()
+
+ def _settings(self) -> dict[str, object]:
+ return config.get_chat_completion_cache_settings()
+
+ def _prune_locked(self, now: float, max_entries: int) -> None:
+ expired = [key for key, item in self._entries.items() if item.expires_at <= now]
+ for key in expired:
+ self._entries.pop(key, None)
+ while len(self._entries) > max_entries:
+ oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at)
+ self._entries.pop(oldest_key, None)
+
+ @staticmethod
+ def _copy(value: Any) -> Any:
+ return copy.deepcopy(value)
+
+ def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+ settings = self._settings()
+ if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0:
+ return compute()
+
+ now = time.time()
+ max_entries = int(settings.get("max_entries") or 1)
+ with self._lock:
+ self._prune_locked(now, max_entries)
+ entry = self._entries.get(key)
+ if entry and entry.expires_at > now:
+ return self._copy(entry.value)
+ inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
+ if inflight is None:
+ inflight = InflightCall()
+ if settings.get("dedupe_inflight"):
+ self._inflight[key] = inflight
+ owner = True
+ else:
+ owner = False
+
+ if not owner:
+ with inflight.condition:
+ while not inflight.done:
+ inflight.condition.wait()
+ if inflight.error:
+ raise inflight.error
+ return self._copy(inflight.value)
+
+ try:
+ value = compute()
+ except BaseException as exc:
+ with self._lock:
+ self._inflight.pop(key, None)
+ with inflight.condition:
+ inflight.error = exc
+ inflight.done = True
+ inflight.condition.notify_all()
+ raise
+
+ expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
+ with self._lock:
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value))
+ self._prune_locked(time.time(), max_entries)
+ self._inflight.pop(key, None)
+ with inflight.condition:
+ inflight.value = self._copy(value)
+ inflight.done = True
+ inflight.condition.notify_all()
+ return value
+
+ def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]:
+ settings = self._settings()
+ if (
+ not settings.get("enabled")
+ or not settings.get("stream_cache")
+ or int(settings.get("ttl_seconds") or 0) <= 0
+ ):
+ yield from compute()
+ return
+
+ now = time.time()
+ max_entries = int(settings.get("max_entries") or 1)
+ with self._lock:
+ self._prune_locked(now, max_entries)
+ entry = self._entries.get(key)
+ if entry and entry.expires_at > now:
+ yield from self._copy(entry.value)
+ return
+ inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
+ if inflight is None:
+ inflight = InflightCall()
+ if settings.get("dedupe_inflight"):
+ self._inflight[key] = inflight
+ owner = True
+ else:
+ owner = False
+
+ if not owner:
+ with inflight.condition:
+ while not inflight.done:
+ inflight.condition.wait()
+ if inflight.error:
+ raise inflight.error
+ yield from self._copy(inflight.value)
+ return
+
+ chunks: list[dict[str, Any]] = []
+ try:
+ for chunk in compute():
+ chunks.append(self._copy(chunk))
+ yield chunk
+ except BaseException as exc:
+ with self._lock:
+ self._inflight.pop(key, None)
+ with inflight.condition:
+ inflight.error = exc
+ inflight.done = True
+ inflight.condition.notify_all()
+ raise
+
+ expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
+ with self._lock:
+ self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks))
+ self._prune_locked(time.time(), max_entries)
+ self._inflight.pop(key, None)
+ with inflight.condition:
+ inflight.value = self._copy(chunks)
+ inflight.done = True
+ inflight.condition.notify_all()
+
+
+chat_completion_cache = ChatCompletionCache()
diff --git a/services/protocol/conversation.py b/services/protocol/conversation.py
new file mode 100644
index 0000000000000000000000000000000000000000..a474f1612c24ca25995e5751437b5395a33303d0
--- /dev/null
+++ b/services/protocol/conversation.py
@@ -0,0 +1,1556 @@
+from __future__ import annotations
+
+import base64
+import json
+import re
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass, field
+from typing import Any, Iterable, Iterator
+
+import tiktoken
+
+from services.account_service import account_service
+from services.config import config
+from services.image_storage_service import image_storage_service
+from services.openai_backend_api import ImageContentPolicyError, ImagePollTimeoutError, OpenAIBackendAPI
+from utils.helper import (
+ IMAGE_MODELS,
+ extract_image_from_message_content,
+ is_codex_image_model,
+ is_supported_image_model,
+ split_image_model,
+)
+from utils.image_tokens import count_image_content_tokens
+from utils.log import logger
+
+
+class ImageGenerationError(Exception):
+ def __init__(
+ self,
+ message: str,
+ status_code: int = 502,
+ error_type: str = "server_error",
+ code: str | None = "upstream_error",
+ param: str | None = None,
+ account_email: str = "",
+ conversation_id: str = "",
+ ) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+ self.error_type = error_type
+ self.code = code
+ self.param = param
+ self.account_email = account_email
+ self.conversation_id = conversation_id
+
+ def to_openai_error(self) -> dict[str, Any]:
+ error_dict = {
+ "error": {
+ "message": public_image_error_message(str(self)),
+ "type": self.error_type,
+ "param": self.param,
+ "code": self.code,
+ }
+ }
+ if self.account_email:
+ error_dict["error"]["account_email"] = self.account_email
+ return error_dict
+
+
+def public_image_error_message(message: str) -> str:
+ text = str(message or "").strip()
+ lower = text.lower()
+ if any(item in lower for item in ("backend-api/", "status=", "body=", "chatgpt.com", "upstreamhttperror")):
+ return "The image generation request failed. Please try again later."
+ return text or "The image generation request failed. Please try again later."
+
+
+def is_token_invalid_error(message: str) -> bool:
+ text = str(message or "").lower()
+ return (
+ "token_invalidated" in text
+ or "token_revoked" in text
+ or "authentication token has been invalidated" in text
+ or "invalidated oauth token" in text
+ )
+
+
+def is_tls_connection_error(message: str) -> bool:
+ """检测 TLS/SSL 连接错误,这类错误通常可以通过重试解决。"""
+ text = str(message or "").lower()
+ return (
+ "curl: (35)" in text
+ or "tls connect error" in text
+ or "openssl_internal" in text
+ or "ssl: wrong_version_number" in text
+ or "ssl: certificate_verify_failed" in text
+ or "connection aborted" in text
+ or "remote disconnected" in text
+ or "connection reset by peer" in text
+ )
+
+
+def is_connection_timeout_error(message: str) -> bool:
+ """检测连接超时错误(如 curl 28),这类错误可通过同账号短等待重试解决。"""
+ text = str(message or "").lower()
+ return (
+ "curl: (28)" in text
+ or "operation timed out" in text
+ or "connection timed out" in text
+ or "read timed out" in text
+ or "connect timeout" in text
+ )
+
+
+def image_stream_error_message(message: str) -> str:
+ text = str(message or "")
+ if is_token_invalid_error(text):
+ return "image generation failed"
+ if is_tls_connection_error(text):
+ return "upstream image connection failed, please retry later"
+ if is_connection_timeout_error(text):
+ return "upstream connection timed out, please retry later"
+ return text or "image generation failed"
+
+
+REFERENCED_IMAGE_IDS_RE = re.compile(r'"referenced_image_ids"\s*:\s*\[([^\]]+)\]')
+# 检测模型返回的部分工具调用 JSON(如 {"size":"1920x1088","n":1})
+# 这些 JSON 包含图片生成工具的参数,但没有实际生成图片
+TOOL_PARAMS_JSON_RE = re.compile(
+ r'\{\s*"size"\s*:\s*"\d+x\d+"\s*,\s*"n"\s*:\s*\d+\s*\}'
+)
+
+
+def is_model_text_reply_instead_of_image(message: str) -> bool:
+ """检测模型是否返回了文本回复(包含工具调用 JSON)而非实际生成图片。
+
+ 当上游 ChatGPT 未能触发图片生成工具时,会返回一段描述性文本,
+ 其中可能包含 JSON 参数(如 prompt、referenced_image_ids、size/n 等)。
+ 这种情况应被视为「上游未生成图片」而非「内容策略违规」。
+
+ 检测两种模式:
+ 1. 完整的工具调用 JSON(含 referenced_image_ids)
+ 2. 部分的工具参数 JSON(如 {"size":"1920x1088","n":1})
+ """
+ if not message:
+ return False
+ if REFERENCED_IMAGE_IDS_RE.search(message):
+ return True
+ # 检测部分工具参数 JSON(模型返回了工具参数但未触发工具)
+ if TOOL_PARAMS_JSON_RE.search(message):
+ return True
+ return False
+
+
+def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
+ return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
+
+
+def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
+ return image_storage_service.save(image_data, base_url).url
+
+
+def message_text(content: Any) -> str:
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = []
+ for item in content:
+ if isinstance(item, str):
+ parts.append(item)
+ elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
+ parts.append(str(item.get("text") or ""))
+ return "".join(parts)
+ return ""
+
+
+def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
+ normalized = []
+ if config.global_system_prompt:
+ normalized.append({"role": "system", "content": config.global_system_prompt})
+ system_text = message_text(system)
+ if system_text:
+ normalized.append({"role": "system", "content": system_text})
+ if isinstance(messages, list):
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ role = message.get("role", "user")
+ content = message.get("content", "")
+ text = message_text(content)
+ images: list[tuple[bytes, str]] = []
+ if role == "user":
+ images.extend(extract_image_from_message_content(content))
+ if isinstance(content, list):
+ for part in content:
+ if not isinstance(part, dict) or part.get("type") != "image":
+ continue
+ data = part.get("data")
+ if isinstance(data, (bytes, bytearray)) and all(existing[0] != bytes(data) for existing in images):
+ images.append((bytes(data), str(part.get("mime") or "image/png")))
+ if images:
+ parts: list[Any] = []
+ if text:
+ parts.append({"type": "text", "text": text})
+ for data, mime in images:
+ parts.append({"type": "image", "data": data, "mime": mime})
+ normalized.append({"role": role, "content": parts})
+ else:
+ normalized.append({"role": role, "content": text})
+ return normalized
+
+
+def prompt_with_global_system(prompt: str) -> str:
+ return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt
+
+
+def assistant_history_text(messages: list[dict[str, Any]]) -> str:
+ return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
+
+
+def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
+ return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
+
+
+def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
+ hints = []
+ if size:
+ hints.append(f"输出图片尺寸为 {size}。")
+ if quality:
+ hints.append(f"输出图片质量为 {quality}。")
+ return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
+
+
+def encoding_for_model(model: str):
+ try:
+ return tiktoken.encoding_for_model(model)
+ except KeyError:
+ try:
+ return tiktoken.get_encoding("o200k_base")
+ except KeyError:
+ return tiktoken.get_encoding("cl100k_base")
+
+
+def count_message_image_tokens(messages: list[dict[str, Any]], model: str) -> int:
+ return sum(count_image_content_tokens(message.get("content"), model) for message in messages)
+
+
+def count_message_text_tokens(messages: list[dict[str, Any]], model: str) -> int:
+ encoding = encoding_for_model(model)
+ total = 0
+ for message in messages:
+ total += 3
+ for key, value in message.items():
+ if key == "content" and isinstance(value, list):
+ total += len(encoding.encode(message_text(value)))
+ elif isinstance(value, str):
+ total += len(encoding.encode(value))
+ else:
+ continue
+ if key == "name":
+ total += 1
+ return total + 3
+
+
+def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
+ return count_message_text_tokens(messages, model) + count_message_image_tokens(messages, model)
+
+
+def count_text_tokens(text: str, model: str) -> int:
+ return len(encoding_for_model(model).encode(text))
+
+
+def format_image_result(
+ items: list[dict[str, Any]],
+ prompt: str,
+ response_format: str,
+ base_url: str | None = None,
+ created: int | None = None,
+ message: str = "",
+) -> dict[str, Any]:
+ data: list[dict[str, Any]] = []
+ for item in items:
+ b64_json = str(item.get("b64_json") or "").strip()
+ if not b64_json:
+ continue
+ revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
+ if response_format == "b64_json":
+ data.append({
+ "b64_json": b64_json,
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
+ "revised_prompt": revised_prompt,
+ })
+ else:
+ data.append({
+ "url": save_image_bytes(base64.b64decode(b64_json), base_url),
+ "revised_prompt": revised_prompt,
+ })
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
+ if message and not data:
+ result["message"] = message
+ return result
+
+
+@dataclass
+class ConversationRequest:
+ model: str = "auto"
+ prompt: str = ""
+ messages: list[dict[str, Any]] | None = None
+ images: list[str] | None = None
+ n: int = 1
+ size: str | None = None
+ quality: str = "auto"
+ response_format: str = "b64_json"
+ base_url: str | None = None
+ message_as_error: bool = False
+ progress_callback: Any = None # Callable[[str], None] | None
+
+
+@dataclass
+class ConversationState:
+ text: str = ""
+ raw_text: str = ""
+ conversation_id: str = ""
+ file_ids: list[str] = field(default_factory=list)
+ sediment_ids: list[str] = field(default_factory=list)
+ blocked: bool = False
+ tool_invoked: bool | None = None
+ turn_use_case: str = ""
+
+
+@dataclass
+class ImageOutput:
+ kind: str
+ model: str
+ index: int
+ total: int
+ created: int = field(default_factory=lambda: int(time.time()))
+ text: str = ""
+ upstream_event_type: str = ""
+ data: list[dict[str, Any]] = field(default_factory=list)
+ account_email: str = ""
+ conversation_id: str = ""
+
+ def to_chunk(self) -> dict[str, Any]:
+ chunk: dict[str, Any] = {
+ "object": "image.generation.chunk",
+ "created": self.created,
+ "model": self.model,
+ "index": self.index,
+ "total": self.total,
+ "progress_text": self.text,
+ "upstream_event_type": self.upstream_event_type,
+ "data": [],
+ }
+ if self.account_email:
+ chunk["_account_email"] = self.account_email
+ if self.conversation_id:
+ chunk["_conversation_id"] = self.conversation_id
+ if self.kind == "message":
+ chunk.update({
+ "object": "image.generation.message",
+ "message": self.text,
+ })
+ chunk.pop("progress_text", None)
+ chunk.pop("upstream_event_type", None)
+ elif self.kind == "result":
+ chunk.update({
+ "object": "image.generation.result",
+ "data": self.data,
+ })
+ chunk.pop("progress_text", None)
+ chunk.pop("upstream_event_type", None)
+ return chunk
+
+
+def assistant_message_text(message: dict[str, Any]) -> str:
+ content = message.get("content") or {}
+ parts = content.get("parts") or []
+ if isinstance(parts, list) and parts:
+ text = "".join(part for part in parts if isinstance(part, str))
+ if text:
+ return text
+ # Fallback: content_type "code" stores text in the "text" field instead of "parts"
+ text_field = str(content.get("text") or "")
+ if text_field:
+ return text_field
+ return ""
+
+
+def strip_history(text: str, history_text: str = "") -> str:
+ text = str(text or "")
+ history_text = str(history_text or "")
+ while history_text and text.startswith(history_text):
+ text = text[len(history_text):]
+ return text
+
+
+def sanitize_output_text(text: str) -> str:
+ text = str(text or "")
+
+ def is_internal_annotation_part(part: str) -> bool:
+ value = part.strip()
+ if not value:
+ return True
+ lower = value.lower()
+ return bool(
+ re.fullmatch(r"turn\d+[a-z]*\d*", lower)
+ or re.fullmatch(r"turn\d+\w*", lower)
+ or lower.startswith(("turn", "source", "sources"))
+ )
+
+ def readable_annotation_part(parts: list[str]) -> str:
+ for part in parts:
+ value = part.strip()
+ if value and not is_internal_annotation_part(value):
+ return value
+ return ""
+
+ def replace_annotation(match: re.Match[str]) -> str:
+ payload = match.group(1)
+ parts = [part.strip() for part in payload.split("\ue202")]
+ kind = (parts[0] if parts else "").lower()
+ data = parts[1:]
+ if kind == "url":
+ label = data[0] if data else ""
+ url = data[1] if len(data) > 1 else ""
+ if label and url.startswith(("http://", "https://")):
+ return f"{label} ({url})"
+ return label or url
+ if kind == "cite":
+ return readable_annotation_part(data)
+ return readable_annotation_part(data)
+
+ # ChatGPT web sometimes returns rich annotation markers using private-use
+ # characters. API clients cannot render those. Preserve readable labels
+ # from entity/link annotations, while removing internal citation pointers.
+ text = re.sub(r"\ue200([^\ue201]*)\ue201", replace_annotation, text)
+ text = re.sub(r"\ue200[^\ue201]*$", "", text)
+ text = re.sub(r"\s+([.,;:!?])", r"\1", text)
+ return text
+
+
+def assistant_raw_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
+ for candidate in (event, event.get("v")):
+ if not isinstance(candidate, dict):
+ continue
+ message = candidate.get("message")
+ if not isinstance(message, dict):
+ continue
+ role = str((message.get("author") or {}).get("role") or "").strip().lower()
+ if role != "assistant":
+ continue
+ text = assistant_message_text(message)
+ if text:
+ return strip_history(text, history_text)
+ return apply_text_patch(event, current_text, history_text)
+
+
+def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
+ return sanitize_output_text(assistant_raw_text(event, current_text, history_text))
+
+
+def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
+ for candidate in (event, event.get("v")):
+ if not isinstance(candidate, dict):
+ continue
+ message = candidate.get("message")
+ if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
+ return strip_history(assistant_message_text(message), history_text)
+ return ""
+
+
+def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
+ if event.get("p") == "/message/content/parts/0":
+ return apply_patch_op(event, current_text, history_text)
+
+ operations = event.get("v")
+ if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
+ return current_text + operations
+
+ if event.get("o") == "patch" and isinstance(operations, list):
+ text = current_text
+ for item in operations:
+ if isinstance(item, dict):
+ text = apply_text_patch(item, text, history_text)
+ return text
+
+ if not isinstance(operations, list):
+ return current_text
+
+ text = current_text
+ for item in operations:
+ if isinstance(item, dict):
+ text = apply_text_patch(item, text, history_text)
+ return text
+
+
+def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
+ op = operation.get("o")
+ value = str(operation.get("v") or "")
+ if op == "append":
+ return current_text + value
+ if op == "replace":
+ return strip_history(value, history_text)
+ return current_text
+
+
+def add_unique(values: list[str], candidates: list[str]) -> None:
+ for candidate in candidates:
+ if candidate and candidate not in values:
+ values.append(candidate)
+
+
+FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
+FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
+# 真正的图片文件 ID 格式:file_00000000 + 24位十六进制字符(共32字符)
+# 用于过滤非图片文件 ID(如 file_upload_business_upsell)
+REAL_IMAGE_FILE_ID_RE = re.compile(r"\bfile_00000000[a-f0-9]{24}\b")
+SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
+
+
+def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
+ conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
+ conversation_id = conversation_match.group(1) if conversation_match else ""
+ file_ids: list[str] = []
+ # Negative lookahead excludes "file-service" (URI prefix, not a real id).
+ add_unique(file_ids, FILE_SERVICE_ID_RE.findall(payload))
+ # 只提取真正的图片文件 ID(file_00000000... 格式),过滤非图片文件 ID(如 file_upload_business_upsell)
+ add_unique(file_ids, REAL_IMAGE_FILE_ID_RE.findall(payload))
+ sediment_ids = SEDIMENT_ID_RE.findall(payload)
+ return conversation_id, file_ids, sediment_ids
+
+
+def is_image_tool_event(event: dict[str, Any]) -> bool:
+ value = event.get("v")
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
+ if not isinstance(message, dict):
+ return False
+ metadata = message.get("metadata") or {}
+ author = message.get("author") or {}
+ content = message.get("content") or {}
+ if author.get("role") != "tool":
+ return False
+ if metadata.get("async_task_type") == "image_gen":
+ return True
+ if content.get("content_type") != "multimodal_text":
+ return False
+ return any(
+ isinstance(part, dict) and (
+ part.get("content_type") == "image_asset_pointer"
+ or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://"))
+ )
+ for part in content.get("parts") or []
+ )
+
+
+def _is_user_message_event(event: dict[str, Any]) -> bool:
+ """检查事件是否来自 user 角色消息。"""
+ value = event.get("v")
+ message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
+ if isinstance(message, dict):
+ author = message.get("author") or {}
+ if str(author.get("role") or "").strip().lower() == "user":
+ return True
+ return False
+
+
+def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
+ if conversation_id and not state.conversation_id:
+ state.conversation_id = conversation_id
+ # Accept file_id / sediment_id when any of:
+ # 1) event is a complete image_gen tool message
+ # 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn),
+ # BUT only for non-user messages — user messages contain the uploaded input image
+ # which must NOT be treated as a generated output.
+ # 3) patch event whose payload references asset_pointer / file-service://,
+ # BUT only when the event is not a user message.
+ is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
+ is_user_msg = isinstance(event, dict) and _is_user_message_event(event)
+ image_context = (
+ (isinstance(event, dict) and is_image_tool_event(event))
+ or (state.tool_invoked is True and not is_user_msg)
+ or (is_patch_event and not is_user_msg and ("asset_pointer" in payload or "file-service://" in payload))
+ )
+ if image_context:
+ add_unique(state.file_ids, file_ids)
+ add_unique(state.sediment_ids, sediment_ids)
+ if not isinstance(event, dict):
+ return
+ state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
+ value = event.get("v")
+ if isinstance(value, dict):
+ state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
+ if event.get("type") == "moderation":
+ moderation = event.get("moderation_response")
+ if isinstance(moderation, dict) and moderation.get("blocked") is True:
+ state.blocked = True
+ if event.get("type") == "server_ste_metadata":
+ metadata = event.get("metadata")
+ if isinstance(metadata, dict):
+ if isinstance(metadata.get("tool_invoked"), bool):
+ state.tool_invoked = metadata["tool_invoked"]
+ state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
+
+
+def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
+ return {
+ "type": event_type,
+ "text": state.text,
+ "conversation_id": state.conversation_id,
+ "file_ids": list(state.file_ids),
+ "sediment_ids": list(state.sediment_ids),
+ "blocked": state.blocked,
+ "tool_invoked": state.tool_invoked,
+ "turn_use_case": state.turn_use_case,
+ **extra,
+ }
+
+
+def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
+ history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]:
+ state = ConversationState()
+ history_messages = history_messages or []
+ history_index = 0
+ for payload in payloads:
+ # print(f"[upstream_sse] {payload}", flush=True)
+ if not payload:
+ continue
+ if payload == "[DONE]":
+ yield conversation_base_event("conversation.done", state, done=True)
+ break
+ try:
+ event = json.loads(payload)
+ except json.JSONDecodeError:
+ update_conversation_state(state, payload)
+ yield conversation_base_event("conversation.raw", state, payload=payload)
+ continue
+ if not isinstance(event, dict):
+ yield conversation_base_event("conversation.event", state, raw=event)
+ continue
+ update_conversation_state(state, payload, event)
+ if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
+ history_index += 1
+ state.raw_text = ""
+ state.text = ""
+ continue
+ next_raw_text = assistant_raw_text(event, state.raw_text, history_text)
+ next_text = sanitize_output_text(next_raw_text)
+ state.raw_text = next_raw_text
+ if next_text != state.text:
+ delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
+ state.text = next_text
+ yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
+ continue
+ yield conversation_base_event("conversation.event", state, raw=event)
+
+
+def conversation_events(
+ backend: OpenAIBackendAPI,
+ messages: list[dict[str, Any]] | None = None,
+ model: str = "auto",
+ prompt: str = "",
+ images: list[str] | None = None,
+ size: str | None = None,
+ quality: str = "auto",
+) -> Iterator[dict[str, Any]]:
+ normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
+ image_model = is_supported_image_model(model)
+ history_text = "" if image_model else assistant_history_text(normalized)
+ history_messages = [] if image_model else assistant_history_messages(normalized)
+ final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
+ payloads = backend.stream_conversation(
+ messages=normalized,
+ model=model,
+ prompt=final_prompt,
+ images=images if image_model else None,
+ system_hints=["picture_v2"] if image_model else None,
+ )
+ yield from iter_conversation_payloads(payloads, history_text, history_messages)
+
+
+def text_backend() -> OpenAIBackendAPI:
+ return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
+
+
+def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
+ attempted_tokens: set[str] = set()
+ token = getattr(backend, "access_token", "")
+ emitted = False
+ while True:
+ if token and token in attempted_tokens:
+ raise RuntimeError("no available text account")
+ if token:
+ attempted_tokens.add(token)
+ try:
+ active_backend = OpenAIBackendAPI(access_token=token)
+ for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt):
+ if event.get("type") != "conversation.delta":
+ continue
+ delta = str(event.get("delta") or "")
+ if delta:
+ emitted = True
+ yield delta
+ account_service.mark_text_used(token)
+ return
+ except Exception as exc:
+ error_message = str(exc)
+ if token and not emitted and is_token_invalid_error(error_message):
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream")
+ if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens:
+ token = refreshed_token
+ else:
+ account_service.remove_invalid_token(token, "text_stream")
+ token = account_service.get_text_access_token(attempted_tokens)
+ if token:
+ continue
+ raise
+
+
+def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
+ return "".join(stream_text_deltas(backend, request))
+
+
+def _get_detailed_error_from_tasks(
+ backend: OpenAIBackendAPI,
+ conversation_id: str,
+ timeout_secs: float = 10.0,
+ wait_secs: float = 2.0,
+) -> str:
+ """从 /backend-api/tasks/ 接口获取结构化错误信息。
+
+ 当 SSE 流检测到 moderation 拦截时,轮询 tasks 接口获取详细错误文本。
+ 使用结构化字段(metadata.is_error, author.role, content.content_type)判断,
+ 而非依赖易变的文本匹配。
+
+ 参数:
+ - `backend`:OpenAIBackendAPI 实例。
+ - `conversation_id`:会话 ID。
+ - `timeout_secs`:请求超时秒数。
+ - `wait_secs`:等待任务创建的秒数。设为 0 可跳过等待。
+
+ 返回:
+ - 详细错误信息文本,如果未找到则返回空字符串。
+ """
+ import time as _time
+ try:
+ if wait_secs > 0:
+ _time.sleep(wait_secs)
+ tasks = backend._query_backend_tasks(conversation_id=conversation_id, timeout_secs=timeout_secs)
+ if not tasks:
+ return ""
+
+ for task in tasks:
+ is_error, error_msg, metadata = backend.check_task_error(task)
+ if is_error and error_msg:
+ logger.info({
+ "event": "image_task_structured_error",
+ "conversation_id": conversation_id,
+ "error_msg": error_msg,
+ "metadata": metadata,
+ })
+ return error_msg
+ return ""
+ except Exception as exc:
+ logger.warning({
+ "event": "image_task_error_query_failed",
+ "conversation_id": conversation_id,
+ "error": str(exc),
+ })
+ return ""
+
+
+def stream_image_outputs(
+ backend: OpenAIBackendAPI,
+ request: ConversationRequest,
+ index: int = 1,
+ total: int = 1,
+) -> Iterator[ImageOutput]:
+ last: dict[str, Any] = {}
+ for event in conversation_events(
+ backend,
+ prompt=request.prompt,
+ model=request.model,
+ images=request.images or [],
+ size=request.size,
+ quality=request.quality,
+ ):
+ last = event
+ if event.get("type") == "conversation.delta":
+ yield ImageOutput(
+ kind="progress",
+ model=request.model,
+ index=index,
+ total=total,
+ text=str(event.get("delta") or ""),
+ upstream_event_type="conversation.delta",
+ )
+ continue
+ if event.get("type") == "conversation.event":
+ raw = event.get("raw")
+ raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
+ yield ImageOutput(
+ kind="progress",
+ model=request.model,
+ index=index,
+ total=total,
+ upstream_event_type=raw_type,
+ )
+
+ conversation_id = str(last.get("conversation_id") or "")
+ file_ids = [str(item) for item in last.get("file_ids") or []]
+ sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
+ message = str(last.get("text") or "").strip()
+ logger.info({
+ "event": "image_stream_resolve_start",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ "tool_invoked": last.get("tool_invoked"),
+ "turn_use_case": last.get("turn_use_case"),
+ })
+ if request.progress_callback:
+ request.progress_callback("image_stream_resolve_start")
+ if message and not file_ids and not sediment_ids and last.get("blocked"):
+ # 尝试从 /backend-api/tasks/ 获取详细错误信息
+ detailed_error = _get_detailed_error_from_tasks(backend, conversation_id)
+ error_text = detailed_error or message or "Image generation was rejected by upstream policy."
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=error_text, conversation_id=conversation_id)
+ return
+ should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen"
+ if message and not file_ids and not sediment_ids and not should_poll_for_image:
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message, conversation_id=conversation_id)
+ return
+
+ # 检测模型是否返回了文本描述(含 referenced_image_ids)而非实际生成图片
+ # 这说明模型已发起图片生成工具调用,但 SSE 在工具完成前断开,
+ # 图片可能正在异步生成中。需要使用更积极的轮询策略来获取结果。
+ is_text_reply = bool(message and is_model_text_reply_instead_of_image(message))
+ if is_text_reply:
+ logger.info({
+ "event": "image_detected_text_reply_with_ids",
+ "conversation_id": conversation_id,
+ "message_preview": message[:200],
+ })
+
+ # 当检测到文本回复但 conversation_id 丢失时,尝试从最近对话列表中恢复
+ # SSE 流太短时(模型返回文本而非触发图片工具),conversation_id 可能未被捕获,
+ # 但图片已在上游异步生成。通过列出最近对话来恢复 conversation_id。
+ if is_text_reply and not conversation_id:
+ try:
+ import time as _time
+ recovered_id = backend.find_conversation_by_prompt(
+ request.prompt, _time.time(), timeout_secs=5.0,
+ )
+ if recovered_id:
+ conversation_id = recovered_id
+ logger.info({
+ "event": "image_conversation_id_recovered",
+ "conversation_id": conversation_id,
+ "message_preview": message[:200],
+ })
+ except Exception as exc:
+ logger.warning({
+ "event": "image_conversation_id_recovery_failed",
+ "error": repr(exc)[:300],
+ })
+
+ # 在轮询图片之前,先检查 /backend-api/tasks/ 是否有 moderation 拦截
+ # 这样可以避免不必要的长时间轮询超时
+ # 注意:当 should_poll_for_image 为 True 或检测到文本回复时,
+ # 即使 tasks 报告了"错误",也不能直接返回——因为上游可能将工具调用的 JSON 参数
+ # (如 {"size":"1792x1024","n":1})标记为 is_error,而实际上图片正在异步生成中。
+ # 此时应继续轮询图片。
+ detailed_error = ""
+ if not file_ids and not sediment_ids and conversation_id:
+ detailed_error = _get_detailed_error_from_tasks(backend, conversation_id, timeout_secs=5.0, wait_secs=1.0)
+ if detailed_error and not should_poll_for_image and not is_text_reply:
+ logger.info({
+ "event": "image_task_error_before_poll",
+ "conversation_id": conversation_id,
+ "error": detailed_error,
+ })
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=detailed_error, conversation_id=conversation_id)
+ return
+ if detailed_error and (should_poll_for_image or is_text_reply):
+ logger.info({
+ "event": "image_task_error_skipped_for_poll",
+ "conversation_id": conversation_id,
+ "error": detailed_error,
+ })
+
+ # 当检测到文本回复(含 referenced_image_ids)时,使用更长的超时来轮询图片结果。
+ # 因为上游可能将图片生成作为异步任务执行,SSE 流在工具完成前就断开了,
+ # 导致对话文档中尚未写入图片工具的响应记录。
+ poll_timeout = config.image_poll_timeout_secs
+ if is_text_reply and conversation_id:
+ # 文本回复场景下图片可能仍在异步生成,使用更长超时(默认 120s → 额外 180s = 300s)
+ poll_timeout = max(poll_timeout, 300)
+ logger.info({
+ "event": "image_text_reply_extended_poll",
+ "conversation_id": conversation_id,
+ "poll_timeout_secs": poll_timeout,
+ })
+
+ try:
+ image_urls = backend.resolve_conversation_image_urls(
+ conversation_id, file_ids, sediment_ids, poll_timeout_secs=poll_timeout,
+ )
+ except (ImageContentPolicyError, ImagePollTimeoutError) as exc:
+ # 当检测到文本回复时,task error 不应直接判定为内容策略违规,
+ # 因为图片可能仍在后台异步生成中
+ if is_text_reply and isinstance(exc, ImageContentPolicyError):
+ logger.warning({
+ "event": "image_text_reply_task_error_ignored",
+ "conversation_id": conversation_id,
+ "error": str(exc),
+ })
+ image_urls = []
+ else:
+ raise
+ except Exception as exc:
+ # 当检测到文本回复时,首次轮询的临时网络错误不应直接中断,
+ # 因为图片可能仍在后台异步生成中,后续 retry poll 会继续尝试。
+ if is_text_reply and conversation_id:
+ logger.warning({
+ "event": "image_text_reply_first_poll_error_ignored",
+ "conversation_id": conversation_id,
+ "error": repr(exc)[:300],
+ })
+ image_urls = []
+ else:
+ raise
+
+ if image_urls:
+ if request.progress_callback:
+ request.progress_callback("receiving_image")
+ image_items = [
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
+ for image_data in backend.download_image_bytes(image_urls)
+ ]
+ data = format_image_result(
+ image_items,
+ request.prompt,
+ request.response_format,
+ request.base_url,
+ int(time.time()),
+ )["data"]
+ if data:
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data, conversation_id=conversation_id)
+ return
+
+ if message:
+ # 检测模型是否返回了文本描述(含 referenced_image_ids)而非实际生成图片
+ # 这说明模型已发起图片生成工具调用,但 SSE 在工具完成前断开。
+ # 此时应再尝试轮询图片结果,而不是直接把文本当作最终输出。
+ # 当 is_text_reply 但 conversation_id 丢失时,尝试从最近对话列表恢复
+ if is_text_reply and not conversation_id:
+ try:
+ import time as _time
+ recovered_id = backend.find_conversation_by_prompt(
+ request.prompt, _time.time(), timeout_secs=5.0,
+ )
+ if recovered_id:
+ conversation_id = recovered_id
+ logger.info({
+ "event": "image_text_reply_conversation_id_recovered",
+ "conversation_id": conversation_id,
+ "message_preview": message[:200],
+ })
+ except Exception as exc:
+ logger.warning({
+ "event": "image_text_reply_conversation_id_recovery_failed",
+ "error": repr(exc)[:300],
+ })
+ if is_text_reply and conversation_id:
+ logger.info({
+ "event": "image_model_text_reply_retry_poll",
+ "conversation_id": conversation_id,
+ "message_preview": message[:200],
+ })
+ # 文本回复场景下,图片可能需要 4-5 分钟才能异步生成完成。
+ # 使用 300s 超时并允许多次重试,避免因临时网络问题提前退出。
+ retry_poll_timeout = max(config.image_poll_timeout_secs, 300)
+ MAX_POLL_RETRIES = 3
+ for poll_attempt in range(1, MAX_POLL_RETRIES + 1):
+ try:
+ polled_file_ids, polled_sediment_ids = backend._poll_image_results(
+ conversation_id,
+ retry_poll_timeout,
+ file_ids,
+ sediment_ids,
+ )
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
+ break # 轮询成功,退出重试循环
+ except Exception as exc:
+ error_str = str(exc)
+ is_transient = (
+ isinstance(exc, ImagePollTimeoutError)
+ or is_tls_connection_error(error_str)
+ or "upstream" in error_str.lower()
+ or "connection" in error_str.lower()
+ or "timeout" in error_str.lower()
+ )
+ logger.warning({
+ "event": "image_model_text_reply_poll_failed",
+ "conversation_id": conversation_id,
+ "poll_attempt": poll_attempt,
+ "error": repr(exc)[:300],
+ "is_transient": is_transient,
+ })
+ # 如果还有重试次数且不是超时/内容违规错误,继续重试
+ if poll_attempt < MAX_POLL_RETRIES and not isinstance(exc, (ImagePollTimeoutError, ImageContentPolicyError)):
+ # 递增退避:30s, 60s, 90s
+ backoff = 30.0 * poll_attempt
+ logger.info({
+ "event": "image_model_text_reply_poll_retry",
+ "conversation_id": conversation_id,
+ "poll_attempt": poll_attempt,
+ "backoff_secs": backoff,
+ })
+ time.sleep(backoff)
+ continue
+ # 超时错误或重试次数用尽,停止重试
+ break
+
+ if file_ids or sediment_ids:
+ image_urls = backend.resolve_conversation_image_urls(
+ conversation_id, file_ids, sediment_ids, poll=False,
+ )
+ if image_urls:
+ if request.progress_callback:
+ request.progress_callback("receiving_image")
+ image_items = [
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
+ for image_data in backend.download_image_bytes(image_urls)
+ ]
+ data = format_image_result(
+ image_items,
+ request.prompt,
+ request.response_format,
+ request.base_url,
+ int(time.time()),
+ )["data"]
+ if data:
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data, conversation_id=conversation_id)
+ return
+ elif is_text_reply:
+ logger.warning({
+ "event": "image_model_text_reply_no_image",
+ "conversation_id": conversation_id,
+ "message_preview": message[:200],
+ })
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message, conversation_id=conversation_id)
+ return
+
+ # 兜底:当 message 为空且图片 URL 解析失败时,先尝试一次短延迟重试轮询
+ # 然后抛出明确错误而非让调用方得到 "upstream completed without generating images" 这种模糊报错
+ logger.warning({
+ "event": "image_stream_no_result_fallback",
+ "conversation_id": conversation_id,
+ "file_ids": file_ids,
+ "sediment_ids": sediment_ids,
+ "should_poll_for_image": should_poll_for_image,
+ })
+ # 当 should_poll_for_image 为 True 但 conversation_id 丢失时,尝试恢复
+ if should_poll_for_image and not conversation_id:
+ try:
+ import time as _time
+ recovered_id = backend.find_conversation_by_prompt(
+ request.prompt, _time.time(), timeout_secs=5.0,
+ )
+ if recovered_id:
+ conversation_id = recovered_id
+ logger.info({
+ "event": "image_fallback_conversation_id_recovered",
+ "conversation_id": conversation_id,
+ })
+ except Exception as exc:
+ logger.warning({
+ "event": "image_fallback_conversation_id_recovery_failed",
+ "error": repr(exc)[:300],
+ })
+ if should_poll_for_image and conversation_id:
+ # 图片可能仍在异步处理中(上游 SSE 流在图片生成完成前就结束了)。
+ # 使用 300s 超时并允许多次重试,避免因临时网络问题或图片尚未提交而提前退出。
+ retry_poll_timeout = max(config.image_poll_timeout_secs, 300)
+ MAX_FALLBACK_POLL_RETRIES = 3
+ for poll_attempt in range(1, MAX_FALLBACK_POLL_RETRIES + 1):
+ retry_wait_secs = min(30.0 * poll_attempt, config.image_poll_initial_wait_secs * poll_attempt)
+ logger.info({
+ "event": "image_stream_retry_poll_after_wait",
+ "conversation_id": conversation_id,
+ "retry_wait_secs": retry_wait_secs,
+ "poll_attempt": poll_attempt,
+ })
+ time.sleep(retry_wait_secs)
+ try:
+ polled_file_ids, polled_sediment_ids = backend._poll_image_results(
+ conversation_id,
+ retry_poll_timeout,
+ file_ids,
+ sediment_ids,
+ )
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
+ break # 轮询成功,退出重试循环
+ except Exception as exc:
+ error_str = str(exc)
+ is_transient = (
+ isinstance(exc, ImagePollTimeoutError)
+ or is_tls_connection_error(error_str)
+ or "upstream" in error_str.lower()
+ or "connection" in error_str.lower()
+ or "timeout" in error_str.lower()
+ )
+ logger.warning({
+ "event": "image_stream_retry_poll_failed",
+ "conversation_id": conversation_id,
+ "poll_attempt": poll_attempt,
+ "error": repr(exc)[:300],
+ "is_transient": is_transient,
+ })
+ # 如果还有重试次数且不是超时/内容违规错误,继续重试
+ if poll_attempt < MAX_FALLBACK_POLL_RETRIES and not isinstance(exc, (ImagePollTimeoutError, ImageContentPolicyError)):
+ # 递增退避:30s, 60s
+ backoff = 30.0 * poll_attempt
+ logger.info({
+ "event": "image_stream_retry_poll_retry",
+ "conversation_id": conversation_id,
+ "poll_attempt": poll_attempt,
+ "backoff_secs": backoff,
+ })
+ time.sleep(backoff)
+ continue
+ # 超时错误或重试次数用尽,停止重试
+ break
+
+ if file_ids or sediment_ids:
+ image_urls = backend.resolve_conversation_image_urls(
+ conversation_id, file_ids, sediment_ids, poll=False,
+ )
+ if image_urls:
+ if request.progress_callback:
+ request.progress_callback("receiving_image")
+ image_items = [
+ {"b64_json": base64.b64encode(image_data).decode("ascii")}
+ for image_data in backend.download_image_bytes(image_urls)
+ ]
+ data = format_image_result(
+ image_items,
+ request.prompt,
+ request.response_format,
+ request.base_url,
+ int(time.time()),
+ )["data"]
+ if data:
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data, conversation_id=conversation_id)
+ return
+
+ # 重试后仍然失败,yield 错误消息
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total,
+ text="Image generation completed upstream but the result could not be retrieved. "
+ "The image may still be processing. Please try again in a moment.",
+ conversation_id=conversation_id)
+ elif message:
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message, conversation_id=conversation_id)
+ else:
+ # conversation_id 也为空时(SSE 流极短、未捕获到会话 ID),
+ # 仍然 yield 一条消息,避免 stream_image_outputs_with_pool 产生
+ # "upstream completed without generating images" 模糊报错
+ yield ImageOutput(kind="message", model=request.model, index=index, total=total,
+ text="Image generation started upstream but the response was incomplete. "
+ "Please try again.",
+ conversation_id=conversation_id)
+
+
+def _codex_response_images(value: Any) -> list[str]:
+ if isinstance(value, dict):
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
+ result = value["result"].strip()
+ if result:
+ return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
+ images: list[str] = []
+ for item in value.values():
+ images.extend(_codex_response_images(item))
+ return images
+ if isinstance(value, list):
+ images: list[str] = []
+ for item in value:
+ images.extend(_codex_response_images(item))
+ return images
+ return []
+
+
+def stream_codex_image_outputs(
+ backend: OpenAIBackendAPI,
+ request: ConversationRequest,
+ index: int = 1,
+ total: int = 1,
+) -> Iterator[ImageOutput]:
+ images = _codex_response_images(list(backend.iter_codex_image_response_events(
+ prompt=request.prompt,
+ images=request.images or [],
+ size=request.size,
+ quality=request.quality,
+ )))
+ if not images:
+ raise ImageGenerationError("No image result found in response")
+ data = format_image_result(
+ [{"b64_json": item, "revised_prompt": request.prompt} for item in images],
+ request.prompt,
+ request.response_format,
+ request.base_url,
+ int(time.time()),
+ )["data"]
+ if data:
+ yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
+ return
+ raise ImageGenerationError("No image result found in response")
+
+
+def _generate_single_image(
+ request: ConversationRequest,
+ index: int,
+ total: int,
+) -> list[ImageOutput]:
+ """为单张图片执行生成逻辑(含重试),返回结果列表。
+
+ 该函数在独立线程中运行,每个线程使用不同的账号,
+ 实现并行生图,避免串行超时阻塞。
+ """
+ # 模型返回文本而非图片的最大重试次数
+ MAX_TEXT_REPLY_RETRIES = 3
+ # TLS 连接错误最大重试次数
+ MAX_TLS_RETRIES = 3
+ # 连接超时错误最大重试次数(同账号短等待重试)
+ MAX_CONN_TIMEOUT_RETRIES = 3
+ # 轮询超时错误最大重试次数(换账号重试)
+ MAX_POLL_TIMEOUT_RETRIES = 4
+
+ text_reply_retry_count = 0
+ tls_retry_count = 0
+ conn_timeout_retry_count = 0
+ poll_timeout_retry_count = 0
+ account_email = ""
+
+ while True:
+ try:
+ if request.progress_callback:
+ request.progress_callback("getting_account")
+ plan_type, _ = split_image_model(request.model)
+ codex_model = is_codex_image_model(request.model)
+ token = account_service.get_available_access_token(
+ plan_type=plan_type,
+ source_type="codex" if codex_model else None,
+ plan_types=("plus", "team", "pro") if codex_model and not plan_type else None,
+ )
+ except RuntimeError as exc:
+ raise ImageGenerationError(str(exc) or "image generation failed", account_email=account_email) from exc
+
+ emitted_for_token = False
+ returned_message = False
+ returned_result = False
+ account = account_service.get_account(token) or {}
+ account_email = str(account.get("email") or "").strip()
+ logger.debug({
+ "event": "image_account_lookup",
+ "token_prefix": token[:12] + "..." if len(token) > 12 else token,
+ "account_email": account_email,
+ "account_found": bool(account),
+ "index": index,
+ })
+ try:
+ backend = OpenAIBackendAPI(access_token=token)
+ if request.progress_callback:
+ backend.progress_callback = request.progress_callback
+ stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
+ outputs: list[ImageOutput] = []
+ for output in stream_fn(backend, request, index, total):
+ if account_email and not output.account_email:
+ output.account_email = account_email
+ if output.kind == "message" and request.message_as_error:
+ raise ImageGenerationError(
+ output.text or "Image generation was rejected by upstream policy.",
+ status_code=400,
+ error_type="invalid_request_error",
+ code="content_policy_violation",
+ account_email=account_email,
+ conversation_id=output.conversation_id,
+ )
+ emitted_for_token = True
+ returned_message = output.kind == "message"
+ returned_result = returned_result or output.kind == "result"
+ outputs.append(output)
+ if returned_message:
+ account_service.mark_image_result(token, False)
+ return outputs
+ if not returned_result:
+ account_service.mark_image_result(token, False)
+ if emitted_for_token:
+ conv_id = outputs[-1].conversation_id if outputs else ""
+ raise ImageGenerationError(
+ "upstream completed without generating images",
+ status_code=400,
+ error_type="invalid_request_error",
+ code="no_image_generated",
+ account_email=account_email,
+ conversation_id=conv_id,
+ )
+ return outputs
+ account_service.mark_image_result(token, True)
+ return outputs
+ except ImagePollTimeoutError as exc:
+ account_service.mark_image_result(token, False)
+ if account_email:
+ setattr(exc, "account_email", account_email)
+ # 轮询超时:换账号重试
+ if not emitted_for_token:
+ poll_timeout_retry_count += 1
+ if poll_timeout_retry_count <= MAX_POLL_TIMEOUT_RETRIES:
+ logger.warning({
+ "event": "image_poll_timeout_retry",
+ "request_token": token,
+ "account_email": account_email,
+ "retry_count": poll_timeout_retry_count,
+ "index": index,
+ "error": str(exc)[:200],
+ })
+ continue
+ logger.warning({
+ "event": "image_poll_timeout_exhausted_retries",
+ "request_token": token,
+ "account_email": account_email,
+ "retry_count": poll_timeout_retry_count,
+ "index": index,
+ })
+ raise
+ raise
+ except ImageContentPolicyError as exc:
+ account_service.mark_image_result(token, False)
+ logger.warning({
+ "event": "image_stream_content_policy_error",
+ "request_token": token,
+ "account_email": account_email,
+ "error": str(exc),
+ "index": index,
+ })
+ raise ImageGenerationError(
+ str(exc) or "Image generation was rejected by upstream policy.",
+ status_code=400,
+ error_type="invalid_request_error",
+ code="content_policy_violation",
+ account_email=account_email,
+ conversation_id=getattr(exc, "conversation_id", ""),
+ ) from exc
+ except ImageGenerationError as exc:
+ account_service.mark_image_result(token, False)
+ if account_email and not getattr(exc, "account_email", ""):
+ exc.account_email = account_email
+ error_text = str(exc)
+ # 如果是模型返回文本而非图片,尝试换账号重试
+ if is_model_text_reply_instead_of_image(error_text) and not emitted_for_token:
+ text_reply_retry_count += 1
+ if text_reply_retry_count <= MAX_TEXT_REPLY_RETRIES:
+ logger.warning({
+ "event": "image_model_text_reply_retry",
+ "request_token": token,
+ "account_email": account_email,
+ "retry_count": text_reply_retry_count,
+ "index": index,
+ "error": error_text[:200],
+ })
+ continue
+ logger.warning({
+ "event": "image_model_text_reply_exhausted_retries",
+ "request_token": token,
+ "account_email": account_email,
+ "retry_count": text_reply_retry_count,
+ "index": index,
+ })
+ raise ImageGenerationError(
+ "Image generation failed: the upstream model returned a text description "
+ "instead of generating an image. Please try again later.",
+ status_code=502,
+ error_type="server_error",
+ code="upstream_text_reply",
+ account_email=account_email,
+ conversation_id=getattr(exc, "conversation_id", ""),
+ ) from exc
+ logger.warning({
+ "event": "image_stream_generation_error",
+ "request_token": token,
+ "account_email": account_email,
+ "error": error_text,
+ "index": index,
+ })
+ raise
+ except Exception as exc:
+ account_service.mark_image_result(token, False)
+ last_error = str(exc)
+ logger.warning({
+ "event": "image_stream_fail",
+ "request_token": token,
+ "account_email": account_email,
+ "error": last_error,
+ "index": index,
+ })
+ if not emitted_for_token and is_token_invalid_error(last_error):
+ refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
+ if refreshed_token and refreshed_token != token:
+ token = refreshed_token
+ continue
+ account_service.remove_invalid_token(token, "image_stream")
+ continue
+ # TLS/SSL 连接错误:自动重试
+ if not emitted_for_token and is_tls_connection_error(last_error):
+ tls_retry_count += 1
+ if tls_retry_count <= MAX_TLS_RETRIES:
+ logger.warning({
+ "event": "image_stream_tls_retry",
+ "request_token": token,
+ "account_email": account_email,
+ "retry_count": tls_retry_count,
+ "index": index,
+ "error": last_error[:200],
+ })
+ time.sleep(min(2.0 * tls_retry_count, 10.0))
+ continue
+ # 连接超时错误(curl 28):同账号短等待重试,不切换账号
+ if not emitted_for_token and is_connection_timeout_error(last_error):
+ conn_timeout_retry_count += 1
+ if conn_timeout_retry_count <= MAX_CONN_TIMEOUT_RETRIES:
+ wait_secs = min(3.0 * conn_timeout_retry_count, 9.0)
+ logger.warning({
+ "event": "image_stream_conn_timeout_retry",
+ "request_token": token,
+ "account_email": account_email,
+ "retry_count": conn_timeout_retry_count,
+ "index": index,
+ "wait_secs": wait_secs,
+ "error": last_error[:200],
+ })
+ time.sleep(wait_secs)
+ continue
+ raise ImageGenerationError(image_stream_error_message(last_error), account_email=account_email, conversation_id="") from exc
+
+
+def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
+ """并行生成多张图片,每张图片使用独立线程和账号,互不阻塞。"""
+ if not is_supported_image_model(request.model):
+ raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS)))
+
+ if request.n <= 1:
+ # 单张图片,直接执行(无需线程池开销)
+ outputs = _generate_single_image(request, 1, 1)
+ for output in outputs:
+ yield output
+ return
+
+ # 多张图片:根据配置选择并行或串行执行
+ if not config.image_parallel_generation:
+ logger.info({
+ "event": "image_serial_generation_start",
+ "n": request.n,
+ "model": request.model,
+ })
+ for index in range(1, request.n + 1):
+ outputs = _generate_single_image(request, index, request.n)
+ for output in outputs:
+ yield output
+ return
+
+ logger.info({
+ "event": "image_parallel_generation_start",
+ "n": request.n,
+ "model": request.model,
+ })
+ # 每张图片一个线程,同时启动
+ futures = {}
+ results: dict[int, list[ImageOutput]] = {}
+ errors: dict[int, Exception] = {}
+ with ThreadPoolExecutor(max_workers=request.n) as executor:
+ for index in range(1, request.n + 1):
+ future = executor.submit(_generate_single_image, request, index, request.n)
+ futures[future] = index
+
+ # 按完成顺序收集结果
+ for future in as_completed(futures):
+ index = futures[future]
+ try:
+ results[index] = future.result()
+ except Exception as exc:
+ errors[index] = exc
+ logger.warning({
+ "event": "image_parallel_generation_error",
+ "index": index,
+ "error": str(exc)[:300],
+ })
+
+ # yield 结果:跳过索引顺序限制,不再让低索引失败阻塞高索引成功结果
+ emitted = False
+ last_error = ""
+ # 先 yield 所有成功的结果
+ for index in range(1, request.n + 1):
+ if index in results:
+ for output in results[index]:
+ emitted = True
+ yield output
+ elif index in errors:
+ last_error = str(errors[index])
+ if not emitted:
+ logger.warning({
+ "event": "image_parallel_failure_before_success",
+ "failed_index": index,
+ "error": last_error[:200],
+ })
+
+ # 如果有失败但也有成功,记录警告
+ if emitted:
+ for index in range(1, request.n + 1):
+ if index in errors:
+ logger.warning({
+ "event": "image_parallel_partial_failure",
+ "failed_index": index,
+ "error": str(errors[index])[:200],
+ })
+
+ if not emitted:
+ if not last_error:
+ last_error = "no account in the pool could generate images — check account quota and rate-limit status"
+ raise ImageGenerationError(image_stream_error_message(last_error), conversation_id="")
+
+
+def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
+ for output in outputs:
+ yield output.to_chunk()
+
+
+def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
+ created = None
+ data: list[dict[str, Any]] = []
+ message = ""
+ progress_parts: list[str] = []
+ account_email = ""
+ for output in outputs:
+ created = created or output.created
+ if output.account_email and not account_email:
+ account_email = output.account_email
+ if output.kind == "progress" and output.text:
+ progress_parts.append(output.text)
+ elif output.kind == "message":
+ message = output.text
+ elif output.kind == "result":
+ data.extend(output.data)
+
+ result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
+ if not data:
+ text = message or "".join(progress_parts).strip()
+ if text:
+ result["message"] = text
+ if account_email:
+ result["_account_email"] = account_email
+ return result
diff --git a/services/protocol/error_response.py b/services/protocol/error_response.py
new file mode 100644
index 0000000000000000000000000000000000000000..53fde95356c4e95172225c71e368d31105447c48
--- /dev/null
+++ b/services/protocol/error_response.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi.responses import JSONResponse
+
+
+def _message_from_value(value: object) -> str:
+ if isinstance(value, str):
+ return value
+ if not isinstance(value, dict):
+ return ""
+ message = value.get("message")
+ if isinstance(message, str) and message:
+ return message
+ return _message_from_value(value.get("error"))
+
+
+def error_message_from_detail(detail: object) -> str:
+ if isinstance(detail, list):
+ messages = []
+ for item in detail:
+ if not isinstance(item, dict):
+ continue
+ location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
+ message = str(item.get("msg") or "").strip()
+ if location and message:
+ messages.append(f"{location}: {message}")
+ elif message:
+ messages.append(message)
+ return "; ".join(messages)
+ if isinstance(detail, dict):
+ message = _message_from_value(detail.get("error")) or _message_from_value(detail)
+ if message:
+ return message
+ return str(detail or "").strip()
+
+
+def _default_error_type(status_code: int) -> str:
+ if status_code == 401:
+ return "authentication_error"
+ if status_code == 403:
+ return "permission_error"
+ if status_code == 429:
+ return "rate_limit_error"
+ if 400 <= status_code < 500:
+ return "invalid_request_error"
+ return "server_error"
+
+
+def _default_error_code(status_code: int) -> str:
+ if status_code == 401:
+ return "invalid_api_key"
+ if status_code == 403:
+ return "permission_denied"
+ if status_code == 429:
+ return "rate_limit_exceeded"
+ if 400 <= status_code < 500:
+ return "bad_request"
+ return "upstream_error"
+
+
+def openai_error_payload(
+ detail: object,
+ status_code: int,
+ *,
+ error_type: str | None = None,
+ code: object | None = None,
+ param: object | None = None,
+) -> dict[str, Any]:
+ error_detail = detail.get("error") if isinstance(detail, dict) else None
+ if isinstance(error_detail, dict):
+ return {
+ "error": {
+ "message": error_message_from_detail(error_detail) or "request failed",
+ "type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
+ "param": error_detail.get("param", param),
+ "code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
+ }
+ }
+ return {
+ "error": {
+ "message": error_message_from_detail(detail) or "request failed",
+ "type": error_type or _default_error_type(status_code),
+ "param": param,
+ "code": code if code is not None else _default_error_code(status_code),
+ }
+ }
+
+
+def openai_error_response(
+ detail: object,
+ status_code: int,
+ *,
+ headers: dict[str, str] | None = None,
+ error_type: str | None = None,
+ code: object | None = None,
+ param: object | None = None,
+) -> JSONResponse:
+ return JSONResponse(
+ status_code=status_code,
+ content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
+ headers=headers,
+ )
+
+
+def anthropic_error_response(
+ detail: object,
+ status_code: int,
+ *,
+ headers: dict[str, str] | None = None,
+) -> JSONResponse:
+ error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
+ return JSONResponse(
+ status_code=status_code,
+ content={
+ "type": "error",
+ "error": {
+ "type": error_type,
+ "message": error_message_from_detail(detail) or "request failed",
+ },
+ },
+ headers=headers,
+ )
diff --git a/services/protocol/openai_search.py b/services/protocol/openai_search.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa9f484200b5be0b4ddb96e835b2f5ed7db87226
--- /dev/null
+++ b/services/protocol/openai_search.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from services.account_service import account_service
+from services.openai_backend_api import OpenAIBackendAPI, SEARCH_MODEL
+
+MODEL = SEARCH_MODEL
+
+
+def handle(body: dict[str, object]) -> dict[str, object]:
+ token = account_service.get_text_access_token()
+ account = account_service.get_account(token) or {}
+ result = OpenAIBackendAPI(token).search(str(body["prompt"]))
+ account_service.mark_text_used(token)
+ result["_account_email"] = str(account.get("email") or "")
+ return result
diff --git a/services/protocol/openai_v1_chat_complete.py b/services/protocol/openai_v1_chat_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..f7ca73f0228e089f874be27930d18af59b094b14
--- /dev/null
+++ b/services/protocol/openai_v1_chat_complete.py
@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+import time
+import uuid
+from typing import Any, Iterable, Iterator
+
+from fastapi import HTTPException
+
+from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
+from services.protocol.conversation import (
+ ConversationRequest,
+ ImageOutput,
+ collect_image_outputs,
+ collect_text,
+ count_message_image_tokens,
+ count_message_text_tokens,
+ count_text_tokens,
+ encode_images,
+ normalize_messages,
+ stream_image_outputs_with_pool,
+ stream_text_deltas,
+ text_backend,
+)
+from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
+from utils.image_tokens import (
+ chat_usage_from_image_usage,
+ count_image_inputs_tokens,
+ count_image_output_items_tokens,
+ image_usage,
+)
+
+TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
+ "This compatibility backend cannot execute local tools, shell commands, web searches, "
+ "or file operations. Do not claim to have run tools or inspected external resources. "
+ "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
+)
+
+
+def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
+ return {
+ "id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
+ "object": "chat.completion.chunk",
+ "created": created or int(time.time()),
+ "model": model,
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
+ }
+
+
+def completion_response(
+ model: str,
+ content: str,
+ created: int | None = None,
+ messages: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ prompt_text_tokens = count_message_text_tokens(messages, model) if messages else 0
+ prompt_image_tokens = count_message_image_tokens(messages, model) if messages else 0
+ prompt_tokens = prompt_text_tokens + prompt_image_tokens
+ completion_tokens = count_text_tokens(content, model) if messages else 0
+ return {
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
+ "object": "chat.completion",
+ "created": created or int(time.time()),
+ "model": model,
+ "choices": [{
+ "index": 0,
+ "message": {"role": "assistant", "content": content},
+ "finish_reason": "stop",
+ }],
+ "usage": {
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ "total_tokens": prompt_tokens + completion_tokens,
+ "prompt_tokens_details": {
+ "text_tokens": prompt_text_tokens,
+ "image_tokens": prompt_image_tokens,
+ "cached_tokens": 0,
+ },
+ "completion_tokens_details": {
+ "text_tokens": completion_tokens,
+ "image_tokens": 0,
+ "reasoning_tokens": 0,
+ },
+ },
+ }
+
+
+def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
+ completion_id = f"chatcmpl-{uuid.uuid4().hex}"
+ created = int(time.time())
+ sent_role = False
+ request = ConversationRequest(model=model, messages=messages)
+ for delta_text in stream_text_deltas(backend, request):
+ if not sent_role:
+ sent_role = True
+ yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
+ else:
+ yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
+ if not sent_role:
+ yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
+ yield completion_chunk(model, {}, "stop", completion_id, created)
+
+
+def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
+ parts: list[str] = []
+ for chunk in chunks:
+ choices = chunk.get("choices")
+ first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
+ delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
+ content = str(delta.get("content") or "")
+ if content:
+ parts.append(content)
+ return "".join(parts)
+
+
+def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
+ messages = body.get("messages")
+ if isinstance(messages, list) and messages:
+ return [message for message in messages if isinstance(message, dict)]
+ prompt = str(body.get("prompt") or "").strip()
+ if prompt:
+ return [{"role": "user", "content": prompt}]
+ raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
+
+
+def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
+ model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
+ prompt = extract_chat_prompt(body)
+ if not prompt:
+ raise HTTPException(status_code=400, detail={"error": "prompt is required"})
+ images = [
+ (data, f"image_{idx}.png", mime)
+ for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
+ ]
+ return model, prompt, parse_image_count(body.get("n")), images
+
+
+def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
+ model = str(body.get("model") or "auto").strip() or "auto"
+ messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body)))
+ tools = body.get("tools")
+ if isinstance(tools, list) and tools:
+ messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
+ return model, messages
+
+
+def image_result_content(result: dict[str, Any]) -> str:
+ data = result.get("data")
+ if isinstance(data, list) and data:
+ return build_chat_image_markdown_content(result)
+ return str(result.get("message") or "Image generation completed.")
+
+
+def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
+ model, prompt, n, images = chat_image_args(body)
+ result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
+ prompt=prompt,
+ model=model,
+ n=n,
+ response_format="b64_json",
+ images=encode_images(images) or None,
+ )))
+ response = completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
+ usage = image_usage(
+ input_text_tokens=count_text_tokens(prompt, model),
+ input_image_tokens=count_image_inputs_tokens(images, model),
+ output_tokens=count_image_output_items_tokens(result.get("data")),
+ )
+ response["usage"] = chat_usage_from_image_usage(usage)
+ return response
+
+
+def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
+ model, prompt, n, images = chat_image_args(body)
+ image_outputs = stream_image_outputs_with_pool(ConversationRequest(
+ prompt=prompt,
+ model=model,
+ n=n,
+ response_format="b64_json",
+ images=encode_images(images) or None,
+ ))
+ yield from stream_image_chat_completion(image_outputs, model)
+
+
+def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
+ completion_id = f"chatcmpl-{uuid.uuid4().hex}"
+ created = int(time.time())
+ sent_role = False
+ sent_text = ""
+ for output in image_outputs:
+ content = ""
+ if output.kind == "progress":
+ content = output.text
+ sent_text += content
+ elif output.kind == "result":
+ content = build_chat_image_markdown_content({"data": output.data})
+ elif output.kind == "message":
+ content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
+ if not content:
+ continue
+ if not sent_role:
+ sent_role = True
+ yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
+ else:
+ yield completion_chunk(model, {"content": content}, None, completion_id, created)
+ if not sent_role:
+ yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
+ yield completion_chunk(model, {}, "stop", completion_id, created)
+
+
+def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
+ if body.get("stream"):
+ if is_image_chat_request(body):
+ return image_chat_events(body)
+ model, messages = text_chat_parts(body)
+ key = cache_key(body, messages, stream=True)
+ return chat_completion_cache.get_or_compute_stream(
+ key,
+ lambda: stream_text_chat_completion(text_backend(), messages, model),
+ )
+ if is_image_chat_request(body):
+ return image_chat_response(body)
+ model, messages = text_chat_parts(body)
+ key = cache_key(body, messages, stream=False)
+ return chat_completion_cache.get_or_compute_response(
+ key,
+ lambda: completion_response(
+ model,
+ collect_text(text_backend(), ConversationRequest(model=model, messages=messages)),
+ messages=messages,
+ ),
+ )
diff --git a/services/protocol/openai_v1_image_edit.py b/services/protocol/openai_v1_image_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..8555d82735a530217eca2bdbd803add5b89235f5
--- /dev/null
+++ b/services/protocol/openai_v1_image_edit.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from typing import Any, Iterator
+
+from services.protocol.conversation import (
+ ConversationRequest,
+ ImageGenerationError,
+ collect_image_outputs,
+ count_text_tokens,
+ encode_images,
+ stream_image_chunks,
+ stream_image_outputs_with_pool,
+)
+from utils.image_tokens import count_image_inputs_tokens, count_image_output_items_tokens, image_usage
+
+
+def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
+ prompt = str(body.get("prompt") or "")
+ images = body.get("images") or []
+ model = str(body.get("model") or "gpt-image-2")
+ n = int(body.get("n") or 1)
+ size = body.get("size")
+ quality = str(body.get("quality") or "auto")
+ response_format = str(body.get("response_format") or "b64_json")
+ base_url = str(body.get("base_url") or "") or None
+ progress_callback = body.get("progress_callback")
+ encoded_images = encode_images(images)
+ if not encoded_images:
+ raise ImageGenerationError("image is required")
+ outputs = stream_image_outputs_with_pool(ConversationRequest(
+ prompt=prompt,
+ model=model,
+ n=n,
+ size=size,
+ quality=quality,
+ response_format=response_format,
+ base_url=base_url,
+ images=encoded_images,
+ message_as_error=True,
+ progress_callback=progress_callback,
+ ))
+ if body.get("stream"):
+ return stream_image_chunks(outputs)
+ result = collect_image_outputs(outputs)
+ result["usage"] = image_usage(
+ input_text_tokens=count_text_tokens(prompt, model),
+ input_image_tokens=count_image_inputs_tokens(images, model),
+ output_tokens=count_image_output_items_tokens(result.get("data"), size, quality),
+ )
+ return result
diff --git a/services/protocol/openai_v1_image_generations.py b/services/protocol/openai_v1_image_generations.py
new file mode 100644
index 0000000000000000000000000000000000000000..a14c9f37aca52af708eeabe48d479ad1da1b6d1f
--- /dev/null
+++ b/services/protocol/openai_v1_image_generations.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from typing import Any, Iterator
+
+from services.protocol.conversation import (
+ ConversationRequest,
+ collect_image_outputs,
+ count_text_tokens,
+ stream_image_chunks,
+ stream_image_outputs_with_pool,
+)
+from utils.image_tokens import count_image_output_items_tokens, image_usage
+
+
+def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
+ prompt = str(body.get("prompt") or "")
+ model = str(body.get("model") or "gpt-image-2")
+ n = int(body.get("n") or 1)
+ size = body.get("size")
+ quality = str(body.get("quality") or "auto")
+ response_format = str(body.get("response_format") or "b64_json")
+ base_url = str(body.get("base_url") or "") or None
+ progress_callback = body.get("progress_callback")
+ outputs = stream_image_outputs_with_pool(ConversationRequest(
+ prompt=prompt,
+ model=model,
+ n=n,
+ size=size,
+ quality=quality,
+ response_format=response_format,
+ base_url=base_url,
+ message_as_error=True,
+ progress_callback=progress_callback,
+ ))
+ if body.get("stream"):
+ return stream_image_chunks(outputs)
+ result = collect_image_outputs(outputs)
+ result["usage"] = image_usage(
+ input_text_tokens=count_text_tokens(prompt, model),
+ output_tokens=count_image_output_items_tokens(result.get("data"), size, quality),
+ )
+ return result
diff --git a/services/protocol/openai_v1_models.py b/services/protocol/openai_v1_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..07fadab500a189fae5e1b3e4be5c727cddafb7ca
--- /dev/null
+++ b/services/protocol/openai_v1_models.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing import Any
+
+from services.account_service import account_service
+from services.openai_backend_api import OpenAIBackendAPI
+from utils.helper import CODEX_IMAGE_MODEL
+
+
+def list_models() -> dict[str, Any]:
+ result = OpenAIBackendAPI().list_models()
+ data = result.get("data")
+ if not isinstance(data, list):
+ return result
+ seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
+ dynamic_models: set[str] = set()
+ accounts = account_service.list_accounts()
+ web_image_accounts = [
+ account
+ for account in accounts
+ if isinstance(account, dict)
+ ]
+ codex_types = {
+ normalized
+ for account in accounts
+ if isinstance(account, dict)
+ and account_service._normalize_source_type(account.get("source_type")) == "codex"
+ and (normalized := account_service._normalize_account_type(account.get("type")))
+ }
+
+ if web_image_accounts:
+ dynamic_models.add("gpt-image-2")
+ if codex_types & {"Plus", "Team", "Pro"}:
+ dynamic_models.add(CODEX_IMAGE_MODEL)
+ if "Plus" in codex_types:
+ dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}")
+ if "Team" in codex_types:
+ dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}")
+ if "Pro" in codex_types:
+ dynamic_models.add(f"pro-{CODEX_IMAGE_MODEL}")
+
+ for model in sorted(dynamic_models):
+ if model not in seen:
+ data.append({
+ "id": model,
+ "object": "model",
+ "created": 0,
+ "owned_by": "chatgpt2api",
+ "permission": [],
+ "root": model,
+ "parent": None,
+ })
+ return result
diff --git a/services/protocol/openai_v1_response.py b/services/protocol/openai_v1_response.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6b715b4abb543035eea4e375ed612f261abaebc
--- /dev/null
+++ b/services/protocol/openai_v1_response.py
@@ -0,0 +1,356 @@
+from __future__ import annotations
+
+import time
+import uuid
+from typing import Any, Iterable, Iterator
+
+from fastapi import HTTPException
+
+from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
+from services.protocol.conversation import (
+ ConversationRequest,
+ ImageOutput,
+ count_message_image_tokens,
+ count_message_text_tokens,
+ count_text_tokens,
+ encode_images,
+ normalize_messages,
+ stream_image_outputs_with_pool,
+ stream_text_deltas,
+ text_backend,
+)
+from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool
+from utils.image_tokens import (
+ count_image_content_tokens,
+ count_image_output_items_tokens,
+ image_usage,
+ token_usage,
+)
+
+TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
+ "This compatibility backend cannot execute local tools, shell commands, web searches, "
+ "or file operations. Do not claim to have run tools or inspected external resources. "
+ "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
+)
+
+RESPONSE_CONTENT_PART_TYPES = {"text", "input_text", "output_text", "image_url", "input_image", "image"}
+
+
+def is_text_response_request(body: dict[str, Any]) -> bool:
+ return not has_response_image_generation_tool(body)
+
+
+def has_non_image_tools(body: dict[str, Any]) -> bool:
+ tools = body.get("tools")
+ if not isinstance(tools, list):
+ return False
+ return any(
+ isinstance(tool, dict) and str(tool.get("type") or "").strip() != "image_generation"
+ for tool in tools
+ )
+
+
+def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
+ for tool in body.get("tools") or []:
+ if isinstance(tool, dict) and tool.get("type") == "image_generation":
+ return tool
+ return {}
+
+
+def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
+ if isinstance(input_value, dict):
+ if str(input_value.get("type") or "").strip() == "input_image":
+ images = extract_image_from_message_content([input_value])
+ return images[0] if images else None
+ images = extract_image_from_message_content(input_value.get("content"))
+ return images[0] if images else None
+ if not isinstance(input_value, list):
+ return None
+ for item in reversed(input_value):
+ if isinstance(item, dict):
+ if str(item.get("type") or "").strip() == "input_image":
+ images = extract_image_from_message_content([item])
+ if images:
+ return images[0]
+ images = extract_image_from_message_content(item.get("content"))
+ if images:
+ return images[0]
+ return None
+
+
+def _input_image_parts(input_value: object) -> list[dict[str, Any]]:
+ parts: list[dict[str, Any]] = []
+ if isinstance(input_value, dict):
+ content = input_value.get("content")
+ if isinstance(content, list):
+ parts.extend(item for item in content if isinstance(item, dict))
+ return parts
+ if not isinstance(input_value, list):
+ return parts
+ if all(isinstance(item, dict) and item.get("type") for item in input_value):
+ return [item for item in input_value if isinstance(item, dict)]
+ for item in input_value:
+ if isinstance(item, dict):
+ content = item.get("content")
+ if isinstance(content, list):
+ parts.extend(part for part in content if isinstance(part, dict))
+ return parts
+
+
+def _is_response_content_part(value: object) -> bool:
+ if not isinstance(value, dict):
+ return False
+ part_type = str(value.get("type") or "").strip()
+ return part_type in RESPONSE_CONTENT_PART_TYPES or ("image_url" in value and part_type != "message")
+
+
+def _message_content_from_response_item(item: dict[str, Any]) -> object:
+ content = item.get("content")
+ if isinstance(content, list):
+ return [dict(part) if isinstance(part, dict) else part for part in content]
+ if isinstance(content, str):
+ return content
+ return extract_response_prompt([item]) or content or ""
+
+
+def _append_response_message(messages: list[dict[str, Any]], role: object, content: object) -> None:
+ if isinstance(content, str):
+ if content.strip():
+ messages.append({"role": str(role or "user"), "content": content.strip()})
+ return
+ if isinstance(content, list) and content:
+ messages.append({"role": str(role or "user"), "content": content})
+
+
+def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
+ messages: list[dict[str, Any]] = []
+ system_text = str(instructions or "").strip()
+ if system_text:
+ messages.append({"role": "system", "content": system_text})
+ if isinstance(input_value, str):
+ if input_value.strip():
+ messages.append({"role": "user", "content": input_value.strip()})
+ return messages
+ if isinstance(input_value, dict):
+ if _is_response_content_part(input_value):
+ _append_response_message(messages, "user", [dict(input_value)])
+ return messages
+ _append_response_message(
+ messages,
+ input_value.get("role") or "user",
+ _message_content_from_response_item(input_value),
+ )
+ return messages
+ if isinstance(input_value, list):
+ if all(_is_response_content_part(item) for item in input_value):
+ _append_response_message(messages, "user", [dict(item) for item in input_value if isinstance(item, dict)])
+ return messages
+ pending_parts: list[dict[str, Any]] = []
+ for item in input_value:
+ if _is_response_content_part(item):
+ pending_parts.append(dict(item))
+ continue
+ if pending_parts:
+ _append_response_message(messages, "user", pending_parts)
+ pending_parts = []
+ if not isinstance(item, dict):
+ continue
+ _append_response_message(
+ messages,
+ item.get("role") or "user",
+ _message_content_from_response_item(item),
+ )
+ if pending_parts:
+ _append_response_message(messages, "user", pending_parts)
+ return messages
+
+
+def text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, Any]:
+ return {
+ "id": item_id or f"msg_{uuid.uuid4().hex}",
+ "type": "message",
+ "status": status,
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": text, "annotations": []}],
+ }
+
+
+def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
+ output = []
+ for item in data:
+ b64_json = str(item.get("b64_json") or "").strip()
+ if b64_json:
+ output.append({
+ "id": item_id or f"ig_{len(output) + 1}",
+ "type": "image_generation_call",
+ "status": "completed",
+ "result": b64_json,
+ "revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
+ })
+ return output
+
+
+def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
+ return {
+ "type": "response.created",
+ "response": {
+ "id": response_id,
+ "object": "response",
+ "created_at": created,
+ "status": "in_progress",
+ "error": None,
+ "incomplete_details": None,
+ "model": model,
+ "output": [],
+ "parallel_tool_calls": False,
+ },
+ }
+
+
+def response_completed(
+ response_id: str,
+ model: str,
+ created: int,
+ output: list[dict[str, Any]],
+ usage: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ response = {
+ "type": "response.completed",
+ "response": {
+ "id": response_id,
+ "object": "response",
+ "created_at": created,
+ "status": "completed",
+ "error": None,
+ "incomplete_details": None,
+ "model": model,
+ "output": output,
+ "parallel_tool_calls": False,
+ },
+ }
+ if usage:
+ response["response"]["usage"] = usage
+ return response
+
+
+def text_response_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
+ model = str(body.get("model") or "auto").strip() or "auto"
+ messages = normalize_text_messages(normalize_messages(messages_from_input(body.get("input"), body.get("instructions"))))
+ if has_non_image_tools(body):
+ messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
+ return model, messages
+
+
+def stream_text_response(backend, body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]:
+ model = str(body.get("model") or "auto").strip() or "auto"
+ messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions"))
+ response_id = f"resp_{uuid.uuid4().hex}"
+ item_id = f"msg_{uuid.uuid4().hex}"
+ created = int(time.time())
+ full_text = ""
+ yield response_created(response_id, model, created)
+ yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
+ request = ConversationRequest(model=model, messages=messages)
+ for delta in stream_text_deltas(backend, request):
+ full_text += delta
+ yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
+ yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
+ item = text_output_item(full_text, item_id, "completed")
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
+ usage = token_usage(
+ input_text_tokens=count_message_text_tokens(messages, model),
+ input_image_tokens=count_message_image_tokens(messages, model),
+ output_text_tokens=count_text_tokens(full_text, model),
+ )
+ yield response_completed(response_id, model, created, [item], usage)
+
+
+def stream_image_response(
+ image_outputs: Iterable[ImageOutput],
+ prompt: str,
+ model: str,
+ input_image_tokens: int = 0,
+ size: object = None,
+ quality: str = "auto",
+) -> Iterator[dict[str, Any]]:
+ response_id = f"resp_{uuid.uuid4().hex}"
+ created = int(time.time())
+ yield response_created(response_id, model, created)
+ for output in image_outputs:
+ if output.kind == "message":
+ text = output.text
+ item = text_output_item(text)
+ usage = token_usage(
+ input_text_tokens=count_text_tokens(prompt, model),
+ input_image_tokens=input_image_tokens,
+ output_text_tokens=count_text_tokens(text, model),
+ )
+ yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
+ yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
+ yield {"type": "response.output_item.done", "output_index": 0, "item": item}
+ yield response_completed(response_id, model, created, [item], usage)
+ return
+ if output.kind != "result":
+ continue
+ items = image_output_items(prompt, output.data)
+ if items:
+ usage = image_usage(
+ input_text_tokens=count_text_tokens(prompt, model),
+ input_image_tokens=input_image_tokens,
+ output_tokens=count_image_output_items_tokens(output.data, size, quality),
+ )
+ for output_index, item in enumerate(items):
+ yield {"type": "response.output_item.done", "output_index": output_index, "item": item}
+ yield response_completed(response_id, model, created, items, usage)
+ return
+ raise RuntimeError("image generation failed")
+
+
+def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
+ completed = {}
+ for event in events:
+ if event.get("type") == "response.completed":
+ completed = event.get("response") if isinstance(event.get("response"), dict) else {}
+ if not completed:
+ raise RuntimeError("response generation failed")
+ return completed
+
+
+def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
+ if is_text_response_request(body):
+ model, messages = text_response_parts(body)
+ key = cache_key(body, messages, stream=bool(body.get("stream")))
+ yield from chat_completion_cache.get_or_compute_stream(
+ key,
+ lambda: stream_text_response(text_backend(), body, messages),
+ )
+ return
+
+ prompt = extract_response_prompt(body.get("input"))
+ if not prompt:
+ raise HTTPException(status_code=400, detail={"error": "input text is required"})
+ model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
+ image_info = extract_response_image(body.get("input"))
+ if image_info:
+ image_data, mime_type = image_info
+ images = encode_images([(image_data, "image.png", mime_type)])
+ else:
+ images = None
+ input_image_tokens = count_image_content_tokens(_input_image_parts(body.get("input")), model)
+ tool = response_image_tool(body)
+ image_outputs = stream_image_outputs_with_pool(ConversationRequest(
+ prompt=prompt,
+ model=model,
+ size=tool.get("size"),
+ quality=str(tool.get("quality") or "auto"),
+ response_format="b64_json",
+ images=images,
+ ))
+ yield from stream_image_response(image_outputs, prompt, model, input_image_tokens, tool.get("size"), str(tool.get("quality") or "auto"))
+
+
+def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
+ events = response_events(body)
+ if body.get("stream"):
+ return events
+ return collect_response(events)
diff --git a/services/proxy_service.py b/services/proxy_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..b851c66f4abffc54976bc4a48b64a159f98d47a2
--- /dev/null
+++ b/services/proxy_service.py
@@ -0,0 +1,64 @@
+"""Global outbound proxy helpers for upstream ChatGPT and CPA requests."""
+
+from __future__ import annotations
+
+import time
+from urllib.parse import urlparse
+
+from curl_cffi.requests import Session
+
+from services.config import config
+
+
+class ProxySettingsStore:
+ def build_session_kwargs(self, account: dict | None = None, proxy: str = "", **session_kwargs) -> dict[str, object]:
+ account_proxy = str((account or {}).get("proxy") or "").strip()
+ proxy = str(proxy or account_proxy or config.get_proxy_settings()).strip()
+ if proxy:
+ session_kwargs["proxy"] = proxy
+ return session_kwargs
+
+
+def _clean(value: object) -> str:
+ return str(value or "").strip()
+
+
+def _is_valid_proxy_url(url: str) -> bool:
+ parsed = urlparse(url)
+ return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc)
+
+
+def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
+ candidate = _clean(url)
+ if not candidate:
+ return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"}
+ if not _is_valid_proxy_url(candidate):
+ return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"}
+ session = Session(impersonate="edge101", verify=True, proxy=candidate)
+ started = time.perf_counter()
+ try:
+ response = session.get(
+ "https://chatgpt.com/api/auth/csrf",
+ headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"},
+ timeout=timeout,
+ )
+ latency_ms = int((time.perf_counter() - started) * 1000)
+ return {
+ "ok": response.status_code < 500,
+ "status": int(response.status_code),
+ "latency_ms": latency_ms,
+ "error": None if response.status_code < 500 else f"HTTP {response.status_code}",
+ }
+ except Exception as exc:
+ latency_ms = int((time.perf_counter() - started) * 1000)
+ return {
+ "ok": False,
+ "status": 0,
+ "latency_ms": latency_ms,
+ "error": str(exc) or exc.__class__.__name__,
+ }
+ finally:
+ session.close()
+
+proxy_settings = ProxySettingsStore()
+
diff --git a/services/register/__init__.py b/services/register/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/services/register/mail_provider.py b/services/register/mail_provider.py
new file mode 100644
index 0000000000000000000000000000000000000000..6b7ea7e62ee676f76b7cea0fc764938c5823cb34
--- /dev/null
+++ b/services/register/mail_provider.py
@@ -0,0 +1,1025 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import random
+import re
+import string
+import time
+from datetime import datetime, timezone
+from email import message_from_string, policy
+from email.utils import parsedate_to_datetime
+from threading import Lock
+from typing import Any, Callable, TypeVar
+
+from curl_cffi import requests
+
+
+from services.config import DATA_DIR
+
+DDG_ALIASES_FILE = DATA_DIR / "ddg_aliases.json"
+_ddg_aliases_lock = Lock()
+
+
+def _load_ddg_aliases() -> set[str]:
+ try:
+ if DDG_ALIASES_FILE.exists():
+ data = json.loads(DDG_ALIASES_FILE.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return {str(item).strip().lower() for item in data if str(item).strip()}
+ except Exception:
+ pass
+ return set()
+
+
+def _save_ddg_aliases(aliases: set[str]) -> None:
+ DDG_ALIASES_FILE.parent.mkdir(parents=True, exist_ok=True)
+ DDG_ALIASES_FILE.write_text(json.dumps(sorted(aliases), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+
+def _is_ddg_alias_duplicate(address: str) -> bool:
+ target = str(address or "").strip().lower()
+ if not target:
+ return False
+ with _ddg_aliases_lock:
+ used = _load_ddg_aliases()
+ return target in used
+
+
+def _record_ddg_alias(address: str) -> None:
+ target = str(address or "").strip().lower()
+ if not target:
+ return
+ with _ddg_aliases_lock:
+ used = _load_ddg_aliases()
+ used.add(target)
+ _save_ddg_aliases(used)
+
+
+ResultT = TypeVar("ResultT")
+domain_lock = Lock()
+provider_lock = Lock()
+domain_index = 0
+provider_index = 0
+cloudmail_token_lock = Lock()
+cloudmail_token_cache: dict[str, tuple[str, float]] = {}
+
+
+def _config(mail_config: dict) -> dict:
+ return {
+ "request_timeout": float(mail_config.get("request_timeout") or 30),
+ "wait_timeout": float(mail_config.get("wait_timeout") or 30),
+ "wait_interval": float(mail_config.get("wait_interval") or 2),
+ "user_agent": str(mail_config.get("user_agent") or "Mozilla/5.0"),
+ "proxy": str(mail_config.get("proxy") or "").strip(),
+ }
+
+
+def _random_mailbox_name() -> str:
+ return f"{''.join(random.choices(string.ascii_lowercase, k=5))}{''.join(random.choices(string.digits, k=random.randint(1, 3)))}{''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))}"
+
+
+def _random_subdomain_label() -> str:
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(4, 10)))
+
+
+def _next_domain(domains: list[str]) -> str:
+ global domain_index
+ domains = [str(item).strip() for item in domains if str(item).strip()]
+ if not domains:
+ raise RuntimeError("mail.domain 不能为空")
+ if len(domains) == 1:
+ return domains[0]
+ with domain_lock:
+ value = domains[domain_index % len(domains)]
+ domain_index = (domain_index + 1) % len(domains)
+ return value
+
+
+def _normalize_string_list(value: Any) -> list[str]:
+ if isinstance(value, list):
+ return [str(item).strip() for item in value if str(item).strip()]
+ text = str(value or "").strip()
+ return [text] if text else []
+
+
+def _create_session(conf: dict):
+ proxy = str(conf.get("proxy") or "").strip()
+ kwargs = {"impersonate": "chrome", "verify": False}
+ if proxy:
+ kwargs["proxy"] = proxy
+ return requests.Session(**kwargs)
+
+
+def _parse_received_at(value: Any) -> datetime | None:
+ if isinstance(value, (int, float)):
+ try:
+ return datetime.fromtimestamp(float(value), tz=timezone.utc)
+ except Exception:
+ return None
+ text = str(value or "").strip()
+ if not text:
+ return None
+ try:
+ date = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
+ return date if date.tzinfo else date.replace(tzinfo=timezone.utc)
+ except Exception:
+ pass
+ try:
+ date = parsedate_to_datetime(text)
+ return date if date.tzinfo else date.replace(tzinfo=timezone.utc)
+ except Exception:
+ return None
+
+
+def _extract_content(data: dict[str, Any]) -> tuple[str, str]:
+ text_content = str(data.get("text_content") or data.get("text") or data.get("body") or data.get("content") or "")
+ html_content = str(data.get("html_content") or data.get("html") or data.get("html_body") or data.get("body_html") or "")
+ if text_content or html_content:
+ return text_content, html_content
+ raw = data.get("raw")
+ if not isinstance(raw, str) or not raw.strip():
+ return "", ""
+ try:
+ parsed = message_from_string(raw, policy=policy.default)
+ except Exception:
+ return raw, ""
+ plain: list[str] = []
+ html: list[str] = []
+ for part in parsed.walk() if parsed.is_multipart() else [parsed]:
+ if part.get_content_maintype() == "multipart":
+ continue
+ try:
+ payload = part.get_content()
+ except Exception:
+ payload = ""
+ if not payload:
+ continue
+ if part.get_content_type() == "text/html":
+ html.append(str(payload))
+ else:
+ plain.append(str(payload))
+ return "\n".join(plain).strip(), "\n".join(html).strip()
+
+
+def _extract_text_candidates(value: Any) -> list[str]:
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, dict):
+ out: list[str] = []
+ for key in ("address", "email", "name", "value"):
+ if value.get(key):
+ out.extend(_extract_text_candidates(value.get(key)))
+ return out
+ if isinstance(value, list):
+ out: list[str] = []
+ for item in value:
+ out.extend(_extract_text_candidates(item))
+ return out
+ return []
+
+
+def _message_matches_email(data: dict[str, Any], email: str) -> bool:
+ target = str(email or "").strip().lower()
+ candidates: list[str] = []
+ for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"):
+ if key in data:
+ candidates.extend(_extract_text_candidates(data.get(key)))
+ return not target or not candidates or any(target in str(item).strip().lower() for item in candidates if str(item).strip())
+
+
+def _extract_code(message: dict[str, Any]) -> str | None:
+ content = f"{message.get('subject', '')}\n{message.get('text_content', '')}\n{message.get('html_content', '')}".strip()
+ if not content:
+ return None
+ match = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?
", content, re.I)
+ if match:
+ return match.group(1)
+ match = re.search(r"(?:Verification code|code is|代码为|验证码)[:\s]*(\d{6})", content, re.I)
+ if match and match.group(1) != "177010":
+ return match.group(1)
+ for code in re.findall(r">\s*(\d{6})\s*<|(? str:
+ provider = str(message.get("provider") or "").strip()
+ mailbox = str(message.get("mailbox") or "").strip()
+ message_id = str(message.get("message_id") or "").strip()
+ if message_id:
+ return f"id:{provider}:{mailbox}:{message_id}"
+ received_at = message.get("received_at")
+ received_value = received_at.isoformat() if isinstance(received_at, datetime) else str(received_at or "")
+ content = "\n".join(str(message.get(key) or "") for key in ("subject", "sender", "text_content", "html_content"))
+ digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
+ return f"content:{provider}:{mailbox}:{received_value}:{digest}"
+
+
+class BaseMailProvider:
+ name = "unknown"
+
+ def __init__(self, conf: dict, provider_ref: str = ""):
+ self.conf = conf
+ self.provider_ref = provider_ref
+
+ def wait_for(self, mailbox: dict[str, Any], on_message: Callable[[dict[str, Any]], ResultT | None]) -> ResultT | None:
+ deadline = time.monotonic() + self.conf["wait_timeout"]
+ while time.monotonic() < deadline:
+ message = self.fetch_latest_message(mailbox)
+ if message:
+ result = on_message(message)
+ if result is not None:
+ return result
+ time.sleep(max(0.2, self.conf["wait_interval"]))
+ return None
+
+ def wait_for_code(self, mailbox: dict[str, Any]) -> str | None:
+ seen_value = mailbox.setdefault("_seen_code_message_refs", [])
+ if not isinstance(seen_value, list):
+ seen_value = []
+ mailbox["_seen_code_message_refs"] = seen_value
+ seen_refs = {str(item) for item in seen_value}
+
+ def extract_unseen_code(message: dict[str, Any]) -> str | None:
+ ref = _message_tracking_ref(message)
+ if ref in seen_refs:
+ return None
+ code = _extract_code(message)
+ if code:
+ seen_value.append(ref)
+ seen_refs.add(ref)
+ return code
+
+ return self.wait_for(mailbox, extract_unseen_code)
+
+ def close(self) -> None:
+ pass
+
+
+class CloudflareTempMailProvider(BaseMailProvider):
+ name = "cloudflare_temp_email"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_base = str(entry["api_base"]).rstrip("/")
+ self.admin_password = str(entry["admin_password"]).strip()
+ self.domain = entry.get("domain") or []
+ self.session = _create_session(conf)
+
+ def _request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
+ resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"Content-Type": "application/json", "User-Agent": self.conf["user_agent"], **(headers or {})}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in expected:
+ raise RuntimeError(f"CloudflareTempMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ return {} if resp.status_code == 204 else resp.json()
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ data = self._request("POST", "/admin/new_address", headers={"x-admin-auth": self.admin_password}, payload={"enablePrefix": True, "name": username or _random_mailbox_name(), "domain": _next_domain(self.domain)})
+ address = str(data.get("address") or "").strip()
+ token = str(data.get("jwt") or "").strip()
+ if not address or not token:
+ raise RuntimeError("CloudflareTempMail 缺少 address 或 jwt")
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
+
+ def get_existing_mailbox(self, email: str) -> dict[str, Any]:
+ """通过管理员密码获取已有邮箱地址的 JWT,用于查询邮件。"""
+ data = self._request("POST", "/admin/get_address", headers={"x-admin-auth": self.admin_password}, payload={"address": email})
+ address = str(data.get("address") or "").strip()
+ token = str(data.get("jwt") or "").strip()
+ if not address or not token:
+ raise RuntimeError(f"CloudflareTempMail 无法获取已有邮箱 {email} 的 JWT")
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ data = self._request("GET", "/api/mails", headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 10, "offset": 0})
+ raw = list(data.get("results") or []) if isinstance(data, dict) else data if isinstance(data, list) else []
+ messages = [item for item in raw if isinstance(item, dict) and _message_matches_email(item, str(mailbox.get("address") or ""))]
+ if not messages:
+ return None
+ item = messages[0]
+ text_content, html_content = _extract_content(item)
+ sender = item.get("from") or item.get("sender") or ""
+ if isinstance(sender, dict):
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("_id") or ""), "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class DDGMailProvider(BaseMailProvider):
+ name = "ddg_mail"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.label = str(entry.get("label") or self.provider_ref)
+ self.ddg_token = str(entry["ddg_token"]).strip()
+ self.cf_api_base = str(entry.get("api_base") or entry.get("cf_api_base") or "").rstrip("/")
+ self.cf_inbox_jwt = str(entry.get("cf_inbox_jwt") or "").strip()
+ self.cf_admin_password = str(entry.get("admin_password") or "").strip()
+ self.cf_api_key = str(entry.get("cf_api_key") or "").strip()
+ self.cf_auth_mode = str(entry.get("cf_auth_mode") or "none").strip().lower()
+ self.cf_domain = entry.get("cf_domain") or []
+ self.cf_create_path = str(entry.get("cf_create_path") or "/api/new_address").strip()
+ self.cf_messages_path = str(entry.get("cf_messages_path") or "/api/mails").strip()
+ self.session = _create_session(conf)
+
+ def _cf_build_headers(self, content_type: bool = False) -> dict:
+ headers = {"Content-Type": "application/json"} if content_type else {}
+ if self.cf_api_key:
+ if self.cf_auth_mode == "x-api-key":
+ headers["X-API-Key"] = self.cf_api_key
+ elif self.cf_auth_mode != "none":
+ headers["Authorization"] = f"Bearer {self.cf_api_key}"
+ return headers
+
+ def _cf_request(self, method: str, path: str, headers: dict | None = None, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict:
+ merged_headers = {**self._cf_build_headers(True), **(headers or {}), "User-Agent": self.conf["user_agent"]}
+ if self.cf_admin_password and method.upper() in ("POST",):
+ merged_headers["x-admin-auth"] = self.cf_admin_password
+ if self.cf_api_key and self.cf_auth_mode == "query-key":
+ params = {**(params or {}), "key": self.cf_api_key}
+ resp = self.session.request(method.upper(), f"{self.cf_api_base}{path}", headers=merged_headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in expected:
+ raise RuntimeError(f"DDGMail CF请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ return {} if resp.status_code == 204 else resp.json()
+
+ def _ddg_request(self, method: str, path: str, payload: dict | None = None) -> dict:
+ resp = self.session.request(method.upper(), f"https://quack.duckduckgo.com{path}", headers={"Authorization": f"Bearer {self.ddg_token}", "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in (200, 201):
+ raise RuntimeError(f"DDG API请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ return resp.json()
+
+ def _cf_list_payload(self, data: Any) -> list:
+ if isinstance(data, list):
+ return data
+ if isinstance(data, dict):
+ for key in ("results", "hydra:member", "data", "messages"):
+ value = data.get(key)
+ if isinstance(value, list):
+ return value
+ if isinstance(value, dict) and isinstance(value.get("messages"), list):
+ return value["messages"]
+ return []
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ ddg_data = self._ddg_request("POST", "/api/email/addresses", payload={})
+ ddg_address_part = str(ddg_data.get("address") or "").strip()
+ if not ddg_address_part:
+ raise RuntimeError("DDG API 返回无 address 字段")
+ ddg_address = f"{ddg_address_part}@duck.com"
+
+ if _is_ddg_alias_duplicate(ddg_address):
+ raise RuntimeError(f"[{self.label}] DDG日上限已达,别名 {ddg_address} 已存在,自动切换邮箱提供商")
+
+ _record_ddg_alias(ddg_address)
+
+ if not self.cf_inbox_jwt:
+ raise RuntimeError("DDGMail 需要 cf_inbox_jwt(DDG 转发目标的固定收件箱 JWT),请在邮箱配置中填写 CF Inbox JWT")
+
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": ddg_address, "token": self.cf_inbox_jwt, "label": self.label}
+
+ def _parse_raw_recipient(self, raw_text: str) -> str:
+ if not raw_text:
+ return ""
+ match = re.search(r"^To:\s*(.+?)$", raw_text, re.MULTILINE | re.IGNORECASE)
+ if match:
+ addr = match.group(1).strip()
+ addr = re.sub(r"\s*<[^>]*>", "", addr)
+ return addr.strip().lower()
+ try:
+ parsed = message_from_string(raw_text, policy=policy.default)
+ return str(parsed.get("To") or "").strip().lower()
+ except Exception:
+ return ""
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ target_address = str(mailbox.get("address") or "").strip().lower()
+ data = self._cf_request("GET", self.cf_messages_path, headers={"Authorization": f"Bearer {mailbox['token']}"}, params={"limit": 30, "offset": 0})
+ raw_list = self._cf_list_payload(data)
+ messages = [item for item in raw_list if isinstance(item, dict)]
+ if not messages:
+ return None
+
+ for item in messages:
+ message_id = str(item.get("id") or item.get("msgid") or item.get("_id") or "")
+ raw_text = str(item.get("raw") or "")
+ raw_recipient = self._parse_raw_recipient(raw_text)
+ if target_address and raw_recipient and target_address not in raw_recipient:
+ continue
+ text_content, html_content = _extract_content(item)
+ subject = str(item.get("subject") or "")
+ sender = item.get("from") or item.get("sender") or item.get("source") or ""
+ if isinstance(sender, dict):
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
+ if raw_text and (not subject or not sender or subject == sender == ""):
+ try:
+ parsed = message_from_string(raw_text, policy=policy.default)
+ if not subject:
+ subject = str(parsed.get("Subject") or "")
+ if not sender:
+ sender = str(parsed.get("From") or "")
+ except Exception:
+ pass
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": subject, "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
+
+ return None
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class CloudMailGenProvider(BaseMailProvider):
+ name = "cloudmail_gen"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_base = str(entry["api_base"]).rstrip("/")
+ self.admin_email = str(entry.get("admin_email") or "").strip()
+ self.admin_password = str(entry.get("admin_password") or "").strip()
+ self.domain = _normalize_string_list(entry.get("domain"))
+ self.subdomain = _normalize_string_list(entry.get("subdomain"))
+ self.email_prefix = str(entry.get("email_prefix") or "").strip()
+ self.session = _create_session(conf)
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ headers: dict | None = None,
+ params: dict | None = None,
+ payload: dict | None = None,
+ expected: tuple[int, ...] = (200,),
+ ):
+ resp = self.session.request(
+ method.upper(),
+ f"{self.api_base}{path}",
+ headers={
+ "Content-Type": "application/json",
+ "User-Agent": self.conf["user_agent"],
+ **(headers or {}),
+ },
+ params=params,
+ json=payload,
+ timeout=self.conf["request_timeout"],
+ verify=False,
+ )
+ if resp.status_code not in expected:
+ raise RuntimeError(f"CloudMailGen 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ return {} if resp.status_code == 204 else resp.json()
+
+ def _cache_key(self) -> str:
+ return f"{self.api_base}|{self.admin_email}"
+
+ def _get_token(self) -> str:
+ if not self.admin_email or not self.admin_password:
+ raise RuntimeError("CloudMailGen 缺少 admin_email 或 admin_password")
+ cache_key = self._cache_key()
+ now = time.time()
+ with cloudmail_token_lock:
+ cached = cloudmail_token_cache.get(cache_key)
+ if cached and now < cached[1] - 300:
+ return cached[0]
+ data = self._request(
+ "POST",
+ "/api/public/genToken",
+ payload={"email": self.admin_email, "password": self.admin_password},
+ )
+ token = ""
+ if isinstance(data, dict) and data.get("code") == 200:
+ token = str((data.get("data") or {}).get("token") or "").strip()
+ if not token:
+ raise RuntimeError(f"CloudMailGen genToken 返回异常: {data}")
+ with cloudmail_token_lock:
+ cloudmail_token_cache[cache_key] = (token, now + 24 * 3600)
+ return token
+
+ def _resolve_address(self, username: str | None = None) -> str:
+ domain = _next_domain(self.domain)
+ if self.subdomain:
+ domain = f"{random.choice(self.subdomain)}.{domain}"
+ if username:
+ local_part = username
+ elif self.email_prefix:
+ local_part = f"{self.email_prefix}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
+ else:
+ local_part = _random_mailbox_name()
+ return f"{local_part}@{domain}"
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ if not self.domain:
+ raise RuntimeError("CloudMailGen 需要至少配置一个 domain")
+ address = self._resolve_address(username)
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ address = str(mailbox.get("address") or "").strip()
+ if not address:
+ raise RuntimeError("CloudMailGen 缺少 address")
+ token = self._get_token()
+ data = self._request(
+ "POST",
+ "/api/public/emailList",
+ headers={"Authorization": token},
+ payload={"toEmail": address, "size": 20, "timeSort": "desc"},
+ )
+ items = (data.get("data") or []) if isinstance(data, dict) and data.get("code") == 200 else []
+ messages = [item for item in items if isinstance(item, dict) and _message_matches_email(item, address)]
+ if not messages:
+ return None
+ item = messages[0]
+ text_content, html_content = _extract_content(item)
+ return {
+ "provider": self.name,
+ "mailbox": address,
+ "message_id": str(item.get("id") or item.get("_id") or item.get("messageId") or ""),
+ "subject": str(item.get("subject") or ""),
+ "sender": str(item.get("from") or item.get("sender") or ""),
+ "text_content": text_content,
+ "html_content": html_content,
+ "received_at": _parse_received_at(
+ item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")
+ ),
+ "to": item.get("to") or item.get("toEmail") or item.get("mailTo"),
+ "raw": item,
+ }
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class TempMailLolProvider(BaseMailProvider):
+ name = "tempmail_lol"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_key = str(entry.get("api_key") or "").strip()
+ self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()]
+ self.session = _create_session(conf)
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
+ if self.api_key:
+ self.session.headers["Authorization"] = f"Bearer {self.api_key}"
+
+ @staticmethod
+ def _resolve_domain(domain: str) -> tuple[str, bool]:
+ text = str(domain or "").strip().lower()
+ if text.startswith("*.") and len(text) > 2:
+ return f"{_random_subdomain_label()}.{text[2:]}", True
+ return text, False
+
+ def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
+ resp = self.session.request(method.upper(), f"https://api.tempmail.lol/v2{path}", params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in expected:
+ raise RuntimeError(f"TempMail.lol 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ data = resp.json()
+ if not isinstance(data, dict):
+ raise RuntimeError(f"TempMail.lol {method} {path} 返回结构不是对象")
+ return data
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ payload: dict[str, Any] = {}
+ if self.domain:
+ domain, force_random_prefix = self._resolve_domain(random.choice(self.domain))
+ payload["domain"] = domain
+ if force_random_prefix:
+ payload["prefix"] = _random_mailbox_name()
+ if username and "prefix" not in payload:
+ payload["prefix"] = username
+ data = self._request("POST", "/inbox/create", payload=payload, expected=(200, 201))
+ address = str(data.get("address") or "").strip()
+ token = str(data.get("token") or "").strip()
+ if not address or not token:
+ raise RuntimeError("TempMail.lol 缺少 address 或 token")
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ data = self._request("GET", "/inbox", params={"token": mailbox["token"]})
+ items = data.get("emails") or data.get("messages") or []
+ messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
+ if not messages:
+ return None
+ item = max(messages, key=lambda value: ((_parse_received_at(value.get("created_at") or value.get("createdAt") or value.get("date") or value.get("received_at") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or value.get("token") or "")))
+ text_content, html_content = _extract_content(item)
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or item.get("token") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from") or item.get("from_address") or ""), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("created_at") or item.get("createdAt") or item.get("date") or item.get("received_at") or item.get("timestamp")), "raw": item}
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class DuckMailProvider(BaseMailProvider):
+ name = "duckmail"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_key = str(entry["api_key"]).strip()
+ self.default_domain = str(entry.get("default_domain") or "duckmail.sbs").strip() or "duckmail.sbs"
+ self.session = _create_session(conf)
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
+
+ def _request(self, method: str, path: str, token: str = "", use_api_key: bool = False, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)):
+ headers = {"Authorization": f"Bearer {self.api_key if use_api_key else token}"} if use_api_key or token else {}
+ resp = self.session.request(method.upper(), f"https://api.duckmail.sbs{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in expected:
+ raise RuntimeError(f"DuckMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ return {} if resp.status_code == 204 else resp.json()
+
+ @staticmethod
+ def _items(data):
+ return data if isinstance(data, list) else data.get("hydra:member") or data.get("member") or data.get("data") or []
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ password = "".join(random.choices(string.ascii_letters + string.digits, k=12))
+ address = f"{username or _random_mailbox_name()}@{self.default_domain}"
+ payload = {"address": address, "password": password}
+ account = self._request("POST", "/accounts", use_api_key=True, payload=payload)
+ token_data = self._request("POST", "/token", use_api_key=True, payload=payload)
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": str(token_data.get("token") or ""), "password": password, "account_id": str(account.get("id") or "")}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"page": 1})
+ items = self._items(data)
+ if not items:
+ return None
+ item = items[0]
+ message_id = str(item.get("id") or item.get("@id") or "").replace("/messages/", "")
+ if message_id:
+ item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""))
+ sender = item.get("from") or ""
+ if isinstance(sender, dict):
+ sender = sender.get("address") or sender.get("name") or ""
+ html_content = item.get("html") or ""
+ if isinstance(html_content, list):
+ html_content = "".join(str(value) for value in html_content)
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": str(item.get("text") or item.get("text_content") or ""), "html_content": str(html_content), "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date")), "raw": item}
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class GptMailProvider(BaseMailProvider):
+ name = "gptmail"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_key = str(entry["api_key"]).strip()
+ self.default_domain = str(entry.get("default_domain") or "").strip()
+ self.session = _create_session(conf)
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json", "X-API-Key": self.api_key})
+
+ def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None):
+ query = dict(params or {})
+ resp = self.session.request(method.upper(), f"https://mail.chatgpt.org.uk{path}", params=query, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code != 200:
+ raise RuntimeError(f"GPTMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ data = resp.json()
+ return data["data"] if isinstance(data, dict) and "data" in data else data
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ payload = {key: value for key, value in {"prefix": username, "domain": self.default_domain}.items() if value}
+ data = self._request("POST" if payload else "GET", "/api/generate-email", payload=payload or None)
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": str(data["email"])}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ data = self._request("GET", "/api/emails", params={"email": mailbox["address"]})
+ emails = data if isinstance(data, list) else data.get("emails") or []
+ if not emails:
+ return None
+ item = max(emails, key=lambda value: (float(value.get("timestamp") or 0), str(value.get("id") or "")))
+ if item.get("id"):
+ item = self._request("GET", f"/api/email/{item['id']}")
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": str(item.get("id") or ""), "subject": str(item.get("subject") or ""), "sender": str(item.get("from_address") or ""), "text_content": str(item.get("content") or ""), "html_content": str(item.get("html_content") or ""), "received_at": _parse_received_at(item.get("timestamp") or item.get("created_at")), "raw": item}
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class MoEmailProvider(BaseMailProvider):
+ name = "moemail"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_base = str(entry["api_base"]).rstrip("/")
+ self.api_key = str(entry["api_key"]).strip()
+ raw_domains = entry.get("domain") or []
+ if isinstance(raw_domains, list):
+ self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
+ else:
+ self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
+ self.expiry_time = int(entry.get("expiry_time") or 0)
+ self.session = _create_session(conf)
+
+ def _request(self, method: str, path: str, params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200,)):
+ resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers={"X-API-Key": self.api_key, "Content-Type": "application/json", "User-Agent": self.conf["user_agent"]}, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in expected:
+ raise RuntimeError(f"MoEmail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ data = resp.json()
+ if not isinstance(data, dict):
+ raise RuntimeError(f"MoEmail {method} {path} 返回结构不是对象")
+ return data
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ data = self._request("POST", "/api/emails/generate", payload={"name": username or _random_mailbox_name(), "expiryTime": self.expiry_time, "domain": _next_domain(self.domain)}, expected=(200, 201))
+ address = str(data.get("email") or "").strip()
+ email_id = str(data.get("id") or data.get("email_id") or "").strip()
+ if not address or not email_id:
+ raise RuntimeError("MoEmail 缺少 email 或 id")
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "email_id": email_id}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ email_id = str(mailbox.get("email_id") or "").strip()
+ if not email_id:
+ raise RuntimeError("MoEmail 缺少 email_id")
+ data = self._request("GET", f"/api/emails/{email_id}")
+ items = data.get("messages") or []
+ messages = [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
+ if not messages:
+ return None
+ _, item = max(enumerate(messages), key=lambda pair: (((_parse_received_at(pair[1].get("createdAt") or pair[1].get("created_at") or pair[1].get("receivedAt") or pair[1].get("date") or pair[1].get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp()), pair[0]))
+ message_id = str(item.get("id") or item.get("message_id") or item.get("_id") or "").strip()
+ detail = self._request("GET", f"/api/emails/{email_id}/{message_id}") if message_id else {"message": item}
+ message = detail.get("message") if isinstance(detail.get("message"), dict) else detail
+ text_content, html_content = _extract_content(message)
+ sender = message.get("from") or message.get("sender") or ""
+ if isinstance(sender, dict):
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(message.get("subject") or item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(message.get("createdAt") or message.get("created_at") or message.get("receivedAt") or message.get("date") or message.get("timestamp") or item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": detail}
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class InbucketMailProvider(BaseMailProvider):
+ name = "inbucket"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_base = str(entry["api_base"]).rstrip("/")
+ raw_domains = entry.get("domain") or []
+ if isinstance(raw_domains, list):
+ self.domain = [str(item).strip() for item in raw_domains if str(item).strip()]
+ else:
+ self.domain = [str(raw_domains).strip()] if str(raw_domains).strip() else []
+ self.random_subdomain = bool(entry.get("random_subdomain", True))
+ self.session = _create_session(conf)
+ self.session.headers.update({
+ "User-Agent": conf["user_agent"],
+ "Accept": "application/json",
+ })
+
+ def _request(self, method: str, path: str, expected: tuple[int, ...] = (200,)):
+ resp = self.session.request(
+ method.upper(),
+ f"{self.api_base}{path}",
+ timeout=self.conf["request_timeout"],
+ verify=False,
+ )
+ if resp.status_code not in expected:
+ raise RuntimeError(f"Inbucket 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ if resp.status_code == 204:
+ return {}
+ content_type = str(resp.headers.get("content-type") or "").lower()
+ if "application/json" in content_type:
+ return resp.json()
+ return resp.text
+
+ def _resolve_domain(self) -> str:
+ if self.domain:
+ return _next_domain(self.domain)
+ raise RuntimeError("Inbucket 需要至少配置一个 domain")
+
+ def _mailbox_name(self, address: str) -> str:
+ local_part, _, _ = str(address or "").partition("@")
+ return local_part.strip()
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ local_part = username or _random_mailbox_name()
+ base_domain = self._resolve_domain()
+ domain = f"{_random_subdomain_label()}.{base_domain}" if self.random_subdomain else base_domain
+ address = f"{local_part}@{domain}"
+ mailbox_name = self._mailbox_name(address)
+ return {
+ "provider": self.name,
+ "provider_ref": self.provider_ref,
+ "address": address,
+ "base_domain": base_domain,
+ "mailbox_name": mailbox_name,
+ }
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ mailbox_name = str(mailbox.get("mailbox_name") or self._mailbox_name(str(mailbox.get("address") or ""))).strip()
+ if not mailbox_name:
+ raise RuntimeError("Inbucket 缺少 mailbox_name")
+ data = self._request("GET", f"/api/v1/mailbox/{mailbox_name}")
+ items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
+ if not items:
+ return None
+ items.sort(
+ key=lambda value: (
+ (_parse_received_at(value.get("date")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(),
+ str(value.get("id") or ""),
+ ),
+ reverse=True,
+ )
+ address = str(mailbox.get("address") or "").strip()
+ for item in items:
+ message_id = str(item.get("id") or "").strip()
+ if not message_id:
+ continue
+ detail = self._request("GET", f"/api/v1/mailbox/{mailbox_name}/{message_id}")
+ if not isinstance(detail, dict):
+ continue
+ header = detail.get("header") if isinstance(detail.get("header"), dict) else {}
+ body = detail.get("body") if isinstance(detail.get("body"), dict) else {}
+ normalized = {
+ "provider": self.name,
+ "mailbox": mailbox_name,
+ "message_id": message_id,
+ "subject": str(detail.get("subject") or item.get("subject") or ""),
+ "sender": str(detail.get("from") or item.get("from") or ""),
+ "text_content": str(body.get("text") or ""),
+ "html_content": str(body.get("html") or ""),
+ "received_at": _parse_received_at(detail.get("date") or item.get("date")),
+ "to": header.get("To") if isinstance(header, dict) else None,
+ "raw": detail,
+ }
+ if _message_matches_email(normalized, address):
+ return normalized
+ return None
+
+ def close(self) -> None:
+ self.session.close()
+
+
+class YydsMailProvider(BaseMailProvider):
+ name = "yyds_mail"
+
+ def __init__(self, entry: dict, conf: dict):
+ super().__init__(conf, str(entry.get("provider_ref") or ""))
+ self.api_base = str(entry.get("api_base") or "https://maliapi.215.im/v1").rstrip("/")
+ self.api_key = str(entry["api_key"]).strip()
+ self.domain = [str(item).strip() for item in (entry.get("domain") or []) if str(item).strip()]
+ self.subdomain = str(entry.get("subdomain") or "").strip()
+ self.wildcard = bool(entry.get("wildcard"))
+ self.session = _create_session(conf)
+ self.session.headers.update({"User-Agent": conf["user_agent"], "Accept": "application/json", "Content-Type": "application/json"})
+
+ def _request(self, method: str, path: str, token: str = "", params: dict | None = None, payload: dict | None = None, expected: tuple[int, ...] = (200, 201, 204)):
+ headers = {"Authorization": f"Bearer {token}"} if token else {"X-API-Key": self.api_key}
+ resp = self.session.request(method.upper(), f"{self.api_base}{path}", headers=headers, params=params, json=payload, timeout=self.conf["request_timeout"], verify=False)
+ if resp.status_code not in expected:
+ raise RuntimeError(f"YYDSMail 请求失败: {method} {path}, HTTP {resp.status_code}, body={resp.text[:300]}")
+ if resp.status_code == 204:
+ return {}
+ data = resp.json()
+ if isinstance(data, dict) and data.get("success") is False:
+ raise RuntimeError(f"YYDSMail 请求失败: {data.get('errorCode') or data.get('error')}")
+ return data.get("data") if isinstance(data, dict) and isinstance(data.get("data"), (dict, list)) else data
+
+ @staticmethod
+ def _items(data):
+ return data if isinstance(data, list) else data.get("items") or data.get("messages") or data.get("data") or []
+
+ def create_mailbox(self, username: str | None = None) -> dict[str, Any]:
+ payload = {"localPart": username or _random_mailbox_name()}
+ if self.domain:
+ payload["domain"] = _next_domain(self.domain)
+ if self.subdomain:
+ payload["subdomain"] = self.subdomain
+ data = self._request("POST", "/accounts/wildcard" if self.wildcard else "/accounts", payload=payload)
+ address = str(data.get("address") or data.get("email") or "").strip()
+ token = str(data.get("token") or data.get("temp_token") or data.get("tempToken") or data.get("access_token") or "").strip()
+ if not address or not token:
+ raise RuntimeError("YYDSMail 缺少 address 或 token")
+ return {"provider": self.name, "provider_ref": self.provider_ref, "address": address, "token": token, "account_id": str(data.get("id") or "")}
+
+ def fetch_latest_message(self, mailbox: dict[str, Any]) -> dict[str, Any] | None:
+ data = self._request("GET", "/messages", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]})
+ messages = [item for item in self._items(data) if isinstance(item, dict)]
+ if not messages:
+ return None
+ item = max(messages, key=lambda value: ((_parse_received_at(value.get("createdAt") or value.get("created_at") or value.get("receivedAt") or value.get("date") or value.get("timestamp")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), str(value.get("id") or "")))
+ message_id = str(item.get("id") or item.get("message_id") or "").strip()
+ if message_id:
+ item = self._request("GET", f"/messages/{message_id}", token=str(mailbox.get("token") or ""), params={"address": mailbox["address"]})
+ text_content, html_content = _extract_content(item)
+ sender = item.get("from") or item.get("sender") or ""
+ if isinstance(sender, dict):
+ sender = sender.get("address") or sender.get("email") or sender.get("name") or ""
+ return {"provider": self.name, "mailbox": mailbox["address"], "message_id": message_id, "subject": str(item.get("subject") or ""), "sender": str(sender), "text_content": text_content, "html_content": html_content, "received_at": _parse_received_at(item.get("createdAt") or item.get("created_at") or item.get("receivedAt") or item.get("date") or item.get("timestamp")), "raw": item}
+
+ def close(self) -> None:
+ self.session.close()
+
+
+def _entries(mail_config: dict) -> list[dict]:
+ result: list[dict] = []
+ counters: dict[str, int] = {}
+ for item in mail_config["providers"]:
+ idx = len(result) + 1
+ t = item.get("type", "")
+ cnt = counters.get(t, 0) + 1
+ counters[t] = cnt
+ label = f"DDG-{cnt}" if t == "ddg_mail" else f"{t}#{idx}"
+ result.append({**item, "provider_ref": f"{item['type']}#{idx}", "label": label})
+ return result
+
+
+def _enabled_entries(mail_config: dict) -> list[dict]:
+ items = [item for item in _entries(mail_config) if item.get("enable")]
+ if not items:
+ raise RuntimeError("mail.providers 没有启用的 provider")
+ return items
+
+
+def _next_entry(mail_config: dict) -> dict:
+ global provider_index
+ items = _enabled_entries(mail_config)
+ if len(items) == 1:
+ return dict(items[0])
+ with provider_lock:
+ value = dict(items[provider_index % len(items)])
+ provider_index = (provider_index + 1) % len(items)
+ return value
+
+
+def _create_provider(mail_config: dict, provider: str = "", provider_ref: str = "") -> BaseMailProvider:
+ entry = next((dict(item) for item in _entries(mail_config) if provider_ref and item["provider_ref"] == provider_ref), None)
+ entry = entry or next((dict(item) for item in _enabled_entries(mail_config) if provider and item["type"] == provider), None) or _next_entry(mail_config)
+ conf = _config(mail_config)
+ if entry["type"] == "cloudmail_gen":
+ return CloudMailGenProvider(entry, conf)
+ if entry["type"] == "cloudflare_temp_email":
+ return CloudflareTempMailProvider(entry, conf)
+ if entry["type"] == "ddg_mail":
+ return DDGMailProvider(entry, conf)
+ if entry["type"] == "tempmail_lol":
+ return TempMailLolProvider(entry, conf)
+ if entry["type"] == "duckmail":
+ return DuckMailProvider(entry, conf)
+ if entry["type"] == "gptmail":
+ return GptMailProvider(entry, conf)
+ if entry["type"] == "moemail":
+ return MoEmailProvider(entry, conf)
+ if entry["type"] == "inbucket":
+ return InbucketMailProvider(entry, conf)
+ if entry["type"] == "yyds_mail":
+ return YydsMailProvider(entry, conf)
+ raise RuntimeError(f"不支持的 mail.provider: {entry['type']}")
+
+
+def create_mailbox(mail_config: dict, username: str | None = None) -> dict:
+ enabled = _enabled_entries(mail_config)
+ tried: set[str] = set()
+ last_error = ""
+ for _ in range(len(enabled)):
+ provider = _create_provider(mail_config)
+ provider_key = f"{provider.name}#{provider.provider_ref}"
+ try:
+ if provider_key in tried:
+ continue
+ tried.add(provider_key)
+ mailbox = provider.create_mailbox(username)
+ return mailbox
+ except RuntimeError as error:
+ last_error = str(error)
+ if "DDG日上限已达" not in last_error:
+ raise
+ finally:
+ provider.close()
+ raise RuntimeError(last_error or "所有启用的邮箱提供商均无法创建邮箱")
+
+
+def wait_for_code(mail_config: dict, mailbox: dict) -> str | None:
+ provider = _create_provider(mail_config, str(mailbox.get("provider") or ""), str(mailbox.get("provider_ref") or ""))
+ try:
+ return provider.wait_for_code(mailbox)
+ finally:
+ provider.close()
+
+
+def get_existing_mailbox(mail_config: dict, email: str) -> dict:
+ """通过管理员密码获取已有邮箱地址的 JWT,用于查询邮件。"""
+ enabled = _enabled_entries(mail_config)
+ tried: set[str] = set()
+ last_error = ""
+ for _ in range(len(enabled)):
+ provider = _create_provider(mail_config)
+ provider_key = f"{provider.name}#{provider.provider_ref}"
+ try:
+ if provider_key in tried:
+ continue
+ tried.add(provider_key)
+ if hasattr(provider, "get_existing_mailbox"):
+ mailbox = provider.get_existing_mailbox(email)
+ return mailbox
+ else:
+ raise RuntimeError(f"邮箱提供商 {provider.name} 不支持查询已有邮箱")
+ except RuntimeError as error:
+ last_error = str(error)
+ if "DDG日上限已达" not in last_error:
+ raise
+ finally:
+ provider.close()
+ raise RuntimeError(last_error or "所有启用的邮箱提供商均无法查询已有邮箱")
diff --git a/services/register/openai_register.py b/services/register/openai_register.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e8670e6e63ed2d051e3e99a87b8366b8771184b
--- /dev/null
+++ b/services/register/openai_register.py
@@ -0,0 +1,476 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import random
+import secrets
+import string
+import threading
+import time
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from urllib.parse import parse_qs, urlencode, urlparse
+
+from curl_cffi import requests
+
+from services.account_service import account_service
+from services.register import mail_provider
+
+base_dir = Path(__file__).resolve().parent
+config = {
+ "mail": {
+ "request_timeout": 30,
+ "wait_timeout": 30,
+ "wait_interval": 2,
+ "providers": [],
+ },
+ "proxy": "",
+ "total": 10,
+ "threads": 3,
+}
+register_config_file = base_dir.parents[1] / "data" / "register.json"
+try:
+ saved_config = json.loads(register_config_file.read_text(encoding="utf-8"))
+ config.update({key: saved_config[key] for key in ("mail", "proxy", "total", "threads") if key in saved_config})
+except Exception:
+ pass
+
+auth_base = "https://auth.openai.com"
+platform_base = "https://platform.openai.com"
+platform_oauth_client_id = "app_2SKx67EdpoN0G6j64rFvigXD"
+platform_oauth_redirect_uri = f"{platform_base}/auth/callback"
+platform_oauth_audience = "https://api.openai.com/v1"
+platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
+user_agent = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/145.0.0.0 Safari/537.36"
+)
+sec_ch_ua = '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"'
+sec_ch_ua_full_version_list = '"Chromium";v="145.0.0.0", "Not:A-Brand";v="99.0.0.0", "Google Chrome";v="145.0.0.0"'
+default_timeout = 30
+print_lock = threading.Lock()
+stats_lock = threading.Lock()
+stats = {"done": 0, "success": 0, "fail": 0, "start_time": 0.0}
+register_log_sink = None
+
+common_headers = {
+ "accept": "application/json",
+ "accept-encoding": "gzip, deflate, br",
+ "accept-language": "en-US,en;q=0.9",
+ "cache-control": "no-cache",
+ "connection": "keep-alive",
+ "content-type": "application/json",
+ "dnt": "1",
+ "origin": auth_base,
+ "priority": "u=1, i",
+ "sec-gpc": "1",
+ "sec-ch-ua": sec_ch_ua,
+ "sec-ch-ua-arch": '"x86_64"',
+ "sec-ch-ua-bitness": '"64"',
+ "sec-ch-ua-full-version-list": sec_ch_ua_full_version_list,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-model": '""',
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-ch-ua-platform-version": '"10.0.0"',
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "same-origin",
+ "user-agent": user_agent,
+}
+
+navigate_headers = {
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ "accept-encoding": "gzip, deflate, br",
+ "accept-language": "en-US,en;q=0.9",
+ "cache-control": "max-age=0",
+ "connection": "keep-alive",
+ "dnt": "1",
+ "sec-gpc": "1",
+ "sec-ch-ua": sec_ch_ua,
+ "sec-ch-ua-arch": '"x86_64"',
+ "sec-ch-ua-bitness": '"64"',
+ "sec-ch-ua-full-version-list": sec_ch_ua_full_version_list,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-model": '""',
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-ch-ua-platform-version": '"10.0.0"',
+ "sec-fetch-dest": "document",
+ "sec-fetch-mode": "navigate",
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-user": "?1",
+ "upgrade-insecure-requests": "1",
+ "user-agent": user_agent,
+}
+
+
+def log(text: str, color: str = "") -> None:
+ colors = {"red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m"}
+ if register_log_sink:
+ try:
+ register_log_sink(text, color)
+ except Exception:
+ pass
+ with print_lock:
+ prefix = colors.get(color, "")
+ suffix = "\033[0m" if prefix else ""
+ print(f"{prefix}{datetime.now().strftime('%H:%M:%S')} {text}{suffix}")
+
+
+def step(index: int, text: str, color: str = "") -> None:
+ log(f"[任务{index}] {text}", color)
+
+
+def _make_trace_headers() -> dict[str, str]:
+ trace_id = str(random.getrandbits(64))
+ parent_id = str(random.getrandbits(64))
+ return {
+ "traceparent": f"00-{uuid.uuid4().hex}-{format(int(parent_id), '016x')}-01",
+ "tracestate": "dd=s:1;o:rum",
+ "x-datadog-origin": "rum",
+ "x-datadog-parent-id": parent_id,
+ "x-datadog-sampling-priority": "1",
+ "x-datadog-trace-id": trace_id,
+ }
+
+
+from utils.pkce import generate_pkce as _generate_pkce # noqa: F401
+
+
+def _random_password(length: int = 16) -> str:
+ chars = string.ascii_letters + string.digits + "!@#$%"
+ value = list(
+ secrets.choice(string.ascii_uppercase)
+ + secrets.choice(string.ascii_lowercase)
+ + secrets.choice(string.digits)
+ + secrets.choice("!@#$%")
+ + "".join(secrets.choice(chars) for _ in range(max(0, length - 4)))
+ )
+ random.shuffle(value)
+ return "".join(value)
+
+
+def _random_name() -> tuple[str, str]:
+ return random.choice(["James", "Robert", "John", "Michael", "David", "Mary", "Emma", "Olivia"]), random.choice(
+ ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller"]
+ )
+
+
+def _random_birthdate() -> str:
+ return f"{random.randint(1996, 2006):04d}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}"
+
+
+def _response_json(resp) -> dict:
+ try:
+ data = resp.json()
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+
+def _response_debug_detail(resp, limit: int = 800) -> str:
+ if resp is None:
+ return ""
+ data = _response_json(resp)
+ parts = [
+ f"url={str(getattr(resp, 'url', '') or '')[:300]}",
+ f"content_type={str(getattr(resp, 'headers', {}).get('content-type') or '')}",
+ ]
+ for key in ("cf-ray", "x-request-id", "openai-processing-ms"):
+ value = str(getattr(resp, "headers", {}).get(key) or "").strip()
+ if value:
+ parts.append(f"{key}={value}")
+ if data:
+ parts.append(f"json={json.dumps(data, ensure_ascii=False)[:limit]}")
+ else:
+ parts.append(f"body={str(getattr(resp, 'text', '') or '')[:limit]}")
+ return ", ".join(parts)
+
+
+def _is_cloudflare_challenge(resp) -> bool:
+ if resp is None:
+ return False
+ text = str(getattr(resp, "text", "") or "").lower()
+ headers = getattr(resp, "headers", {}) or {}
+ server = str(headers.get("server") or "").lower()
+ return (
+ "cloudflare" in server
+ or "challenges.cloudflare.com" in text
+ or "just a moment" in text
+ )
+
+
+def create_mailbox(username: str | None = None) -> dict:
+ return mail_provider.create_mailbox(config["mail"], username)
+
+
+def wait_for_code(mailbox: dict) -> str | None:
+ return mail_provider.wait_for_code(config["mail"], mailbox)
+
+
+from utils.sentinel import SentinelTokenGenerator, build_sentinel_token as _build_sentinel_token_tuple # noqa: F401
+
+
+def build_sentinel_token(session: requests.Session, device_id: str, flow: str) -> str:
+ """请求 sentinel token,返回 sentinel header 字符串(兼容旧接口)。"""
+ sentinel_val, _oai_sc_val = _build_sentinel_token_tuple(session, device_id, flow, user_agent=user_agent, sec_ch_ua=sec_ch_ua)
+ return sentinel_val
+
+
+def create_session(proxy: str = "") -> Any:
+ kwargs = {"impersonate": "chrome", "verify": False}
+ if proxy:
+ kwargs["proxy"] = proxy
+ return requests.Session(**kwargs)
+
+
+def request_with_local_retry(session: requests.Session, method: str, url: str, retry_attempts: int = 3, **kwargs):
+ last_error = ""
+ for _ in range(max(1, retry_attempts)):
+ try:
+ return session.request(method.upper(), url, timeout=default_timeout, **kwargs), ""
+ except Exception as error:
+ last_error = str(error)
+ time.sleep(1)
+ return None, last_error
+
+
+def validate_otp(session: requests.Session, device_id: str, code: str):
+ headers = dict(common_headers)
+ headers["referer"] = f"{auth_base}/email-verification"
+ headers["oai-device-id"] = device_id
+ headers.update(_make_trace_headers())
+ resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False)
+ if resp is not None and resp.status_code == 200:
+ return resp, ""
+ headers["openai-sentinel-token"] = build_sentinel_token(session, device_id, "authorize_continue")
+ resp, error = request_with_local_retry(session, "post", f"{auth_base}/api/accounts/email-otp/validate", json={"code": code}, headers=headers, verify=False)
+ return resp, error
+
+
+def extract_oauth_callback_params_from_url(url: str) -> dict[str, str] | None:
+ if not url:
+ return None
+ try:
+ params = parse_qs(urlparse(url).query)
+ except Exception:
+ return None
+ code = str((params.get("code") or [""])[0]).strip()
+ if not code:
+ return None
+ return {"code": code, "state": str((params.get("state") or [""])[0]).strip(), "scope": str((params.get("scope") or [""])[0]).strip()}
+
+
+def request_platform_oauth_token(session: requests.Session, code: str, code_verifier: str) -> dict | None:
+ headers = {
+ "accept": "*/*",
+ "accept-language": "zh-CN,zh;q=0.9",
+ "auth0-client": platform_auth0_client,
+ "cache-control": "no-cache",
+ "content-type": "application/json",
+ "origin": platform_base,
+ "pragma": "no-cache",
+ "priority": "u=1, i",
+ "referer": f"{platform_base}/",
+ "sec-ch-ua": sec_ch_ua,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "same-site",
+ "user-agent": user_agent,
+ }
+ resp = session.post(
+ f"{auth_base}/api/accounts/oauth/token",
+ headers=headers,
+ json={
+ "client_id": platform_oauth_client_id,
+ "code_verifier": code_verifier,
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": platform_oauth_redirect_uri,
+ },
+ verify=False,
+ timeout=60,
+ )
+ if resp.status_code != 200:
+ print(resp.text)
+ return None
+ return _response_json(resp)
+
+
+class PlatformRegistrar:
+ def __init__(self, proxy: str = "") -> None:
+ self.session = create_session(proxy)
+ self.device_id = str(uuid.uuid4())
+ self.code_verifier = ""
+ self.platform_auth_code = ""
+
+ def close(self) -> None:
+ self.session.close()
+
+ def _navigate_headers(self, referer: str = "") -> dict[str, str]:
+ headers = dict(navigate_headers)
+ if referer:
+ headers["referer"] = referer
+ return headers
+
+ def _json_headers(self, referer: str) -> dict[str, str]:
+ headers = dict(common_headers)
+ headers["referer"] = referer
+ headers["oai-device-id"] = self.device_id
+ headers.update(_make_trace_headers())
+ return headers
+
+ def _platform_authorize(self, email: str, index: int) -> None:
+ step(index, "开始 platform authorize")
+ self.session.cookies.set("oai-did", self.device_id, domain=".auth.openai.com")
+ self.session.cookies.set("oai-did", self.device_id, domain="auth.openai.com")
+ self.code_verifier, code_challenge = _generate_pkce()
+ params = {
+ "issuer": auth_base,
+ "client_id": platform_oauth_client_id,
+ "audience": platform_oauth_audience,
+ "redirect_uri": platform_oauth_redirect_uri,
+ "device_id": self.device_id,
+ "screen_hint": "login_or_signup",
+ "max_age": "0",
+ "login_hint": email,
+ "scope": "openid profile email offline_access",
+ "response_type": "code",
+ "response_mode": "query",
+ "state": secrets.token_urlsafe(32),
+ "nonce": secrets.token_urlsafe(32),
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "auth0Client": platform_auth0_client,
+ }
+ resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/authorize?{urlencode(params)}", headers=self._navigate_headers(f"{platform_base}/"), allow_redirects=True, verify=False)
+ if resp is None or resp.status_code != 200:
+ err = _response_json(resp).get("error", {}) if resp is not None else {}
+ detail = f": {err.get('code', '')} - {err.get('message', '')}".strip(" -") if err else ""
+ if _is_cloudflare_challenge(resp):
+ raise RuntimeError("被 Cloudflare 拦截,请更换 IP 重试")
+ debug = _response_debug_detail(resp)
+ status = getattr(resp, "status_code", "unknown")
+ raise RuntimeError(error or f"platform_authorize_http_{status}{detail}, {debug}")
+ step(index, "platform authorize 完成")
+
+ def _register_user(self, email: str, password: str, index: int) -> None:
+ step(index, "开始提交注册密码")
+ headers = self._json_headers(f"{auth_base}/create-account/password")
+ headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create")
+ resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/user/register", json={"username": email, "password": password}, headers=headers, verify=False)
+ if resp is None or resp.status_code != 200:
+ data = _response_json(resp) if resp is not None else {}
+ if data.get("message") == "Failed to create account. Please try again.":
+ step(index, "注册失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow")
+ detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else ""
+ raise RuntimeError(error or f"user_register_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
+ step(index, "提交注册密码完成")
+
+ def _send_otp(self, index: int) -> None:
+ step(index, "开始发送验证码")
+ resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/email-otp/send", headers=self._navigate_headers(f"{auth_base}/create-account/password"), allow_redirects=True, verify=False)
+ if resp is None or resp.status_code not in (200, 302):
+ raise RuntimeError(error or f"send_otp_http_{getattr(resp, 'status_code', 'unknown')}")
+ step(index, "发送验证码完成")
+
+ def _validate_otp(self, code: str, index: int) -> None:
+ step(index, f"开始校验验证码 {code}")
+ resp, error = validate_otp(self.session, self.device_id, code)
+ if resp is None or resp.status_code != 200:
+ body = ""
+ try:
+ body = (resp.text or "")[:500] if resp is not None else ""
+ except Exception:
+ pass
+ raise RuntimeError(error or f"validate_otp_http_{getattr(resp, 'status_code', 'unknown')}_body={body}")
+ step(index, "验证码校验完成")
+
+ def _create_account(self, name: str, birthdate: str, index: int) -> None:
+ step(index, "开始创建账号资料")
+ headers = self._json_headers(f"{auth_base}/about-you")
+ headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account")
+ resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/create_account", json={"name": name, "birthdate": birthdate}, headers=headers, verify=False)
+ if resp is None or resp.status_code not in (200, 302):
+ data = _response_json(resp) if resp is not None else {}
+ if data.get("message") == "Failed to create account. Please try again.":
+ step(index, "创建账号失败提示: 邮箱域名很可能因滥用被封禁,请更换邮箱域名", "yellow")
+ detail = f", detail={json.dumps(data, ensure_ascii=False)}" if data else ""
+ raise RuntimeError(error or f"create_account_http_{getattr(resp, 'status_code', 'unknown')}{detail}")
+ data = _response_json(resp)
+ callback_params = extract_oauth_callback_params_from_url(str(data.get("continue_url") or "").strip())
+ self.platform_auth_code = str((callback_params or {}).get("code") or "").strip()
+ step(index, "创建账号资料完成")
+
+ def _exchange_registered_tokens(self, index: int) -> dict:
+ step(index, "开始换 token")
+ tokens = request_platform_oauth_token(self.session, self.platform_auth_code, self.code_verifier)
+ if not tokens:
+ raise RuntimeError("token换取失败")
+ step(index, "token 换取完成")
+ return tokens
+
+ def register(self, index: int) -> dict:
+ step(index, "开始创建邮箱")
+ mailbox = create_mailbox()
+ email = str(mailbox.get("address") or "").strip()
+ if not email:
+ raise RuntimeError("邮箱服务未返回 address")
+ label = str(mailbox.get("label") or "")
+ step(index, f"邮箱创建完成[{label}]: {email}")
+ password = _random_password()
+ first_name, last_name = _random_name()
+ self._platform_authorize(email, index)
+ self._register_user(email, password, index)
+ self._send_otp(index)
+ step(index, "开始等待注册验证码")
+ code = wait_for_code(mailbox)
+ if not code:
+ raise RuntimeError("等待注册验证码超时")
+ step(index, f"收到注册验证码: {code}")
+ self._validate_otp(code, index)
+ self._create_account(f"{first_name} {last_name}", _random_birthdate(), index)
+ tokens = self._exchange_registered_tokens(index)
+ return {
+ "email": email,
+ "password": password,
+ "access_token": str(tokens.get("access_token") or "").strip(),
+ "refresh_token": str(tokens.get("refresh_token") or "").strip(),
+ "id_token": str(tokens.get("id_token") or "").strip(),
+ "source_type": "web",
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+def worker(index: int) -> dict:
+ start = time.time()
+ registrar = PlatformRegistrar(config["proxy"])
+ try:
+ step(index, "任务启动")
+ result = registrar.register(index)
+ cost = time.time() - start
+ access_token = str(result["access_token"])
+ account_service.add_account_items([result])
+ refresh_result = account_service.refresh_accounts([access_token])
+ if refresh_result.get("errors"):
+ step(index, f"账号已保存,刷新状态暂未成功,稍后可重试: {refresh_result['errors']}", "yellow")
+ with stats_lock:
+ stats["done"] += 1
+ stats["success"] += 1
+ avg = (time.time() - stats["start_time"]) / stats["success"]
+ log(f'{result["email"]} 注册成功,本次耗时{cost:.1f}s,全局平均每个号注册耗时{avg:.1f}s', "green")
+ return {"ok": True, "index": index, "result": result}
+ except Exception as e:
+ cost = time.time() - start
+ with stats_lock:
+ stats["done"] += 1
+ stats["fail"] += 1
+ log(f"任务{index} 注册失败,本次耗时{cost:.1f}s,原因: {e}", "red")
+ return {"ok": False, "index": index, "error": str(e)}
+ finally:
+ registrar.close()
diff --git a/services/register_service.py b/services/register_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..a29ccb73d5739ed35af05266987ab73b83e73ea5
--- /dev/null
+++ b/services/register_service.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import json
+import threading
+import time
+import uuid
+from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
+from datetime import datetime, timezone
+from pathlib import Path
+
+from services.account_service import account_service
+from services.config import DATA_DIR
+from services.register import openai_register
+
+
+REGISTER_FILE = DATA_DIR / "register.json"
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _default_config() -> dict:
+ return {**openai_register.config, "mode": "total", "target_quota": 100, "target_available": 10, "check_interval": 5, "enabled": False, "stats": {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": openai_register.config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, "current_quota": 0, "current_available": 0}}
+
+
+def _normalize(raw: dict) -> dict:
+ cfg = _default_config()
+ cfg.update({k: v for k, v in raw.items() if k not in {"stats", "logs"}})
+ cfg["total"] = max(1, int(cfg.get("total") or 1))
+ cfg["threads"] = max(1, int(cfg.get("threads") or 1))
+ cfg["mode"] = str(cfg.get("mode") or "total").strip() if str(cfg.get("mode") or "total").strip() in {"total", "quota", "available"} else "total"
+ cfg["target_quota"] = max(1, int(cfg.get("target_quota") or 1))
+ cfg["target_available"] = max(1, int(cfg.get("target_available") or 1))
+ cfg["check_interval"] = max(1, int(cfg.get("check_interval") or 5))
+ cfg["proxy"] = str(cfg.get("proxy") or "").strip()
+ cfg["enabled"] = bool(cfg.get("enabled"))
+ stats = {**_default_config()["stats"], **(raw.get("stats") if isinstance(raw.get("stats"), dict) else {}),
+ "threads": cfg["threads"]}
+ cfg["stats"] = stats
+ return cfg
+
+
+class RegisterService:
+ def __init__(self, store_file: Path):
+ self._store_file = store_file
+ self._lock = threading.RLock()
+ self._runner: threading.Thread | None = None
+ self._logs: list[dict] = []
+ openai_register.register_log_sink = self._append_log
+ self._config = self._load()
+ if self._config["enabled"]:
+ self.start()
+
+ def _load(self) -> dict:
+ try:
+ return _normalize(json.loads(self._store_file.read_text(encoding="utf-8")))
+ except Exception:
+ return _normalize({})
+
+ def _save(self) -> None:
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
+ self._store_file.write_text(json.dumps(self._config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+
+ def get(self) -> dict:
+ with self._lock:
+ return json.loads(json.dumps({**self._config, "logs": self._logs[-300:]}, ensure_ascii=False))
+
+ def _inject_proxy_to_mail(self) -> None:
+ proxy = str(self._config.get("proxy") or "").strip()
+ if proxy and isinstance(self._config.get("mail"), dict):
+ self._config["mail"]["proxy"] = proxy
+
+ def update(self, updates: dict) -> dict:
+ with self._lock:
+ self._config = _normalize({**self._config, **updates})
+ self._inject_proxy_to_mail()
+ openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")})
+ self._save()
+ return self.get()
+
+ def start(self) -> dict:
+ with self._lock:
+ if self._runner and self._runner.is_alive():
+ self._config["enabled"] = True
+ self._save()
+ return self.get()
+ self._config["enabled"] = True
+ self._inject_proxy_to_mail()
+ self._logs = []
+ metrics = self._pool_metrics()
+ self._config["stats"] = {"job_id": uuid.uuid4().hex, "success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], **metrics, "started_at": _now(), "updated_at": _now()}
+ openai_register.config.update({k: self._config[k] for k in ("mail", "proxy", "total", "threads")})
+ with openai_register.stats_lock:
+ openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": time.time()})
+ self._save()
+ self._runner = threading.Thread(target=self._run, daemon=True, name="openai-register")
+ self._runner.start()
+ self._append_log(f"注册任务启动,模式={self._config['mode']},线程数={self._config['threads']}", "yellow")
+ return self.get()
+
+ def stop(self) -> dict:
+ with self._lock:
+ self._config["enabled"] = False
+ self._config["stats"]["updated_at"] = _now()
+ self._save()
+ self._append_log("已请求停止注册任务,正在等待当前运行任务结束", "yellow")
+ return self.get()
+
+ def reset(self) -> dict:
+ with self._lock:
+ self._logs = []
+ self._config["stats"] = {"success": 0, "fail": 0, "done": 0, "running": 0, "threads": self._config["threads"], "elapsed_seconds": 0, "avg_seconds": 0, "success_rate": 0, **self._pool_metrics(), "updated_at": _now()}
+ with openai_register.stats_lock:
+ openai_register.stats.update({"done": 0, "success": 0, "fail": 0, "start_time": 0.0})
+ self._save()
+ return self.get()
+
+ def _append_log(self, text: str, color: str = "") -> None:
+ with self._lock:
+ self._logs.append({"time": _now(), "text": str(text), "level": str(color or "info")})
+ self._logs = self._logs[-300:]
+
+ def _pool_metrics(self) -> dict:
+ items = account_service.list_accounts()
+ normal = [item for item in items if item.get("status") == "正常"]
+ return {
+ "current_quota": sum(int(item.get("quota") or 0) for item in normal if not item.get("image_quota_unknown")),
+ "current_available": len(normal),
+ }
+
+ def _target_reached(self, cfg: dict, submitted: int) -> bool:
+ mode = str(cfg.get("mode") or "total")
+ metrics = self._pool_metrics()
+ self._bump(**metrics)
+ if mode == "quota":
+ reached = metrics["current_quota"] >= int(cfg.get("target_quota") or 1)
+ self._append_log(f"检查号池:当前正常账号={metrics['current_available']},当前剩余额度={metrics['current_quota']},目标额度={cfg.get('target_quota')},{'跳过注册' if reached else '继续注册'}", "yellow")
+ return reached
+ if mode == "available":
+ reached = metrics["current_available"] >= int(cfg.get("target_available") or 1)
+ self._append_log(f"检查号池:当前正常账号={metrics['current_available']},目标账号={cfg.get('target_available')},当前剩余额度={metrics['current_quota']},{'跳过注册' if reached else '继续注册'}", "yellow")
+ return reached
+ return submitted >= int(cfg.get("total") or 1)
+
+ def _bump(self, **updates) -> None:
+ with self._lock:
+ self._config["stats"].update(updates)
+ stats = self._config["stats"]
+ started_at = str(stats.get("started_at") or "")
+ if started_at:
+ try:
+ elapsed = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds())
+ except Exception:
+ elapsed = 0.0
+ done = int(stats.get("done") or 0)
+ success = int(stats.get("success") or 0)
+ fail = int(stats.get("fail") or 0)
+ stats["elapsed_seconds"] = round(elapsed, 1)
+ stats["avg_seconds"] = round(elapsed / success, 1) if success else 0
+ stats["success_rate"] = round(success * 100 / max(1, success + fail), 1)
+ self._config["stats"]["updated_at"] = _now()
+ self._save()
+
+ def _run(self) -> None:
+ threads = int(self.get()["threads"])
+ submitted, done, success, fail = 0, 0, 0, 0
+ with ThreadPoolExecutor(max_workers=threads) as executor:
+ futures = set()
+ while True:
+ cfg = self.get()
+ while self.get()["enabled"] and not self._target_reached(cfg, submitted) and len(futures) < threads:
+ submitted += 1
+ futures.add(executor.submit(openai_register.worker, submitted))
+ self._bump(running=len(futures), done=done, success=success, fail=fail)
+ if not futures and (not self.get()["enabled"] or str(cfg.get("mode") or "total") == "total"):
+ break
+ if not futures:
+ time.sleep(max(1, int(cfg.get("check_interval") or 5)))
+ continue
+ finished, futures = wait(futures, return_when=FIRST_COMPLETED)
+ for future in finished:
+ done += 1
+ try:
+ result = future.result()
+ success += 1 if result.get("ok") else 0
+ fail += 0 if result.get("ok") else 1
+ except Exception:
+ fail += 1
+ self._bump(running=0, done=done, success=success, fail=fail, finished_at=_now())
+ with self._lock:
+ self._config["enabled"] = False
+ self._save()
+ self._append_log(f"注册任务结束,成功{success},失败{fail}", "yellow")
+
+
+register_service = RegisterService(REGISTER_FILE)
diff --git a/services/storage/__init__.py b/services/storage/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f32c64a60a2066fc162d7c0dbfb4f7d82e972d6
--- /dev/null
+++ b/services/storage/__init__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from services.storage.factory import create_storage_backend
+
+__all__ = ["create_storage_backend"]
diff --git a/services/storage/base.py b/services/storage/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..14097b88c1224971d50f3d41fde4259636cbed8d
--- /dev/null
+++ b/services/storage/base.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+
+class StorageBackend(ABC):
+ """抽象存储后端基类"""
+
+ @abstractmethod
+ def load_accounts(self) -> list[dict[str, Any]]:
+ """加载所有账号数据"""
+ pass
+
+ @abstractmethod
+ def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
+ """保存所有账号数据"""
+ pass
+
+ @abstractmethod
+ def load_auth_keys(self) -> list[dict[str, Any]]:
+ """加载所有鉴权密钥数据"""
+ pass
+
+ @abstractmethod
+ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
+ """保存所有鉴权密钥数据"""
+ pass
+
+ @abstractmethod
+ def health_check(self) -> dict[str, Any]:
+ """健康检查,返回存储后端状态"""
+ pass
+
+ @abstractmethod
+ def get_backend_info(self) -> dict[str, Any]:
+ """获取存储后端信息"""
+ pass
diff --git a/services/storage/database_storage.py b/services/storage/database_storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..91d8d8d1bc76c25891418d05e4dc8db8341fcdf5
--- /dev/null
+++ b/services/storage/database_storage.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from sqlalchemy import Column, String, Text, create_engine, Integer, text
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+from services.storage.base import StorageBackend
+
+Base = declarative_base()
+
+
+class AccountModel(Base):
+ """账号数据模型"""
+ __tablename__ = "accounts"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ access_token = Column(String(2048), unique=True, nullable=False, index=True)
+ data = Column(Text, nullable=False) # JSON 格式存储完整账号数据
+
+
+class AuthKeyModel(Base):
+ """鉴权密钥数据模型"""
+ __tablename__ = "auth_keys"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ key_id = Column(String(255), unique=True, nullable=False, index=True)
+ data = Column(Text, nullable=False)
+
+
+class DatabaseStorageBackend(StorageBackend):
+ """数据库存储后端(支持 SQLite、PostgreSQL、MySQL 等)"""
+
+ def __init__(self, database_url: str):
+ self.database_url = database_url
+ self.engine = create_engine(
+ database_url,
+ pool_pre_ping=True, # 自动检测连接是否有效
+ pool_recycle=3600, # 1小时回收连接
+ )
+ Base.metadata.create_all(self.engine)
+ self.Session = sessionmaker(bind=self.engine)
+
+ def load_accounts(self) -> list[dict[str, Any]]:
+ """从数据库加载账号数据"""
+ session = self.Session()
+ try:
+ accounts = []
+ for row in session.query(AccountModel).all():
+ try:
+ account_data = json.loads(row.data)
+ if isinstance(account_data, dict):
+ accounts.append(account_data)
+ except json.JSONDecodeError:
+ continue
+ return accounts
+ finally:
+ session.close()
+
+ def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
+ """保存账号数据到数据库"""
+ self._save_rows(AccountModel, accounts, "access_token")
+
+ def load_auth_keys(self) -> list[dict[str, Any]]:
+ """从数据库加载鉴权密钥数据"""
+ return self._load_rows(AuthKeyModel)
+
+ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
+ """保存鉴权密钥数据到数据库"""
+ self._save_rows(AuthKeyModel, auth_keys, "id", "key_id")
+
+ def _load_rows(self, model: type[AccountModel] | type[AuthKeyModel]) -> list[dict[str, Any]]:
+ session = self.Session()
+ try:
+ items = []
+ for row in session.query(model).all():
+ try:
+ item_data = json.loads(row.data)
+ if isinstance(item_data, dict):
+ items.append(item_data)
+ except json.JSONDecodeError:
+ continue
+ return items
+ finally:
+ session.close()
+
+ def _save_rows(
+ self,
+ model: type[AccountModel] | type[AuthKeyModel],
+ items: list[dict[str, Any]],
+ source_key: str,
+ target_key: str | None = None,
+ ) -> None:
+ session = self.Session()
+ try:
+ session.query(model).delete()
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ key_value = str(item.get(source_key) or "").strip()
+ if not key_value:
+ continue
+ session.add(
+ model(
+ **{target_key or source_key: key_value},
+ data=json.dumps(item, ensure_ascii=False),
+ )
+ )
+ session.commit()
+ except Exception as e:
+ session.rollback()
+ raise e
+ finally:
+ session.close()
+
+ def health_check(self) -> dict[str, Any]:
+ """健康检查"""
+ try:
+ session = self.Session()
+ try:
+ # 尝试执行简单查询
+ session.execute(text("SELECT 1"))
+ count = session.query(AccountModel).count()
+ auth_key_count = session.query(AuthKeyModel).count()
+ return {
+ "status": "healthy",
+ "backend": "database",
+ "database_url": self._mask_password(self.database_url),
+ "account_count": count,
+ "auth_key_count": auth_key_count,
+ }
+ finally:
+ session.close()
+ except Exception as e:
+ return {
+ "status": "unhealthy",
+ "backend": "database",
+ "error": str(e),
+ }
+
+ def get_backend_info(self) -> dict[str, Any]:
+ """获取存储后端信息"""
+ db_type = "unknown"
+ if "sqlite" in self.database_url:
+ db_type = "sqlite"
+ elif "postgresql" in self.database_url or "postgres" in self.database_url:
+ db_type = "postgresql"
+ elif "mysql" in self.database_url:
+ db_type = "mysql"
+
+ return {
+ "type": "database",
+ "db_type": db_type,
+ "description": f"数据库存储 ({db_type})",
+ "database_url": self._mask_password(self.database_url),
+ }
+
+ @staticmethod
+ def _mask_password(url: str) -> str:
+ """隐藏数据库连接字符串中的密码"""
+ if "://" not in url:
+ return url
+ try:
+ protocol, rest = url.split("://", 1)
+ if "@" in rest:
+ credentials, host = rest.split("@", 1)
+ if ":" in credentials:
+ username, _ = credentials.split(":", 1)
+ return f"{protocol}://{username}:****@{host}"
+ return url
+ except Exception:
+ return url
diff --git a/services/storage/factory.py b/services/storage/factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..585a4f207556776e9477a1ef46afca1392b887d9
--- /dev/null
+++ b/services/storage/factory.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from services.storage.base import StorageBackend
+from services.storage.database_storage import DatabaseStorageBackend
+from services.storage.git_storage import GitStorageBackend
+from services.storage.json_storage import JSONStorageBackend
+
+
+def create_storage_backend(data_dir: Path) -> StorageBackend:
+ """
+ 根据环境变量创建存储后端
+
+ 环境变量:
+ - STORAGE_BACKEND: json|sqlite|postgres|git (默认 json)
+ - DATABASE_URL: 数据库连接字符串 (用于 sqlite/postgres)
+ - GIT_REPO_URL: Git 仓库地址 (用于 git)
+ - GIT_TOKEN: Git 访问令牌 (用于 git)
+ - GIT_BRANCH: Git 分支 (默认 main)
+ - GIT_FILE_PATH: Git 仓库中的文件路径 (默认 accounts.json)
+ """
+ backend_type = os.getenv("STORAGE_BACKEND", "json").lower().strip()
+
+ print(f"[storage] Initializing storage backend: {backend_type}")
+
+ if backend_type == "json":
+ # 本地 JSON 文件存储
+ file_path = data_dir / "accounts.json"
+ auth_keys_path = data_dir / "auth_keys.json"
+ print(f"[storage] Using JSON storage: {file_path}")
+ return JSONStorageBackend(file_path, auth_keys_path)
+
+ elif backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
+ # 数据库存储
+ database_url = os.getenv("DATABASE_URL", "").strip()
+
+ if not database_url:
+ # 如果没有指定 DATABASE_URL,使用本地 SQLite
+ database_url = f"sqlite:///{data_dir / 'accounts.db'}"
+ print(f"[storage] No DATABASE_URL provided, using local SQLite: {database_url}")
+ else:
+ print(f"[storage] Using database storage: {_mask_password(database_url)}")
+
+ return DatabaseStorageBackend(database_url)
+
+ elif backend_type == "git":
+ # Git 仓库存储
+ repo_url = os.getenv("GIT_REPO_URL", "").strip()
+ token = os.getenv("GIT_TOKEN", "").strip()
+ branch = os.getenv("GIT_BRANCH", "main").strip()
+ file_path = os.getenv("GIT_FILE_PATH", "accounts.json").strip()
+ auth_keys_file_path = os.getenv("GIT_AUTH_KEYS_FILE_PATH", "auth_keys.json").strip()
+
+ if not repo_url:
+ raise ValueError(
+ "GIT_REPO_URL is required when using git storage backend. "
+ "Please set GIT_REPO_URL environment variable."
+ )
+
+ print(f"[storage] Using Git storage: {_mask_token(repo_url)}, branch: {branch}, file: {file_path}")
+
+ cache_dir = data_dir / "git_cache"
+ return GitStorageBackend(
+ repo_url=repo_url,
+ token=token,
+ branch=branch,
+ file_path=file_path,
+ auth_keys_file_path=auth_keys_file_path,
+ local_cache_dir=cache_dir,
+ )
+
+ else:
+ raise ValueError(
+ f"Unknown storage backend: {backend_type}. "
+ f"Supported backends: json, sqlite, postgres, git"
+ )
+
+
+def _mask_password(url: str) -> str:
+ """隐藏数据库连接字符串中的密码"""
+ if "://" not in url:
+ return url
+ try:
+ protocol, rest = url.split("://", 1)
+ if "@" in rest:
+ credentials, host = rest.split("@", 1)
+ if ":" in credentials:
+ username, _ = credentials.split(":", 1)
+ return f"{protocol}://{username}:****@{host}"
+ return url
+ except Exception:
+ return url
+
+
+def _mask_token(url: str) -> str:
+ """隐藏 URL 中的 token"""
+ if "@" in url and "://" in url:
+ protocol, rest = url.split("://", 1)
+ if "@" in rest:
+ _, host = rest.split("@", 1)
+ return f"{protocol}://****@{host}"
+ return url
diff --git a/services/storage/git_storage.py b/services/storage/git_storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..33b062751970784c4516d0e291421db74abcdb53
--- /dev/null
+++ b/services/storage/git_storage.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+import json
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Any
+
+from git import Repo
+from git.exc import GitCommandError
+
+from services.storage.base import StorageBackend
+
+
+class GitStorageBackend(StorageBackend):
+ """Git 私有仓库存储后端"""
+
+ def __init__(
+ self,
+ repo_url: str,
+ token: str,
+ branch: str = "main",
+ file_path: str = "accounts.json",
+ auth_keys_file_path: str = "auth_keys.json",
+ local_cache_dir: Path | None = None,
+ ):
+ self.repo_url = repo_url
+ self.token = token
+ self.branch = branch
+ self.file_path = file_path
+ self.auth_keys_file_path = auth_keys_file_path
+
+ # 本地缓存目录
+ if local_cache_dir is None:
+ local_cache_dir = Path(tempfile.gettempdir()) / "chatgpt2api_git_cache"
+ self.local_cache_dir = local_cache_dir
+ self.local_cache_dir.mkdir(parents=True, exist_ok=True)
+
+ # 构建带认证的 Git URL
+ self.auth_repo_url = self._build_auth_url(repo_url, token)
+
+ @staticmethod
+ def _build_auth_url(repo_url: str, token: str) -> str:
+ """构建带认证的 Git URL"""
+ if not token:
+ return repo_url
+
+ # 支持 HTTPS 格式:https://github.com/user/repo.git
+ if repo_url.startswith("https://"):
+ # 插入 token
+ return repo_url.replace("https://", f"https://{token}@")
+
+ # 支持 git@ 格式:git@github.com:user/repo.git
+ # 转换为 HTTPS 格式
+ if repo_url.startswith("git@"):
+ repo_url = repo_url.replace("git@", "https://")
+ repo_url = repo_url.replace(".com:", ".com/")
+ return repo_url.replace("https://", f"https://{token}@")
+
+ return repo_url
+
+ def _clone_or_pull(self) -> Repo:
+ """克隆或拉取仓库"""
+ repo_path = self.local_cache_dir / "repo"
+
+ if repo_path.exists() and (repo_path / ".git").exists():
+ # 仓库已存在,拉取最新代码
+ try:
+ repo = Repo(repo_path)
+ origin = repo.remote("origin")
+ origin.pull(self.branch)
+ return repo
+ except GitCommandError:
+ # 拉取失败,删除重新克隆
+ shutil.rmtree(repo_path)
+
+ # 克隆仓库
+ repo = Repo.clone_from(
+ self.auth_repo_url,
+ repo_path,
+ branch=self.branch,
+ )
+ return repo
+
+ def load_accounts(self) -> list[dict[str, Any]]:
+ """从 Git 仓库加载账号数据"""
+ try:
+ return self._load_json_file(self.file_path)
+ except Exception as e:
+ print(f"[git-storage] load failed: {e}")
+ raise
+
+ def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
+ """保存账号数据到 Git 仓库"""
+ try:
+ self._save_json_file(self.file_path, accounts, "Update accounts data")
+ except Exception as e:
+ print(f"[git-storage] save failed: {e}")
+ raise e
+
+ def load_auth_keys(self) -> list[dict[str, Any]]:
+ """从 Git 仓库加载鉴权密钥数据"""
+ try:
+ data = self._load_json_value(self.auth_keys_file_path)
+ if isinstance(data, dict):
+ data = data.get("items")
+ return data if isinstance(data, list) else []
+ except Exception as e:
+ print(f"[git-storage] load failed: {e}")
+ raise
+
+ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
+ """保存鉴权密钥数据到 Git 仓库"""
+ try:
+ self._save_json_file(self.auth_keys_file_path, {"items": auth_keys}, "Update auth keys data")
+ except Exception as e:
+ print(f"[git-storage] save failed: {e}")
+ raise e
+
+ def _load_json_file(self, file_path: str) -> list[dict[str, Any]]:
+ data = self._load_json_value(file_path)
+ return data if isinstance(data, list) else []
+
+ def _load_json_value(self, file_path: str) -> Any:
+ repo = self._clone_or_pull()
+ file_full_path = Path(repo.working_dir) / file_path
+ if not file_full_path.exists():
+ return None
+ return json.loads(file_full_path.read_text(encoding="utf-8"))
+
+ def _save_json_file(self, file_path: str, items: Any, message: str) -> None:
+ repo = self._clone_or_pull()
+ file_full_path = Path(repo.working_dir) / file_path
+ file_full_path.parent.mkdir(parents=True, exist_ok=True)
+ file_full_path.write_text(
+ json.dumps(items, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ repo.index.add([file_path])
+ if repo.is_dirty():
+ repo.index.commit(message)
+ repo.remote("origin").push(self.branch)
+
+ def health_check(self) -> dict[str, Any]:
+ """健康检查"""
+ try:
+ repo = self._clone_or_pull()
+ return {
+ "status": "healthy",
+ "backend": "git",
+ "repo_url": self._mask_token(self.repo_url),
+ "branch": self.branch,
+ "file_path": self.file_path,
+ "auth_keys_file_path": self.auth_keys_file_path,
+ "last_commit": repo.head.commit.hexsha[:8],
+ }
+ except Exception as e:
+ return {
+ "status": "unhealthy",
+ "backend": "git",
+ "error": str(e),
+ }
+
+ def get_backend_info(self) -> dict[str, Any]:
+ """获取存储后端信息"""
+ return {
+ "type": "git",
+ "description": "Git 私有仓库存储",
+ "repo_url": self._mask_token(self.repo_url),
+ "branch": self.branch,
+ "file_path": self.file_path,
+ "auth_keys_file_path": self.auth_keys_file_path,
+ }
+
+ @staticmethod
+ def _mask_token(url: str) -> str:
+ """隐藏 URL 中的 token"""
+ if "@" in url and "://" in url:
+ protocol, rest = url.split("://", 1)
+ if "@" in rest:
+ _, host = rest.split("@", 1)
+ return f"{protocol}://****@{host}"
+ return url
diff --git a/services/storage/json_storage.py b/services/storage/json_storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..591e376cf62ed3cda3e5e336bd4375a139373ba4
--- /dev/null
+++ b/services/storage/json_storage.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from services.storage.base import StorageBackend
+
+
+class JSONStorageBackend(StorageBackend):
+ """本地 JSON 文件存储后端"""
+
+ def __init__(self, file_path: Path, auth_keys_path: Path | None = None):
+ self.file_path = file_path
+ self.auth_keys_path = auth_keys_path or file_path.with_name("auth_keys.json")
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
+ self.auth_keys_path.parent.mkdir(parents=True, exist_ok=True)
+
+ @staticmethod
+ def _load_json_list(file_path: Path) -> list[dict[str, Any]]:
+ if not file_path.exists():
+ return []
+ try:
+ data = json.loads(file_path.read_text(encoding="utf-8"))
+ return data if isinstance(data, list) else []
+ except (json.JSONDecodeError, Exception):
+ return []
+
+ @staticmethod
+ def _save_json_list(file_path: Path, items: list[dict[str, Any]]) -> None:
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ file_path.write_text(
+ json.dumps(items, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ def load_accounts(self) -> list[dict[str, Any]]:
+ """从 JSON 文件加载账号数据"""
+ return self._load_json_list(self.file_path)
+
+ def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
+ """保存账号数据到 JSON 文件"""
+ self._save_json_list(self.file_path, accounts)
+
+ def load_auth_keys(self) -> list[dict[str, Any]]:
+ """从 JSON 文件加载鉴权密钥数据"""
+ if not self.auth_keys_path.exists():
+ return []
+ try:
+ data = json.loads(self.auth_keys_path.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, Exception):
+ return []
+ if isinstance(data, dict):
+ data = data.get("items")
+ return data if isinstance(data, list) else []
+
+ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
+ """保存鉴权密钥数据到 JSON 文件"""
+ self.auth_keys_path.parent.mkdir(parents=True, exist_ok=True)
+ self.auth_keys_path.write_text(
+ json.dumps({"items": auth_keys}, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ def health_check(self) -> dict[str, Any]:
+ """健康检查"""
+ try:
+ # 检查文件是否可读写
+ if self.file_path.exists():
+ self.file_path.read_text(encoding="utf-8")
+ return {
+ "status": "healthy",
+ "backend": "json",
+ "file_exists": self.file_path.exists(),
+ "file_path": str(self.file_path),
+ "auth_keys_file_exists": self.auth_keys_path.exists(),
+ "auth_keys_file_path": str(self.auth_keys_path),
+ }
+ except Exception as e:
+ return {
+ "status": "unhealthy",
+ "backend": "json",
+ "error": str(e),
+ }
+
+ def get_backend_info(self) -> dict[str, Any]:
+ """获取存储后端信息"""
+ return {
+ "type": "json",
+ "description": "本地 JSON 文件存储",
+ "file_path": str(self.file_path),
+ "file_exists": self.file_path.exists(),
+ "auth_keys_file_path": str(self.auth_keys_path),
+ "auth_keys_file_exists": self.auth_keys_path.exists(),
+ }
diff --git a/services/sub2api_service.py b/services/sub2api_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..51635d58a164ed20662a52ea19c916a02e4f676f
--- /dev/null
+++ b/services/sub2api_service.py
@@ -0,0 +1,523 @@
+"""Sub2API integration for browsing and importing ChatGPT OAuth accounts from a sub2api admin."""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+import uuid
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime, timezone
+from pathlib import Path
+from threading import Lock
+
+from curl_cffi.requests import Session
+
+from services.account_service import account_service
+from services.config import DATA_DIR
+
+
+SUB2API_CONFIG_FILE = DATA_DIR / "sub2api_config.json"
+
+# Cached JWT per server to avoid re-login on every list/import call.
+# Token lifetime on sub2api defaults to 24h; we refresh 5 min before expiry.
+_TOKEN_REFRESH_SKEW = 5 * 60
+
+
+def _new_id() -> str:
+ return uuid.uuid4().hex[:12]
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _clean(value: object) -> str:
+ return str(value or "").strip()
+
+
+def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
+ if not isinstance(raw, dict):
+ return None
+ status = _clean(raw.get("status")) or "failed"
+ if fail_unfinished and status in {"pending", "running"}:
+ status = "failed"
+ return {
+ "job_id": _clean(raw.get("job_id")) or uuid.uuid4().hex,
+ "status": status,
+ "created_at": _clean(raw.get("created_at")) or _now_iso(),
+ "updated_at": _clean(raw.get("updated_at")) or _clean(raw.get("created_at")) or _now_iso(),
+ "total": int(raw.get("total") or 0),
+ "completed": int(raw.get("completed") or 0),
+ "added": int(raw.get("added") or 0),
+ "skipped": int(raw.get("skipped") or 0),
+ "refreshed": int(raw.get("refreshed") or 0),
+ "failed": int(raw.get("failed") or 0),
+ "errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
+ }
+
+
+def _normalize_server(raw: dict) -> dict:
+ return {
+ "id": _clean(raw.get("id")) or _new_id(),
+ "name": _clean(raw.get("name")),
+ "base_url": _clean(raw.get("base_url")),
+ "email": _clean(raw.get("email")),
+ "password": _clean(raw.get("password")),
+ "api_key": _clean(raw.get("api_key")),
+ "group_id": _clean(raw.get("group_id")),
+ "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
+ }
+
+
+class Sub2APIConfig:
+ def __init__(self, store_file: Path):
+ self._store_file = store_file
+ self._lock = Lock()
+ self._servers: list[dict] = self._load()
+
+ def _load(self) -> list[dict]:
+ if not self._store_file.exists():
+ return []
+ try:
+ raw = json.loads(self._store_file.read_text(encoding="utf-8"))
+ if isinstance(raw, list):
+ return [_normalize_server(item) for item in raw if isinstance(item, dict)]
+ except Exception:
+ pass
+ return []
+
+ def _save(self) -> None:
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
+ self._store_file.write_text(
+ json.dumps(self._servers, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ def list_servers(self) -> list[dict]:
+ with self._lock:
+ return [dict(server) for server in self._servers]
+
+ def get_server(self, server_id: str) -> dict | None:
+ with self._lock:
+ for server in self._servers:
+ if server["id"] == server_id:
+ return dict(server)
+ return None
+
+ def add_server(
+ self,
+ *,
+ name: str,
+ base_url: str,
+ email: str,
+ password: str,
+ api_key: str,
+ group_id: str = "",
+ ) -> dict:
+ server = _normalize_server({
+ "id": _new_id(),
+ "name": name,
+ "base_url": base_url,
+ "email": email,
+ "password": password,
+ "api_key": api_key,
+ "group_id": group_id,
+ })
+ with self._lock:
+ self._servers.append(server)
+ self._save()
+ _token_cache.pop(server["id"], None)
+ return dict(server)
+
+ def update_server(self, server_id: str, updates: dict) -> dict | None:
+ with self._lock:
+ for index, server in enumerate(self._servers):
+ if server["id"] != server_id:
+ continue
+ merged = {**server, **{k: v for k, v in updates.items() if v is not None}, "id": server_id}
+ self._servers[index] = _normalize_server(merged)
+ self._save()
+ result = dict(self._servers[index])
+ break
+ else:
+ return None
+ _token_cache.pop(server_id, None)
+ return result
+
+ def delete_server(self, server_id: str) -> bool:
+ with self._lock:
+ before = len(self._servers)
+ self._servers = [server for server in self._servers if server["id"] != server_id]
+ removed = len(self._servers) < before
+ if removed:
+ self._save()
+ if removed:
+ _token_cache.pop(server_id, None)
+ return removed
+
+ def set_import_job(self, server_id: str, import_job: dict | None) -> dict | None:
+ with self._lock:
+ for index, server in enumerate(self._servers):
+ if server["id"] != server_id:
+ continue
+ next_server = dict(server)
+ next_server["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
+ self._servers[index] = next_server
+ self._save()
+ return dict(next_server)
+ return None
+
+ def get_import_job(self, server_id: str) -> dict | None:
+ with self._lock:
+ for server in self._servers:
+ if server["id"] == server_id:
+ job = server.get("import_job")
+ return dict(job) if isinstance(job, dict) else None
+ return None
+
+
+# Per-server cached access token: {server_id: (jwt, expires_at_epoch)}
+_token_cache: dict[str, tuple[str, float]] = {}
+_token_cache_lock = Lock()
+
+
+def _login(base_url: str, email: str, password: str) -> tuple[str, float]:
+ url = f"{base_url.rstrip('/')}/api/v1/auth/login"
+ session = Session(verify=True)
+ try:
+ response = session.post(
+ url,
+ json={"email": email, "password": password},
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
+ timeout=30,
+ )
+ if not response.ok:
+ raise RuntimeError(f"sub2api login failed: HTTP {response.status_code} {response.text[:200]}")
+ payload = response.json()
+ finally:
+ session.close()
+
+ body = _unwrap_envelope(payload)
+ if not isinstance(body, dict):
+ raise RuntimeError("sub2api login payload is invalid")
+
+ token = _clean(body.get("access_token"))
+ if not token:
+ raise RuntimeError("sub2api login did not return access_token")
+
+ expires_in = int(body.get("expires_in") or 3600)
+ expires_at = time.time() + max(60, expires_in) - _TOKEN_REFRESH_SKEW
+ return token, expires_at
+
+
+def _auth_headers(server: dict) -> dict[str, str]:
+ api_key = _clean(server.get("api_key"))
+ if api_key:
+ return {"x-api-key": api_key, "Accept": "application/json"}
+
+ email = _clean(server.get("email"))
+ password = _clean(server.get("password"))
+ if not email or not password:
+ raise RuntimeError("sub2api server requires email+password or api_key")
+
+ server_id = _clean(server.get("id"))
+ base_url = _clean(server.get("base_url"))
+
+ with _token_cache_lock:
+ cached = _token_cache.get(server_id)
+ if cached and cached[1] > time.time():
+ return {"Authorization": f"Bearer {cached[0]}", "Accept": "application/json"}
+
+ token, expires_at = _login(base_url, email, password)
+ with _token_cache_lock:
+ _token_cache[server_id] = (token, expires_at)
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
+
+
+def _extract_access_token(credentials: object) -> str:
+ if not isinstance(credentials, dict):
+ return ""
+ for key in ("access_token", "accessToken", "token"):
+ value = _clean(credentials.get(key))
+ if value:
+ return value
+ return ""
+
+
+def _unwrap_envelope(payload: object) -> object:
+ """Peel sub2api's `{code, message, data}` envelope, returning the inner `data` field
+ when present. Also handles unwrapped responses from older/alt versions."""
+ if isinstance(payload, dict) and "data" in payload and "code" in payload:
+ return payload.get("data")
+ return payload
+
+
+def _extract_paged_items(payload: object) -> tuple[list, int]:
+ """Return (items, total) from a paginated sub2api response.
+
+ Handles both the wrapped shape `{code,data:{items,total,...}}` and a few looser
+ variants (`{data:[...]}`, `[...]`, `{items:[...],total:N}`)."""
+ inner = _unwrap_envelope(payload)
+ if isinstance(inner, list):
+ return inner, len(inner)
+ if isinstance(inner, dict):
+ for key in ("items", "data", "list"):
+ value = inner.get(key)
+ if isinstance(value, list):
+ return value, int(inner.get("total") or len(value))
+ return [], 0
+
+
+def list_remote_accounts(server: dict) -> list[dict]:
+ """Return a flat list of OpenAI OAuth accounts from a sub2api server."""
+ base_url = _clean(server.get("base_url"))
+ if not base_url:
+ return []
+
+ headers = _auth_headers(server)
+ group_id = _clean(server.get("group_id"))
+
+ session = Session(verify=True)
+ items: list[dict] = []
+ try:
+ page = 1
+ while True:
+ params: dict[str, object] = {
+ "platform": "openai",
+ "type": "oauth",
+ "page": page,
+ "page_size": 200,
+ }
+ if group_id:
+ params["group"] = group_id
+ response = session.get(
+ f"{base_url.rstrip('/')}/api/v1/admin/accounts",
+ headers=headers,
+ params=params,
+ timeout=30,
+ )
+ if not response.ok:
+ raise RuntimeError(f"sub2api list failed: HTTP {response.status_code} {response.text[:200]}")
+ payload = response.json()
+
+ data, total = _extract_paged_items(payload)
+ if not data:
+ break
+
+ for account in data:
+ if not isinstance(account, dict):
+ continue
+ credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
+ access_token = _extract_access_token(credentials)
+ if not access_token:
+ continue
+ account_id = account.get("id")
+ items.append({
+ "id": str(account_id) if account_id is not None else _clean(credentials.get("chatgpt_account_id")),
+ "name": _clean(account.get("name")),
+ "email": _clean(credentials.get("email")) or _clean(account.get("name")),
+ "plan_type": _clean(credentials.get("plan_type")),
+ "status": _clean(account.get("status")),
+ "expires_at": _clean(credentials.get("expires_at")),
+ "has_refresh_token": bool(_clean(credentials.get("refresh_token"))),
+ })
+
+ if page * 200 >= total or len(data) < 200:
+ break
+ page += 1
+ finally:
+ session.close()
+
+ return items
+
+
+def list_remote_groups(server: dict) -> list[dict]:
+ """Return OpenAI account groups from a sub2api server."""
+ base_url = _clean(server.get("base_url"))
+ if not base_url:
+ return []
+
+ headers = _auth_headers(server)
+
+ session = Session(verify=True)
+ items: list[dict] = []
+ try:
+ page = 1
+ while True:
+ response = session.get(
+ f"{base_url.rstrip('/')}/api/v1/admin/groups",
+ headers=headers,
+ params={
+ "page": page,
+ "page_size": 200,
+ },
+ timeout=30,
+ )
+ if not response.ok:
+ raise RuntimeError(f"sub2api groups failed: HTTP {response.status_code} {response.text[:200]}")
+ payload = response.json()
+
+ data, total = _extract_paged_items(payload)
+ if not data:
+ break
+
+ for group in data:
+ if not isinstance(group, dict):
+ continue
+ group_id = group.get("id")
+ if group_id is None:
+ continue
+ items.append({
+ "id": str(group_id),
+ "name": _clean(group.get("name")),
+ "description": _clean(group.get("description")),
+ "platform": _clean(group.get("platform")),
+ "status": _clean(group.get("status")),
+ "account_count": int(group.get("account_count") or 0),
+ "active_account_count": int(group.get("active_account_count") or 0),
+ })
+
+ if page * 200 >= total or len(data) < 200:
+ break
+ page += 1
+ finally:
+ session.close()
+
+ return items
+
+
+def _fetch_access_token_for_account(server: dict, account_id: str) -> tuple[str, dict]:
+ """Return (access_token, account_meta) for a single sub2api account id."""
+ base_url = _clean(server.get("base_url"))
+ headers = _auth_headers(server)
+
+ session = Session(verify=True)
+ try:
+ response = session.get(
+ f"{base_url.rstrip('/')}/api/v1/admin/accounts/{account_id}",
+ headers=headers,
+ timeout=30,
+ )
+ if not response.ok:
+ raise RuntimeError(f"HTTP {response.status_code}")
+ payload = response.json()
+ finally:
+ session.close()
+
+ account = _unwrap_envelope(payload)
+ if not isinstance(account, dict):
+ account = payload if isinstance(payload, dict) else {}
+ credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
+ access_token = _extract_access_token(credentials)
+ if not access_token:
+ raise RuntimeError("missing access_token")
+ return access_token, {
+ "email": _clean(credentials.get("email")),
+ "plan_type": _clean(credentials.get("plan_type")),
+ }
+
+
+class Sub2APIImportService:
+ def __init__(self, sub2api_config: Sub2APIConfig):
+ self._config = sub2api_config
+
+ def start_import(self, server: dict, account_ids: list[str]) -> dict:
+ ids = [_clean(item) for item in account_ids if _clean(item)]
+ if not ids:
+ raise ValueError("account ids is required")
+
+ server_id = _clean(server.get("id"))
+ job = {
+ "job_id": uuid.uuid4().hex,
+ "status": "pending",
+ "created_at": _now_iso(),
+ "updated_at": _now_iso(),
+ "total": len(ids),
+ "completed": 0,
+ "added": 0,
+ "skipped": 0,
+ "refreshed": 0,
+ "failed": 0,
+ "errors": [],
+ }
+ saved = self._config.set_import_job(server_id, job)
+ if saved is None:
+ raise ValueError("server not found")
+
+ thread = threading.Thread(
+ target=self._run_import,
+ args=(server_id, server, ids),
+ name=f"sub2api-import-{server_id}",
+ daemon=True,
+ )
+ thread.start()
+ return dict(saved.get("import_job") or job)
+
+ def _update_job(self, server_id: str, **updates) -> None:
+ current = self._config.get_import_job(server_id)
+ if current is None:
+ return
+ next_job = {**current, **updates, "updated_at": _now_iso()}
+ self._config.set_import_job(server_id, next_job)
+
+ def _append_error(self, server_id: str, account_id: str, message: str) -> None:
+ current = self._config.get_import_job(server_id)
+ if current is None:
+ return
+ errors = list(current.get("errors") or [])
+ errors.append({"name": account_id, "error": message})
+ self._update_job(server_id, errors=errors, failed=len(errors))
+
+ def _run_import(self, server_id: str, server: dict, account_ids: list[str]) -> None:
+ self._update_job(server_id, status="running")
+
+ tokens: list[str] = []
+ max_workers = min(8, max(1, len(account_ids)))
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ future_map = {
+ executor.submit(_fetch_access_token_for_account, server, account_id): account_id
+ for account_id in account_ids
+ }
+ for future in as_completed(future_map):
+ account_id = future_map[future]
+ try:
+ token, _meta = future.result()
+ tokens.append(token)
+ except Exception as exc:
+ self._append_error(server_id, account_id, str(exc) or "unknown error")
+
+ current = self._config.get_import_job(server_id) or {}
+ failed = len(current.get("errors") or [])
+ self._update_job(
+ server_id,
+ completed=int(current.get("completed") or 0) + 1,
+ failed=failed,
+ )
+
+ if not tokens:
+ current = self._config.get_import_job(server_id) or {}
+ self._update_job(
+ server_id,
+ status="failed",
+ completed=int(current.get("total") or 0),
+ failed=len(current.get("errors") or []),
+ )
+ return
+
+ add_result = account_service.add_accounts(tokens, source_type="codex")
+ refresh_result = account_service.refresh_accounts(tokens)
+ current = self._config.get_import_job(server_id) or {}
+ self._update_job(
+ server_id,
+ status="completed",
+ completed=len(account_ids),
+ added=int(add_result.get("added") or 0),
+ skipped=int(add_result.get("skipped") or 0),
+ refreshed=int(refresh_result.get("refreshed") or 0),
+ failed=len(current.get("errors") or []),
+ )
+
+
+sub2api_config = Sub2APIConfig(SUB2API_CONFIG_FILE)
+sub2api_import_service = Sub2APIImportService(sub2api_config)
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc
--- /dev/null
+++ b/test/__init__.py
@@ -0,0 +1 @@
+
diff --git a/test/test_account_export.py b/test/test_account_export.py
new file mode 100644
index 0000000000000000000000000000000000000000..095c5dbf6d1d846cb801f73e7e19a71f88d5e532
--- /dev/null
+++ b/test/test_account_export.py
@@ -0,0 +1,118 @@
+import base64
+import json
+import unittest
+from typing import Any
+
+from services.account_service import AccountService
+
+
+class MemoryStorage:
+ def __init__(self, accounts: list[dict[str, Any]] | None = None) -> None:
+ self.accounts = list(accounts or [])
+
+ def load_accounts(self) -> list[dict[str, Any]]:
+ return list(self.accounts)
+
+ def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
+ self.accounts = list(accounts)
+
+ def load_auth_keys(self) -> list[dict[str, Any]]:
+ return []
+
+ def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
+ pass
+
+ def health_check(self) -> dict[str, Any]:
+ return {"ok": True}
+
+ def get_backend_info(self) -> dict[str, Any]:
+ return {"type": "memory"}
+
+
+def make_jwt(payload: dict[str, Any]) -> str:
+ def encode(value: dict[str, Any]) -> str:
+ raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+
+ return f'{encode({"alg": "none", "typ": "JWT"})}.{encode(payload)}.sig'
+
+
+class AccountExportTests(unittest.TestCase):
+ def test_build_export_items_uses_codex_shape_and_jwt_claims(self) -> None:
+ access_token = make_jwt(
+ {
+ "exp": 0,
+ "iat": 3600,
+ "https://api.openai.com/auth": {"chatgpt_account_id": "acct_123"},
+ "https://api.openai.com/profile": {"email": "test@example.com"},
+ }
+ )
+ id_token = make_jwt({"email": "fallback@example.com"})
+ service = AccountService(
+ MemoryStorage(
+ [
+ {
+ "access_token": access_token,
+ "id_token": id_token,
+ "refresh_token": "rt_test",
+ }
+ ]
+ )
+ )
+
+ [item] = service.build_export_items([access_token])
+
+ self.assertEqual(item["type"], "codex")
+ self.assertEqual(item["email"], "test@example.com")
+ self.assertEqual(item["expired"], "1970-01-01T08:00:00+08:00")
+ self.assertEqual(item["account_id"], "acct_123")
+ self.assertEqual(item["access_token"], access_token)
+ self.assertEqual(item["last_refresh"], "1970-01-01T09:00:00+08:00")
+ self.assertEqual(item["id_token"], id_token)
+ self.assertEqual(item["refresh_token"], "rt_test")
+
+ def test_build_export_items_skips_accounts_missing_complete_tokens(self) -> None:
+ complete_access_token = make_jwt({"exp": 0})
+ complete_id_token = make_jwt({"email": "complete@example.com"})
+ service = AccountService(
+ MemoryStorage(
+ [
+ {"access_token": "only_access"},
+ {"access_token": "missing_id", "refresh_token": "rt_missing_id"},
+ {"access_token": complete_access_token, "id_token": complete_id_token, "refresh_token": "rt_complete"},
+ ]
+ )
+ )
+
+ items = service.build_export_items()
+
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0]["access_token"], complete_access_token)
+ self.assertEqual(items[0]["id_token"], complete_id_token)
+ self.assertEqual(items[0]["refresh_token"], "rt_complete")
+
+ def test_add_account_items_preserves_export_fields_without_overwriting_plan_type(self) -> None:
+ service = AccountService(MemoryStorage())
+
+ result = service.add_account_items(
+ [
+ {
+ "type": "codex",
+ "access_token": "access_token_test",
+ "refresh_token": "rt_test",
+ "account_id": "acct_123",
+ }
+ ]
+ )
+
+ account = service.get_account("access_token_test")
+ self.assertEqual(result["added"], 1)
+ self.assertIsNotNone(account)
+ self.assertEqual(account["type"], "free")
+ self.assertEqual(account["export_type"], "codex")
+ self.assertEqual(account["refresh_token"], "rt_test")
+ self.assertEqual(account["account_id"], "acct_123")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_account_image_capabilities.py b/test/test_account_image_capabilities.py
new file mode 100644
index 0000000000000000000000000000000000000000..5aaf01c0590514f79dd71e60fb932a9b155815d3
--- /dev/null
+++ b/test/test_account_image_capabilities.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+os.environ.setdefault("CHATGPT2API_AUTH_KEY", "test-auth")
+
+from services.account_service import AccountService
+from services.auth_service import AuthService
+from services.storage.json_storage import JSONStorageBackend
+from utils.helper import anonymize_token, split_image_model
+
+
+class AccountCapabilityTests(unittest.TestCase):
+ def test_unknown_quota_accounts_are_available_only_when_not_throttled(self) -> None:
+ self.assertFalse(
+ AccountService._is_image_account_available(
+ {"status": "限流", "image_quota_unknown": True, "quota": 0}
+ )
+ )
+ self.assertTrue(
+ AccountService._is_image_account_available(
+ {"status": "正常", "image_quota_unknown": True, "quota": 0}
+ )
+ )
+
+ def test_prolite_variants_are_normalized(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
+ self.assertEqual(service._normalize_account_type("prolite"), "ProLite")
+ self.assertEqual(service._normalize_account_type("pro_lite"), "ProLite")
+
+ def test_search_account_type_ignores_unrelated_scalar_values(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
+ self.assertIsNone(
+ service._search_account_type(
+ {
+ "amr": ["pwd", "otp", "mfa"],
+ "chatgpt_compute_residency": "no_constraint",
+ "chatgpt_data_residency": "no_constraint",
+ "user_id": "user-I52GFfLGFM0dokFk2dBiKEBn",
+ }
+ )
+ )
+
+ def test_mark_image_result_does_not_consume_unknown_quota(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
+ service.add_accounts(["token-1"])
+ service.update_account(
+ "token-1",
+ {
+ "status": "正常",
+ "quota": 0,
+ "image_quota_unknown": True,
+ },
+ )
+
+ updated = service.mark_image_result("token-1", success=True)
+
+ self.assertIsNotNone(updated)
+ self.assertEqual(updated["quota"], 0)
+ self.assertEqual(updated["status"], "正常")
+ self.assertTrue(updated["image_quota_unknown"])
+
+ def test_split_image_model_supports_plan_type_prefix(self) -> None:
+ self.assertEqual(split_image_model("gpt-image-2"), (None, "gpt-image-2"))
+ self.assertEqual(split_image_model("plus-codex-gpt-image-2"), ("plus", "codex-gpt-image-2"))
+ self.assertEqual(split_image_model("team-codex-gpt-image-2"), ("team", "codex-gpt-image-2"))
+ self.assertEqual(split_image_model("pro-codex-gpt-image-2"), ("pro", "codex-gpt-image-2"))
+ self.assertEqual(split_image_model("plus-gpt-image-2"), (None, None))
+ self.assertEqual(split_image_model("unknown-image-model"), (None, None))
+
+ def test_get_available_access_token_filters_by_plan_type(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AccountService(JSONStorageBackend(Path(tmp_dir) / "accounts.json"))
+ service.add_account_items(
+ [
+ {"access_token": "token-plus", "type": "Plus", "status": "正常", "quota": 3},
+ {"access_token": "token-pro", "type": "Pro", "status": "正常", "quota": 3},
+ ]
+ )
+
+ service.fetch_remote_info = lambda access_token, event="fetch_remote_info": service.get_account(access_token)
+
+ plus_token = service.get_available_access_token(plan_type="plus")
+ pro_token = service.get_available_access_token(plan_type="pro")
+ service.release_image_slot(plus_token)
+ service.release_image_slot(pro_token)
+
+ self.assertEqual(plus_token, "token-plus")
+ self.assertEqual(pro_token, "token-pro")
+
+
+class TokenLogTests(unittest.TestCase):
+ def test_anonymize_token_hides_raw_value(self) -> None:
+ token = "super-secret-token"
+ token_ref = anonymize_token(token)
+
+ self.assertTrue(token_ref.startswith("token:"))
+ self.assertNotIn(token, token_ref)
+
+
+class AuthServiceTests(unittest.TestCase):
+ def test_create_authenticate_disable_and_delete_user_key(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
+
+ item, raw_key = service.create_key(role="user", name="Alice")
+
+ self.assertEqual(item["role"], "user")
+ self.assertEqual(item["name"], "Alice")
+ self.assertTrue(item["enabled"])
+ self.assertTrue(raw_key.startswith("sk-"))
+
+ authed = service.authenticate(raw_key)
+ self.assertIsNotNone(authed)
+ self.assertEqual(authed["id"], item["id"])
+ self.assertEqual(authed["role"], "user")
+ self.assertIsNotNone(authed["last_used_at"])
+
+ updated = service.update_key(item["id"], {"enabled": False}, role="user")
+ self.assertIsNotNone(updated)
+ self.assertFalse(updated["enabled"])
+ self.assertIsNone(service.authenticate(raw_key))
+
+ self.assertTrue(service.delete_key(item["id"], role="user"))
+ self.assertFalse(service.delete_key(item["id"], role="user"))
+ self.assertEqual(service.list_keys(role="user"), [])
+
+ def test_authenticate_ignores_last_used_save_failure(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
+ item, raw_key = service.create_key(role="user", name="Alice")
+
+ def fail_save() -> None:
+ raise OSError("disk unavailable")
+
+ service._save = fail_save
+
+ authed = service.authenticate(raw_key)
+
+ self.assertIsNotNone(authed)
+ self.assertEqual(authed["id"], item["id"])
+ self.assertIsNotNone(authed["last_used_at"])
+
+ def test_update_user_key_replaces_raw_key(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
+ item, raw_key = service.create_key(role="user", name="Alice")
+
+ updated = service.update_key(item["id"], {"key": "sk-user-custom-key"}, role="user")
+
+ self.assertIsNotNone(updated)
+ self.assertIsNone(service.authenticate(raw_key))
+
+ authed = service.authenticate("sk-user-custom-key")
+ self.assertIsNotNone(authed)
+ self.assertEqual(authed["id"], item["id"])
+
+ def test_user_key_name_must_be_unique(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
+ first, _ = service.create_key(role="user", name="Alice")
+ second, _ = service.create_key(role="user", name="Bob")
+
+ with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"):
+ service.create_key(role="user", name="Alice")
+
+ with self.assertRaisesRegex(ValueError, "这个名称已经在使用中了"):
+ service.update_key(second["id"], {"name": "Alice"}, role="user")
+
+ updated = service.update_key(first["id"], {"name": "Alice"}, role="user")
+ self.assertIsNotNone(updated)
+ self.assertEqual(updated["name"], "Alice")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_chat_completion_cache.py b/test/test_chat_completion_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..785b2b88bfa1a40f8d9b58eeaa6abe78809c5b20
--- /dev/null
+++ b/test/test_chat_completion_cache.py
@@ -0,0 +1,336 @@
+from __future__ import annotations
+
+import unittest
+from unittest import mock
+import json
+import base64
+
+from services.config import config
+from services.protocol import openai_v1_chat_complete, openai_v1_response
+from services.protocol.chat_completion_cache import chat_completion_cache
+from services.protocol.conversation import iter_conversation_payloads, sanitize_output_text
+from utils.helper import extract_image_from_message_content
+
+
+PNG_1X1 = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP8z8BQDwAFgwJ/luzl4wAAAABJRU5ErkJggg=="
+)
+PNG_1X1_DATA_URL = "data:image/png;base64," + base64.b64encode(PNG_1X1).decode("ascii")
+
+
+class ChatCompletionCacheTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.old_cache_settings = config.data.get("chat_completion_cache")
+ config.data["chat_completion_cache"] = {
+ "enabled": True,
+ "ttl_seconds": 60,
+ "max_entries": 32,
+ "dedupe_inflight": True,
+ "stream_cache": True,
+ "normalize_messages": True,
+ "drop_adjacent_duplicates": True,
+ "drop_assistant_history": False,
+ }
+ chat_completion_cache.clear()
+
+ def tearDown(self) -> None:
+ if self.old_cache_settings is None:
+ config.data.pop("chat_completion_cache", None)
+ else:
+ config.data["chat_completion_cache"] = self.old_cache_settings
+ chat_completion_cache.clear()
+
+ def test_repeated_non_stream_text_completion_uses_cache(self) -> None:
+ calls = 0
+
+ def fake_collect_text(_backend, _request):
+ nonlocal calls
+ calls += 1
+ return f"cached answer {calls}"
+
+ body = {
+ "model": "auto",
+ "messages": [{"role": "user", "content": "cache this exact prompt"}],
+ }
+
+ with (
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
+ mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
+ ):
+ first = openai_v1_chat_complete.handle(body)
+ second = openai_v1_chat_complete.handle(body)
+
+ self.assertEqual(calls, 1)
+ self.assertEqual(
+ first["choices"][0]["message"]["content"],
+ second["choices"][0]["message"]["content"],
+ )
+
+ def test_repeated_stream_text_completion_replays_cached_chunks(self) -> None:
+ calls = 0
+
+ def fake_stream_text_deltas(_backend, _request):
+ nonlocal calls
+ calls += 1
+ yield "streamed"
+ yield " answer"
+
+ body = {
+ "model": "auto",
+ "stream": True,
+ "messages": [{"role": "user", "content": "stream cache this exact prompt"}],
+ }
+
+ with (
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
+ mock.patch(
+ "services.protocol.openai_v1_chat_complete.stream_text_deltas",
+ side_effect=fake_stream_text_deltas,
+ ),
+ ):
+ first = list(openai_v1_chat_complete.handle(body))
+ second = list(openai_v1_chat_complete.handle(body))
+
+ self.assertEqual(calls, 1)
+ self.assertEqual(first, second)
+ content = "".join(str(chunk["choices"][0]["delta"].get("content") or "") for chunk in second)
+ self.assertEqual(content, "streamed answer")
+
+ def test_adjacent_duplicate_messages_are_removed_before_upstream_call(self) -> None:
+ captured_messages = []
+
+ def fake_collect_text(_backend, request):
+ captured_messages.extend(request.messages or [])
+ return "ok"
+
+ body = {
+ "model": "auto",
+ "messages": [
+ {"role": "user", "content": "repeat me"},
+ {"role": "user", "content": "repeat me"},
+ {"role": "assistant", "content": "old answer"},
+ {"role": "user", "content": "next prompt"},
+ ],
+ }
+
+ with (
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
+ mock.patch("services.protocol.openai_v1_chat_complete.collect_text", side_effect=fake_collect_text),
+ ):
+ openai_v1_chat_complete.handle(body)
+
+ self.assertEqual(
+ captured_messages,
+ [
+ {"role": "user", "content": "repeat me"},
+ {"role": "assistant", "content": "old answer"},
+ {"role": "user", "content": "next prompt"},
+ ],
+ )
+
+ def test_chat_completion_usage_includes_cached_tokens(self) -> None:
+ with (
+ mock.patch("services.protocol.openai_v1_chat_complete.text_backend", return_value=object()),
+ mock.patch("services.protocol.openai_v1_chat_complete.collect_text", return_value="ok"),
+ ):
+ response = openai_v1_chat_complete.handle({
+ "model": "auto",
+ "messages": [{"role": "user", "content": "usage shape"}],
+ })
+
+ details = response["usage"]["prompt_tokens_details"]
+ self.assertEqual(details["cached_tokens"], 0)
+ output_details = response["usage"]["completion_tokens_details"]
+ self.assertEqual(output_details["reasoning_tokens"], 0)
+
+ def test_responses_completed_usage_includes_cached_tokens(self) -> None:
+ with (
+ mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
+ mock.patch("services.protocol.openai_v1_response.stream_text_deltas", return_value=iter(["ok"])),
+ ):
+ response = openai_v1_response.handle({
+ "model": "auto",
+ "input": "usage shape",
+ })
+
+ details = response["usage"]["input_tokens_details"]
+ self.assertEqual(details["cached_tokens"], 0)
+ output_details = response["usage"]["output_tokens_details"]
+ self.assertEqual(output_details["reasoning_tokens"], 0)
+
+ def test_repeated_responses_text_request_uses_cache(self) -> None:
+ calls = 0
+
+ def fake_stream_text_deltas(_backend, _request):
+ nonlocal calls
+ calls += 1
+ yield f"response cache {calls}"
+
+ body = {
+ "model": "auto",
+ "input": "cache this responses prompt",
+ "stream": True,
+ }
+
+ with (
+ mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
+ mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas),
+ ):
+ first = list(openai_v1_response.handle(body))
+ second = list(openai_v1_response.handle(body))
+
+ self.assertEqual(calls, 1)
+ self.assertEqual(first, second)
+
+ def test_output_sanitizer_removes_chatgpt_annotation_markup(self) -> None:
+ text = (
+ "Repo: \ue200url\ue202basketikun/chatgpt2api"
+ "\ue202https://github.com/basketikun/chatgpt2api\ue201 "
+ "details \ue200cite\ue202turn0search0\ue201."
+ )
+
+ self.assertEqual(
+ sanitize_output_text(text),
+ "Repo: basketikun/chatgpt2api (https://github.com/basketikun/chatgpt2api) details.",
+ )
+
+ def test_output_sanitizer_preserves_annotated_entity_text(self) -> None:
+ text = (
+ "The character is from \ue200entity\ue202Invincible\ue201, "
+ "which is based on the comic series \ue200entity\ue202Invincible\ue201."
+ )
+
+ self.assertEqual(
+ sanitize_output_text(text),
+ "The character is from Invincible, which is based on the comic series Invincible.",
+ )
+
+ def test_output_sanitizer_preserves_readable_cite_label(self) -> None:
+ text = "The character is \ue200cite\ue202Invincible\ue202turn0search0\ue201."
+
+ self.assertEqual(sanitize_output_text(text), "The character is Invincible.")
+
+ def test_stream_sanitizer_does_not_emit_partial_annotation_or_repeat_prefix(self) -> None:
+ events = [
+ {"p": "/message/content/parts/0", "o": "append", "v": "Repo: \ue200url\ue202chat"},
+ {"p": "/message/content/parts/0", "o": "append", "v": "gpt2api\ue202turn0search0\ue201 done \ue200cite\ue202turn0\ue201."},
+ "[DONE]",
+ ]
+ payloads = [json.dumps(event, ensure_ascii=False) if isinstance(event, dict) else event for event in events]
+ deltas = [
+ str(event.get("delta") or "")
+ for event in iter_conversation_payloads(iter(payloads))
+ if event.get("type") == "conversation.delta"
+ ]
+
+ self.assertEqual("".join(deltas), "Repo: chatgpt2api done.")
+ self.assertFalse(any("\ue200" in delta or "\ue202" in delta or "\ue201" in delta for delta in deltas))
+
+ def test_responses_tools_add_honest_no_tool_guard(self) -> None:
+ model, messages = openai_v1_response.text_response_parts({
+ "model": "auto",
+ "input": "run echo hi",
+ "tools": [{"type": "function", "name": "shell"}],
+ })
+
+ self.assertEqual(model, "auto")
+ self.assertEqual(messages[0]["role"], "system")
+ self.assertIn("cannot execute local tools", str(messages[0]["content"]))
+
+ def test_chat_completions_accepts_remote_image_url(self) -> None:
+ class FakeImageResponse:
+ status_code = 200
+ headers = {"content-type": "image/png", "content-length": str(len(PNG_1X1))}
+ content = PNG_1X1
+
+ with mock.patch("utils.helper.requests.get", return_value=FakeImageResponse()) as request_get:
+ model, messages = openai_v1_chat_complete.text_chat_parts({
+ "model": "auto",
+ "messages": [{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "Describe this"},
+ {"type": "image_url", "image_url": {"url": "https://example.test/image.png"}},
+ ],
+ }],
+ })
+
+ request_get.assert_called_once()
+ self.assertEqual(model, "auto")
+ content = messages[0]["content"]
+ self.assertEqual(content[0], {"type": "text", "text": "Describe this"})
+ self.assertEqual(content[1]["type"], "image")
+ self.assertEqual(content[1]["data"], PNG_1X1)
+ self.assertEqual(content[1]["mime"], "image/png")
+
+ def test_responses_text_request_preserves_input_image(self) -> None:
+ captured = {}
+
+ def fake_stream_text_deltas(_backend, request):
+ captured["messages"] = request.messages
+ yield "red"
+
+ body = {
+ "model": "auto",
+ "input": [
+ {"type": "input_text", "text": "What color is this image?"},
+ {"type": "input_image", "image_url": PNG_1X1_DATA_URL},
+ ],
+ }
+
+ with (
+ mock.patch("services.protocol.openai_v1_response.text_backend", return_value=object()),
+ mock.patch("services.protocol.openai_v1_response.stream_text_deltas", side_effect=fake_stream_text_deltas),
+ ):
+ response = openai_v1_response.handle(body)
+
+ self.assertEqual(response["output"][0]["content"][0]["text"], "red")
+ content = captured["messages"][0]["content"]
+ self.assertEqual(content[0], {"type": "text", "text": "What color is this image?"})
+ self.assertEqual(content[1]["type"], "image")
+ self.assertEqual(content[1]["mime"], "image/png")
+ self.assertEqual(content[1]["data"], PNG_1X1)
+ self.assertGreater(response["usage"]["input_tokens_details"]["image_tokens"], 0)
+
+ def test_responses_text_request_accepts_remote_input_image_url(self) -> None:
+ class FakeImageResponse:
+ status_code = 200
+ headers = {"content-type": "image/png", "content-length": str(len(PNG_1X1))}
+ content = PNG_1X1
+
+ with mock.patch("utils.helper.requests.get", return_value=FakeImageResponse()) as request_get:
+ _model, messages = openai_v1_response.text_response_parts({
+ "model": "auto",
+ "input": [{
+ "type": "message",
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "Describe this"},
+ {"type": "input_image", "image_url": {"url": "https://example.test/image.png"}},
+ ],
+ }],
+ })
+
+ request_get.assert_called_once()
+ content = messages[0]["content"]
+ self.assertEqual(content[0], {"type": "text", "text": "Describe this"})
+ self.assertEqual(content[1]["type"], "image")
+ self.assertEqual(content[1]["data"], PNG_1X1)
+ self.assertEqual(content[1]["mime"], "image/png")
+
+ def test_image_extractor_supports_extra_image_object_shapes(self) -> None:
+ encoded = base64.b64encode(PNG_1X1).decode("ascii")
+
+ images = extract_image_from_message_content([
+ {"type": "image", "data": PNG_1X1, "mime": "image/png"},
+ {"type": "input_image", "base64": encoded, "mime_type": "image/png"},
+ {"type": "input_image", "source": {"type": "base64", "data": encoded, "media_type": "image/png"}},
+ ])
+
+ self.assertEqual(len(images), 3)
+ self.assertEqual([image[1] for image in images], ["image/png", "image/png", "image/png"])
+ self.assertTrue(all(image[0] == PNG_1X1 for image in images))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_codex_4k.py b/test/test_codex_4k.py
new file mode 100644
index 0000000000000000000000000000000000000000..db1ffcea324a2c6b8d1520a40de01c6298c5b96c
--- /dev/null
+++ b/test/test_codex_4k.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+# Test script: directly call https://chatgpt.com/backend-api/codex/responses to generate one 2K image.
+# Only edit ACCESS_TOKEN, then run: python codex_responses_image_test.py
+# Fixed request parameters:
+# prompt: A highly detailed square 2K image of a quiet futuristic library at sunrise
+# responses model: gpt-5.5
+# image model: gpt-image-2
+# size: 2048x2048
+# quality: auto
+# output_format: png
+# output file: codex_4k.png
+
+import base64
+import json
+import time
+import urllib.request
+
+ACCESS_TOKEN = ""
+
+
+def parse_events(raw):
+ ctype, text = raw.headers.get("content-type", ""), raw.read().decode("utf-8", "replace")
+ if "application/json" in ctype:
+ return [json.loads(text)]
+ events, lines = [], []
+ for line in text.splitlines() + [""]:
+ if not line:
+ if lines:
+ data = "\n".join(lines).strip()
+ if data and data != "[DONE]":
+ events.append(json.loads(data))
+ lines = []
+ elif line.startswith("data:"):
+ lines.append(line[5:].lstrip())
+ return events
+
+
+def find_images(value):
+ if isinstance(value, dict):
+ if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
+ result = value["result"].strip()
+ return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
+ return [image for item in value.values() for image in find_images(item)]
+ if isinstance(value, list):
+ return [image for item in value for image in find_images(item)]
+ return []
+
+
+def main():
+ start_time = time.time()
+ body = {
+ "model": "gpt-5.5",
+ "instructions": "Use the image_generation tool to create exactly one image for the user's request. Return the generated image result.",
+ "store": False,
+ "input": [{"role": "user", "content": [{"type": "input_text", "text": "A highly detailed square 2K image of a quiet futuristic library at sunrise"}]}],
+ "tools": [{
+ "type": "image_generation",
+ "model": "gpt-image-2",
+ "action": "generate",
+ "size": "3840x2160",
+ "quality": "auto",
+ "output_format": "png"
+ }],
+ "tool_choice": {"type": "image_generation"},
+ "stream": True
+ }
+ headers = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"}
+
+ req = urllib.request.Request("https://chatgpt.com/backend-api/codex/responses", json.dumps(body).encode(), headers, method="POST")
+ try:
+ images = find_images(parse_events(urllib.request.urlopen(req, timeout=1200)))
+ except urllib.error.HTTPError as error:
+ raise SystemExit(f"HTTP {error.code}: {error.read().decode('utf-8', 'replace')[:1000]}")
+
+ if not images:
+ raise SystemExit("No image result found in response")
+
+ with open("codex_4k.png", "wb") as file:
+ file.write(base64.b64decode(images[0]))
+ print("saved codex_4k.png")
+ end_time = time.time()
+ print(f"total time: {end_time - start_time:.2f} seconds")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_config.py b/test/test_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9e4f3d596a4ba06b0cd389925a0a556d9365da9
--- /dev/null
+++ b/test/test_config.py
@@ -0,0 +1,63 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+ROOT_CONFIG_FILE = ROOT_DIR / "config.json"
+
+
+class ConfigLoadingTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls._created_root_config = False
+ if not ROOT_CONFIG_FILE.exists():
+ ROOT_CONFIG_FILE.write_text(json.dumps({"auth-key": "test-auth"}), encoding="utf-8")
+ cls._created_root_config = True
+
+ from services import config as config_module
+
+ cls.config_module = config_module
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ if cls._created_root_config and ROOT_CONFIG_FILE.exists():
+ ROOT_CONFIG_FILE.unlink()
+
+ def test_load_settings_ignores_directory_config_path(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ base_dir = Path(tmp_dir)
+ data_dir = base_dir / "data"
+ config_dir = base_dir / "config.json"
+ os_auth_key = "env-auth"
+
+ config_dir.mkdir()
+
+ module = self.config_module
+ old_base_dir = module.BASE_DIR
+ old_data_dir = module.DATA_DIR
+ old_config_file = module.CONFIG_FILE
+ old_env_auth_key = module.os.environ.get("CHATGPT2API_AUTH_KEY")
+ try:
+ module.BASE_DIR = base_dir
+ module.DATA_DIR = data_dir
+ module.CONFIG_FILE = config_dir
+ module.os.environ["CHATGPT2API_AUTH_KEY"] = os_auth_key
+
+ settings = module._load_settings()
+
+ self.assertEqual(settings.auth_key, os_auth_key)
+ self.assertEqual(settings.refresh_account_interval_minute, 5)
+ finally:
+ module.BASE_DIR = old_base_dir
+ module.DATA_DIR = old_data_dir
+ module.CONFIG_FILE = old_config_file
+ if old_env_auth_key is None:
+ module.os.environ.pop("CHATGPT2API_AUTH_KEY", None)
+ else:
+ module.os.environ["CHATGPT2API_AUTH_KEY"] = old_env_auth_key
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_generations.py b/test/test_generations.py
new file mode 100644
index 0000000000000000000000000000000000000000..4345dab6a2eb2de2047fbfaa5d9120087f6e3a41
--- /dev/null
+++ b/test/test_generations.py
@@ -0,0 +1,12 @@
+from test.utils import post_json, save_image
+
+
+def main() -> None:
+ prompt = "A cute orange cat sitting on a chair"
+ result = post_json("/v1/images/generations", {"prompt": prompt, "model": "gpt-image-2", "n": 1})
+ for index, item in enumerate(result["data"], start=1):
+ print(save_image(item["b64_json"], f"generations_{index}"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_generations_url.py b/test/test_generations_url.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f7a04f26982cd34d8ea2f58fceb206f32cbf81a
--- /dev/null
+++ b/test/test_generations_url.py
@@ -0,0 +1,19 @@
+from test.utils import post_json
+
+
+def main() -> None:
+ result = post_json(
+ "/v1/images/generations",
+ {
+ "prompt": "一只橘猫坐在窗边,午后阳光,写实摄影",
+ "model": "gpt-image-2",
+ "n": 1,
+ "response_format": "url",
+ },
+ )
+ for item in result.get("data", []):
+ print(item.get("url", ""))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_gpt_ppt.py b/test/test_gpt_ppt.py
new file mode 100644
index 0000000000000000000000000000000000000000..26189a5854b5a7bb9cfdf622a0a26868926a242a
--- /dev/null
+++ b/test/test_gpt_ppt.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import json
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from uuid import uuid4
+
+BASE_URL = "http://127.0.0.1:8000"
+PROMPT = "生成一份 2026 年 Q2 电商运营复盘 PPT,8 页以内,商务科技风,包含销售、用户、渠道、广告、618 活动和 Q3 规划。"
+BASE64_IMAGES: list[str] = []
+TIMEOUT_SECS = 600
+POLL_INTERVAL_SECS = 5
+
+
+def request_json(method: str, path: str, payload: dict | None = None) -> dict:
+ api_key = json.loads((Path(__file__).resolve().parents[1] / "config.json").read_text(encoding="utf-8"))["auth-key"]
+ if not api_key.strip():
+ raise ValueError("API_KEY is empty")
+ data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
+ request = urllib.request.Request(
+ BASE_URL.rstrip("/") + path,
+ data=data,
+ method=method,
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return json.loads(response.read().decode())
+ except urllib.error.HTTPError as exc:
+ raise RuntimeError(exc.read().decode("utf-8", "replace")) from exc
+
+
+def main() -> None:
+ task = request_json("POST", "/v1/ppt/generations", {
+ "client_task_id": str(uuid4()),
+ "prompt": PROMPT,
+ "base64_images": BASE64_IMAGES,
+ })
+ task_id = str(task.get("taskId") or task.get("id") or "")
+ if not task_id:
+ raise RuntimeError(f"missing taskId: {task}")
+ print(json.dumps(task, ensure_ascii=False, indent=2))
+ deadline = time.time() + TIMEOUT_SECS
+ while time.time() < deadline:
+ time.sleep(POLL_INTERVAL_SECS)
+ status = request_json("GET", "/v1/editable-file-tasks?ids=" + urllib.parse.quote(task_id))
+ print(json.dumps(status, ensure_ascii=False, indent=2))
+ item = (status.get("items") or [{}])[0]
+ if item.get("status") in {"success", "error"}:
+ return
+ raise TimeoutError(f"task timeout: {task_id}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_gpt_psd.py b/test/test_gpt_psd.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae422d3d02ddb2e65fe7945cd9e20c3385d8a533
--- /dev/null
+++ b/test/test_gpt_psd.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import json
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from uuid import uuid4
+
+BASE_URL = "http://127.0.0.1:8000"
+PROMPT = "按原图位置拆分海报元素并合成可编辑 PSD,同时输出每个图层素材 zip。"
+BASE64_IMAGES: list[str] = []
+TIMEOUT_SECS = 600
+POLL_INTERVAL_SECS = 5
+
+
+def request_json(method: str, path: str, payload: dict | None = None) -> dict:
+ api_key = json.loads((Path(__file__).resolve().parents[1] / "config.json").read_text(encoding="utf-8"))["auth-key"]
+ if not api_key.strip():
+ raise ValueError("API_KEY is empty")
+ data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
+ request = urllib.request.Request(
+ BASE_URL.rstrip("/") + path,
+ data=data,
+ method=method,
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return json.loads(response.read().decode())
+ except urllib.error.HTTPError as exc:
+ raise RuntimeError(exc.read().decode("utf-8", "replace")) from exc
+
+
+def main() -> None:
+ task = request_json("POST", "/v1/psd/generations", {
+ "client_task_id": str(uuid4()),
+ "prompt": PROMPT,
+ "base64_images": BASE64_IMAGES,
+ })
+ task_id = str(task.get("taskId") or task.get("id") or "")
+ if not task_id:
+ raise RuntimeError(f"missing taskId: {task}")
+ print(json.dumps(task, ensure_ascii=False, indent=2))
+ deadline = time.time() + TIMEOUT_SECS
+ while time.time() < deadline:
+ time.sleep(POLL_INTERVAL_SECS)
+ status = request_json("GET", "/v1/editable-file-tasks?ids=" + urllib.parse.quote(task_id))
+ print(json.dumps(status, ensure_ascii=False, indent=2))
+ item = (status.get("items") or [{}])[0]
+ if item.get("status") in {"success", "error"}:
+ return
+ raise TimeoutError(f"task timeout: {task_id}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_gpt_search.py b/test/test_gpt_search.py
new file mode 100644
index 0000000000000000000000000000000000000000..8874944d577c6cdf20ccda3225c49fe61a61b4df
--- /dev/null
+++ b/test/test_gpt_search.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from services.openai_backend_api import OpenAIBackendAPI
+
+
+def main() -> None:
+ ACCESS_TOKEN = ""
+ PROMPT = "帮我去网上搜索关于chatgpt2api的相关项目"
+ if not ACCESS_TOKEN.strip():
+ raise ValueError("ACCESS_TOKEN is empty")
+ print(json.dumps(OpenAIBackendAPI(ACCESS_TOKEN).search(PROMPT), ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_image.py b/test/test_image.py
new file mode 100644
index 0000000000000000000000000000000000000000..25ef8f2b0e96396c499f5f0edf3fa7b7a2b2b8a1
--- /dev/null
+++ b/test/test_image.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from services.protocol import openai_v1_image_generations
+from test.utils import save_image
+
+
+def main() -> None:
+ prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
+ data = openai_v1_image_generations.handle({"prompt": prompt, "model": "gpt-5-3", "n": 1})
+ for index, item in enumerate(data["data"], start=1):
+ print(save_image(item["b64_json"], f"image_{index}"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_image_base_url_api.py b/test/test_image_base_url_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2ad577b83a0bf7dcb30b1880fb1614bd9afaf0e
--- /dev/null
+++ b/test/test_image_base_url_api.py
@@ -0,0 +1,43 @@
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+import api.support as api_support
+
+
+class ImageBaseUrlApiTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.fake_config = SimpleNamespace(base_url="https://public.example.com")
+ patcher = mock.patch.object(api_support, "config", self.fake_config)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_prefers_configured_base_url(self) -> None:
+ request = SimpleNamespace(
+ url=SimpleNamespace(scheme="http", netloc="127.0.0.1:8000"),
+ headers={"host": "127.0.0.1:8000"},
+ )
+
+ self.assertEqual(api_support.resolve_image_base_url(request), "https://public.example.com")
+
+ def test_falls_back_to_request_host(self) -> None:
+ self.fake_config.base_url = ""
+ request = SimpleNamespace(
+ url=SimpleNamespace(scheme="http", netloc="127.0.0.1:8000"),
+ headers={"host": "internal.example:9000"},
+ )
+
+ self.assertEqual(api_support.resolve_image_base_url(request), "http://internal.example:9000")
+
+ def test_falls_back_to_request_netloc_when_host_missing(self) -> None:
+ self.fake_config.base_url = ""
+ request = SimpleNamespace(
+ url=SimpleNamespace(scheme="https", netloc="public.example.com"),
+ headers={},
+ )
+
+ self.assertEqual(api_support.resolve_image_base_url(request), "https://public.example.com")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_image_output_tokens.py b/test/test_image_output_tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..71a8e5dbf97138de6531ca936171f412cc79bf08
--- /dev/null
+++ b/test/test_image_output_tokens.py
@@ -0,0 +1,33 @@
+import json
+import urllib.error
+import urllib.request
+
+from test.utils import BASE_URL, load_auth_key
+
+
+def main() -> None:
+ payload = {
+ "prompt": "一只橘猫坐在窗台上,午后阳光,写实摄影",
+ "model": "gpt-image-2",
+ "n": 1,
+ "response_format": "url",
+ }
+ request = urllib.request.Request(
+ BASE_URL + "/v1/images/generations",
+ data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request) as response:
+ body = response.read().decode()
+ except urllib.error.HTTPError as error:
+ body = error.read().decode()
+ try:
+ print(json.dumps(json.loads(body), ensure_ascii=False, indent=2))
+ except json.JSONDecodeError:
+ print(body)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/test_image_storage_service.py b/test/test_image_storage_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea926de9f9569a8f1d4e6232aa80bb547919c9ef
--- /dev/null
+++ b/test/test_image_storage_service.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+from PIL import Image
+
+from services.image_storage_service import ImageStorageService
+
+
+def png_bytes() -> bytes:
+ path = Path(tempfile.gettempdir()) / "chatgpt2api-test-image.png"
+ Image.new("RGB", (2, 2), color=(255, 0, 0)).save(path, format="PNG")
+ return path.read_bytes()
+
+
+class FakeWebDAVClient:
+ uploaded: dict[str, bytes] = {}
+ deleted: list[str] = []
+
+ def __init__(self, _settings):
+ pass
+
+ def put(self, rel: str, payload: bytes) -> str:
+ self.uploaded[rel] = payload
+ return f"https://dav.example.test/{rel}"
+
+ def get(self, rel: str) -> bytes:
+ return self.uploaded[rel]
+
+ def delete(self, rel: str) -> bool:
+ self.deleted.append(rel)
+ self.uploaded.pop(rel, None)
+ return True
+
+ def test(self) -> dict[str, object]:
+ self.put(".chatgpt2api_webdav_test.txt", b"chatgpt2api webdav test\n")
+ self.delete(".chatgpt2api_webdav_test.txt")
+ return {"ok": True, "status": 200, "error": None}
+
+
+class ImageStorageServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.data_dir = Path(self.tmp.name)
+ self.images_dir = self.data_dir / "images"
+ self.settings = {
+ "enabled": False,
+ "mode": "local",
+ "webdav_url": "",
+ "webdav_username": "",
+ "webdav_password": "",
+ "webdav_root_path": "chatgpt2api/images",
+ "public_base_url": "",
+ }
+ self.config_patcher = mock.patch("services.image_storage_service.config")
+ self.mock_config = self.config_patcher.start()
+ self.addCleanup(self.config_patcher.stop)
+ self.mock_config.images_dir = self.images_dir
+ self.mock_config.base_url = "http://app.test"
+ self.mock_config.cleanup_old_images.return_value = 0
+ self.mock_config.get_image_storage_settings.side_effect = lambda: dict(self.settings)
+ FakeWebDAVClient.uploaded = {}
+ FakeWebDAVClient.deleted = []
+
+ def service(self) -> ImageStorageService:
+ return ImageStorageService(self.data_dir / "image_index.json")
+
+ def test_local_mode_saves_to_local_directory(self):
+ stored = self.service().save(png_bytes(), "http://app.test")
+
+ self.assertEqual(stored.storage, "local")
+ self.assertTrue((self.images_dir / stored.rel).is_file())
+ self.assertEqual(stored.url, f"http://app.test/images/{stored.rel}")
+
+ def test_webdav_mode_uploads_without_local_file(self):
+ self.settings.update({
+ "enabled": True,
+ "mode": "webdav",
+ "webdav_url": "https://dav.example.test",
+ "webdav_password": "secret",
+ })
+ with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient):
+ stored = self.service().save(png_bytes(), "http://app.test")
+ payload = self.service().get_bytes(stored.rel)
+
+ self.assertEqual(stored.storage, "webdav")
+ self.assertFalse((self.images_dir / stored.rel).exists())
+ self.assertIn(stored.rel, FakeWebDAVClient.uploaded)
+ self.assertEqual(payload, FakeWebDAVClient.uploaded[stored.rel])
+
+ def test_list_items_ignores_non_image_files(self):
+ image = png_bytes()
+ image_path = self.images_dir / "2026" / "05" / "07" / "sample.png"
+ image_path.parent.mkdir(parents=True, exist_ok=True)
+ image_path.write_bytes(image)
+ (self.images_dir / ".DS_Store").write_text("not an image", encoding="utf-8")
+ (self.images_dir / "2026" / ".DS_Store").write_text("not an image", encoding="utf-8")
+
+ items = self.service().list_items("http://app.test")
+
+ self.assertEqual([item["rel"] for item in items], ["2026/05/07/sample.png"])
+ self.assertEqual(items[0]["storage"], "local")
+
+ def test_both_mode_saves_to_local_and_webdav(self):
+ self.settings.update({
+ "enabled": True,
+ "mode": "both",
+ "webdav_url": "https://dav.example.test",
+ "webdav_password": "secret",
+ "public_base_url": "https://cdn.example.test/images",
+ })
+ with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient):
+ stored = self.service().save(png_bytes(), "http://app.test")
+
+ self.assertEqual(stored.storage, "both")
+ self.assertTrue((self.images_dir / stored.rel).is_file())
+ self.assertIn(stored.rel, FakeWebDAVClient.uploaded)
+ self.assertEqual(stored.url, f"https://cdn.example.test/images/{stored.rel}")
+
+ def test_test_webdav_writes_and_deletes_probe_file(self):
+ self.settings.update({
+ "enabled": True,
+ "mode": "webdav",
+ "webdav_url": "https://dav.example.test",
+ "webdav_password": "secret",
+ })
+ with mock.patch("services.image_storage_service.WebDAVClient", FakeWebDAVClient):
+ result = self.service().test_webdav()
+
+ self.assertTrue(result["ok"])
+ self.assertIn(".chatgpt2api_webdav_test.txt", FakeWebDAVClient.deleted)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_image_task_service.py b/test/test_image_task_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..566db0f8124250b77125adaf50678f0f99d8063d
--- /dev/null
+++ b/test/test_image_task_service.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import time
+import unittest
+from pathlib import Path
+
+from services.image_task_service import ImageTaskService
+
+
+OWNER = {"id": "owner-1", "name": "Owner", "role": "admin"}
+OTHER_OWNER = {"id": "owner-2", "name": "Other", "role": "user"}
+
+
+def wait_for_task(service: ImageTaskService, identity: dict[str, object], task_id: str, status: str, timeout: float = 2.0):
+ deadline = time.time() + timeout
+ last = None
+ while time.time() < deadline:
+ result = service.list_tasks(identity, [task_id])
+ last = (result.get("items") or [None])[0]
+ if last and last.get("status") == status:
+ return last
+ time.sleep(0.02)
+ raise AssertionError(f"task {task_id} did not reach {status}, last={last}")
+
+
+class ImageTaskServiceTests(unittest.TestCase):
+ def make_service(self, path: Path, handler=None) -> ImageTaskService:
+ return ImageTaskService(
+ path,
+ generation_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/image.png"}]}),
+ edit_handler=handler or (lambda _payload: {"data": [{"url": "http://example.test/edit.png"}]}),
+ retention_days_getter=lambda: 30,
+ )
+
+ def test_duplicate_submit_uses_existing_task(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ calls = 0
+
+ def handler(_payload):
+ nonlocal calls
+ calls += 1
+ time.sleep(0.05)
+ return {"data": [{"url": "http://example.test/image.png"}]}
+
+ service = self.make_service(Path(tmp_dir) / "image_tasks.json", handler)
+ first = service.submit_generation(
+ OWNER,
+ client_task_id="task-1",
+ prompt="cat",
+ model="gpt-image-2",
+ size=None,
+ base_url="http://local.test",
+ )
+ second = service.submit_generation(
+ OWNER,
+ client_task_id="task-1",
+ prompt="cat",
+ model="gpt-image-2",
+ size=None,
+ base_url="http://local.test",
+ )
+
+ self.assertEqual(first["id"], "task-1")
+ self.assertEqual(second["id"], "task-1")
+ task = wait_for_task(service, OWNER, "task-1", "success")
+ self.assertEqual(task["data"][0]["url"], "http://example.test/image.png")
+ self.assertEqual(calls, 1)
+
+ def test_different_owner_cannot_query_task(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ service = self.make_service(Path(tmp_dir) / "image_tasks.json")
+ service.submit_generation(
+ OWNER,
+ client_task_id="private-task",
+ prompt="cat",
+ model="gpt-image-2",
+ size=None,
+ base_url="http://local.test",
+ )
+
+ wait_for_task(service, OWNER, "private-task", "success")
+ result = service.list_tasks(OTHER_OWNER, ["private-task"])
+
+ self.assertEqual(result["items"], [])
+ self.assertEqual(result["missing_ids"], ["private-task"])
+
+ def test_success_task_persists_to_new_service_instance(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "image_tasks.json"
+ service = self.make_service(path)
+ service.submit_generation(
+ OWNER,
+ client_task_id="persisted-task",
+ prompt="cat",
+ model="gpt-image-2",
+ size=None,
+ base_url="http://local.test",
+ )
+ wait_for_task(service, OWNER, "persisted-task", "success")
+
+ reloaded = self.make_service(path)
+ result = reloaded.list_tasks(OWNER, ["persisted-task"])
+
+ self.assertEqual(result["missing_ids"], [])
+ self.assertEqual(result["items"][0]["status"], "success")
+ self.assertEqual(result["items"][0]["data"][0]["url"], "http://example.test/image.png")
+
+ def test_startup_marks_unfinished_tasks_as_error(self):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "image_tasks.json"
+ path.write_text(
+ json.dumps(
+ {
+ "tasks": [
+ {
+ "id": "queued-task",
+ "owner_id": "owner-1",
+ "status": "queued",
+ "mode": "generate",
+ "model": "gpt-image-2",
+ "created_at": "2099-01-01 00:00:00",
+ "updated_at": "2099-01-01 00:00:00",
+ },
+ {
+ "id": "running-task",
+ "owner_id": "owner-1",
+ "status": "running",
+ "mode": "generate",
+ "model": "gpt-image-2",
+ "created_at": "2099-01-01 00:00:00",
+ "updated_at": "2099-01-01 00:00:00",
+ },
+ ]
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ service = self.make_service(path)
+ result = service.list_tasks(OWNER, ["queued-task", "running-task"])
+
+ self.assertEqual([item["status"] for item in result["items"]], ["error", "error"])
+ self.assertTrue(all("已中断" in item.get("error", "") for item in result["items"]))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_image_tasks_api.py b/test/test_image_tasks_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..0afc4cb19d650648ec6d932a29054fb76b5d2cbe
--- /dev/null
+++ b/test/test_image_tasks_api.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+import base64
+import unittest
+from unittest import mock
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+import api.image_tasks as image_tasks_module
+
+
+AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
+PNG_BYTES = b"\x89PNG\r\n\x1a\n"
+DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode('ascii')}"
+
+
+class FakeImageTaskService:
+ def __init__(self):
+ self.generation_calls = []
+ self.edit_calls = []
+
+ def submit_generation(self, identity, **kwargs):
+ self.generation_calls.append((identity, kwargs))
+ return {
+ "id": kwargs["client_task_id"],
+ "status": "success",
+ "mode": "generate",
+ "created_at": "2026-01-01 00:00:00",
+ "updated_at": "2026-01-01 00:00:00",
+ "data": [{"url": f"{kwargs['base_url']}/images/fake.png"}],
+ }
+
+ def submit_edit(self, identity, **kwargs):
+ self.edit_calls.append((identity, kwargs))
+ return {
+ "id": kwargs["client_task_id"],
+ "status": "queued",
+ "mode": "edit",
+ "created_at": "2026-01-01 00:00:00",
+ "updated_at": "2026-01-01 00:00:00",
+ }
+
+ def list_tasks(self, _identity, ids):
+ return {
+ "items": [
+ {
+ "id": task_id,
+ "status": "success",
+ "mode": "generate",
+ "created_at": "2026-01-01 00:00:00",
+ "updated_at": "2026-01-01 00:00:00",
+ "data": [{"url": "http://testserver/images/fake.png"}],
+ }
+ for task_id in ids
+ if task_id != "missing"
+ ],
+ "missing_ids": [task_id for task_id in ids if task_id == "missing"],
+ }
+
+
+class ImageTasksApiTests(unittest.TestCase):
+ def setUp(self):
+ self.fake_service = FakeImageTaskService()
+ self.service_patcher = mock.patch.object(image_tasks_module, "image_task_service", self.fake_service)
+ self.service_patcher.start()
+ self.addCleanup(self.service_patcher.stop)
+ app = FastAPI()
+ app.include_router(image_tasks_module.create_router())
+ self.client = TestClient(app)
+
+ def test_create_generation_task(self):
+ response = self.client.post(
+ "/api/image-tasks/generations",
+ headers=AUTH_HEADERS,
+ json={"client_task_id": "task-1", "prompt": "cat", "model": "gpt-image-2"},
+ )
+
+ self.assertEqual(response.status_code, 200, response.text)
+ payload = response.json()
+ self.assertEqual(payload["id"], "task-1")
+ self.assertEqual(payload["status"], "success")
+ self.assertEqual(len(self.fake_service.generation_calls), 1)
+
+ def test_create_edit_task_accepts_multiple_images(self):
+ """测试图片编辑任务接口支持多个上传图片。"""
+ response = self.client.post(
+ "/api/image-tasks/edits",
+ headers=AUTH_HEADERS,
+ data={"client_task_id": "edit-1", "prompt": "edit", "model": "gpt-image-2"},
+ files=[
+ ("image", ("one.png", b"one", "image/png")),
+ ("image", ("two.png", b"two", "image/png")),
+ ],
+ )
+
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(response.json()["id"], "edit-1")
+ self.assertEqual(len(self.fake_service.edit_calls), 1)
+ images = self.fake_service.edit_calls[0][1]["images"]
+ self.assertEqual(len(images), 2)
+
+ def test_create_edit_task_accepts_image_url(self):
+ """测试图片编辑任务接口支持表单 image_url 引用。"""
+ response = self.client.post(
+ "/api/image-tasks/edits",
+ headers=AUTH_HEADERS,
+ data={
+ "client_task_id": "edit-url-1",
+ "prompt": "edit",
+ "model": "gpt-image-2",
+ "image_url": DATA_IMAGE_URL,
+ },
+ )
+
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(len(self.fake_service.edit_calls), 1)
+ images = self.fake_service.edit_calls[0][1]["images"]
+ self.assertEqual(images, [(PNG_BYTES, "image_url.png", "image/png")])
+
+ def test_list_tasks_reports_missing_ids(self):
+ response = self.client.get("/api/image-tasks?ids=task-1,missing", headers=AUTH_HEADERS)
+
+ self.assertEqual(response.status_code, 200, response.text)
+ payload = response.json()
+ self.assertEqual([item["id"] for item in payload["items"]], ["task-1"])
+ self.assertEqual(payload["missing_ids"], ["missing"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_image_tokens.py b/test/test_image_tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..cdd2554f03be03be6a8fdd7f591bfc43da0e0b93
--- /dev/null
+++ b/test/test_image_tokens.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import unittest
+
+from utils.image_tokens import (
+ count_image_input_tokens,
+ count_image_output_tokens,
+)
+
+
+class ImageTokenTests(unittest.TestCase):
+ def test_patch_token_examples_match_openai_docs(self):
+ self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-4.1-mini", "high"), 1659)
+ self.assertEqual(count_image_input_tokens(1800, 2400, "gpt-4.1-mini", "high"), 2353)
+
+ def test_image_input_tokens_force_gpt_54_mini(self):
+ expected = count_image_input_tokens(1024, 1024, "gpt-5.4-mini", "low")
+ self.assertEqual(expected, 415)
+ self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-4o", "low"), expected)
+ self.assertEqual(count_image_input_tokens(1024, 1024, "gpt-image-2", "low"), expected)
+
+ def test_image_output_tokens_scale_by_count_and_size(self):
+ single = count_image_output_tokens("1024x1024", "auto", 1)
+ self.assertGreater(single, 0)
+ self.assertEqual(count_image_output_tokens("1024x1024", "auto", 2), single * 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_multi_image_results.py b/test/test_multi_image_results.py
new file mode 100644
index 0000000000000000000000000000000000000000..2d9f98a3778d6676ecea68b52325a24d57699827
--- /dev/null
+++ b/test/test_multi_image_results.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+import base64
+import unittest
+from unittest import mock
+
+from services.config import config
+from services.openai_backend_api import OpenAIBackendAPI
+from services.protocol.conversation import ImageOutput, extract_conversation_ids
+from services.protocol.openai_v1_response import stream_image_response
+
+
+def _conversation(file_ids: list[str], sediment_ids: list[str] | None = None) -> dict:
+ parts: list[object] = [
+ {"content_type": "image_asset_pointer", "asset_pointer": f"file-service://{file_id}"}
+ for file_id in file_ids
+ ]
+ parts.extend(f"sediment://{sediment_id}" for sediment_id in (sediment_ids or []))
+ return {
+ "mapping": {
+ "tool": {
+ "message": {
+ "author": {"role": "tool"},
+ "create_time": 1,
+ "metadata": {"async_task_type": "image_gen"},
+ "content": {"content_type": "multimodal_text", "parts": parts},
+ }
+ }
+ }
+ }
+
+
+class FakeBackend(OpenAIBackendAPI):
+ def __init__(self, conversations: list[dict] | None = None) -> None:
+ self.conversations = conversations or []
+ self.calls = 0
+ self.file_urls: dict[str, str] = {}
+ self.sediment_urls: dict[str, str] = {}
+
+ def _get_conversation(self, conversation_id: str) -> dict:
+ self.calls += 1
+ index = min(self.calls - 1, len(self.conversations) - 1)
+ return self.conversations[index]
+
+ def _get_file_download_url(self, file_id: str) -> str:
+ return self.file_urls.get(file_id, "")
+
+ def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
+ return self.sediment_urls.get(attachment_id, "")
+
+
+class MultiImageResultTests(unittest.TestCase):
+ def test_stream_id_extractor_keeps_full_file_ids(self) -> None:
+ payload = (
+ '{"conversation_id":"conv-1"} '
+ 'file-service://file-first_123-extra sediment://sed-second_456-extra'
+ )
+
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
+
+ self.assertEqual(conversation_id, "conv-1")
+ self.assertEqual(file_ids, ["file-first_123-extra"])
+ self.assertEqual(sediment_ids, ["sed-second_456-extra"])
+
+ def test_conversation_record_extractor_finds_all_generated_assets(self) -> None:
+ backend = FakeBackend()
+ conversation = {
+ "mapping": {
+ "user": {
+ "message": {
+ "author": {"role": "user"},
+ "content": {"parts": ["file-service://file-user-input"]},
+ }
+ },
+ "tool": {
+ "message": {
+ "author": {"role": "tool"},
+ "create_time": 1,
+ "metadata": {
+ "async_task_type": "image_gen",
+ "nested": {"asset": "file-service://file-second"},
+ },
+ "content": {
+ "content_type": "text",
+ "parts": [
+ {"content_type": "image_asset_pointer", "asset_pointer": "file-service://file-first"},
+ "sediment://sed-first",
+ ],
+ },
+ }
+ },
+ "assistant": {
+ "message": {
+ "author": {"role": "assistant"},
+ "create_time": 2,
+ "metadata": {},
+ "content": {
+ "parts": [
+ {"content_type": "image_asset_pointer", "asset_pointer": "file-service://file-third"}
+ ]
+ },
+ }
+ },
+ }
+ }
+
+ records = backend._extract_image_tool_records(conversation)
+ file_ids = [file_id for record in records for file_id in record["file_ids"]]
+ sediment_ids = [sediment_id for record in records for sediment_id in record["sediment_ids"]]
+
+ self.assertEqual(file_ids, ["file-first", "file-second", "file-third"])
+ self.assertEqual(sediment_ids, ["sed-first"])
+
+ def test_poll_waits_for_generated_asset_ids_to_settle(self) -> None:
+ backend = FakeBackend([
+ _conversation(["file-one"]),
+ _conversation(["file-one", "file-two"], ["sed-one"]),
+ _conversation(["file-one", "file-two"], ["sed-one"]),
+ ])
+
+ with (
+ mock.patch.dict(config.data, {"image_poll_initial_wait_secs": 0, "image_poll_interval_secs": 0.5}),
+ mock.patch("services.openai_backend_api.time.sleep", lambda _seconds: None),
+ ):
+ file_ids, sediment_ids = backend._poll_image_results("conv-1", timeout_secs=10)
+
+ self.assertEqual(file_ids, ["file-one", "file-two"])
+ self.assertEqual(sediment_ids, ["sed-one"])
+ self.assertEqual(backend.calls, 3)
+
+ def test_resolver_uses_file_and_sediment_urls(self) -> None:
+ backend = FakeBackend()
+ backend.file_urls = {"file-one": "https://files.test/one.png"}
+ backend.sediment_urls = {
+ "sed-one": "https://attachments.test/one.png",
+ "sed-two": "https://attachments.test/two.png",
+ }
+
+ urls = backend._resolve_image_urls("conv-1", ["file-one"], ["sed-one", "sed-two"])
+
+ self.assertEqual(urls, [
+ "https://files.test/one.png",
+ "https://attachments.test/one.png",
+ "https://attachments.test/two.png",
+ ])
+
+ def test_resolver_keeps_stream_ids_when_poll_extension_fails(self) -> None:
+ backend = FakeBackend()
+ backend.file_urls = {"file-one": "https://files.test/one.png"}
+ backend._get_conversation = mock.Mock(side_effect=RuntimeError("poll failed"))
+
+ with mock.patch("services.openai_backend_api.time.sleep", lambda _seconds: None):
+ urls = backend.resolve_conversation_image_urls("conv-1", ["file-one"], [], poll=True)
+
+ self.assertEqual(urls, ["https://files.test/one.png"])
+
+ def test_responses_stream_emits_all_image_output_items(self) -> None:
+ first = base64.b64encode(b"first").decode("ascii")
+ second = base64.b64encode(b"second").decode("ascii")
+ events = list(stream_image_response(
+ [ImageOutput(
+ kind="result",
+ model="gpt-image-2",
+ index=1,
+ total=1,
+ data=[{"b64_json": first}, {"b64_json": second}],
+ )],
+ "draw two options",
+ "gpt-image-2",
+ ))
+
+ done_events = [event for event in events if event.get("type") == "response.output_item.done"]
+ completed = next(event["response"] for event in events if event.get("type") == "response.completed")
+
+ self.assertEqual([event["output_index"] for event in done_events], [0, 1])
+ self.assertEqual([item["result"] for item in completed["output"]], [first, second])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_v1_chat_completions.py b/test/test_v1_chat_completions.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c9d9c6b53bcd32181d339f1b90bee3cfb29eec4
--- /dev/null
+++ b/test/test_v1_chat_completions.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+import json
+import time
+import unittest
+
+import requests
+
+from utils.helper import save_images_from_text
+
+AUTH_KEY = "chatgpt2api"
+BASE_URL = "http://localhost:8000"
+
+
+class ChatCompletionsTests(unittest.TestCase):
+ def test_text_completion_http(self):
+ """测试文本对话的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": "auto",
+ "messages": [
+ {"role": "user", "content": "你好。"},
+ {"role": "assistant", "content": "你好,我可以帮助你处理文本和图片相关请求。"},
+ {"role": "user", "content": "那你再简单介绍一下你自己。"},
+ ],
+ },
+ timeout=300,
+ )
+ print("text non-stream status:")
+ print(response.status_code)
+ print("text non-stream result:")
+ print(json.dumps(response.json(), ensure_ascii=False, indent=2))
+
+ def test_text_completion_stream_http(self):
+ """测试文本对话的流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": "auto",
+ "stream": True,
+ "messages": [
+ {"role": "user", "content": "你好。"},
+ {"role": "assistant", "content": "你好,我的名字是Claude。"},
+ {"role": "user", "content": "那你再简单介绍一下你自己,比如你的名字是什么。"},
+ ],
+ },
+ stream=True,
+ timeout=300,
+ )
+ print("text stream status:")
+ print(response.status_code)
+ print("text stream result:")
+ for line in response.iter_lines():
+ if line:
+ print(line.decode("utf-8", errors="replace"))
+
+ def test_image_completion_http(self):
+ """测试图片对话的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": "gpt-image-2",
+ "messages": [
+ {"role": "user", "content": "我想做一张南京城市宣传海报图。"},
+ ],
+ "n": 1,
+ },
+ timeout=300,
+ )
+ payload = response.json()
+ content = str((((payload.get("choices") or [{}])[0].get("message") or {}).get("content") or ""))
+ saved_paths = save_images_from_text(content, "chat_completions_image_non_stream")
+ print("image non-stream status:")
+ print(response.status_code)
+ print("image non-stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+ def test_image_completion_stream_http(self):
+ """测试图片对话的流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/chat/completions",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": "gpt-image-2",
+ "stream": True,
+ "messages": [
+ {"role": "user", "content": "我想做一张南京城市宣传海报图。"},
+ ],
+ "n": 1,
+ },
+ stream=True,
+ timeout=300,
+ )
+ parts: list[str] = []
+ started_at = time.time()
+ print("image stream status:")
+ print(response.status_code)
+ print("image stream chunks:")
+ for line in response.iter_lines():
+ if not line:
+ continue
+ text = line.decode("utf-8", errors="replace")
+ print(f"{time.time() - started_at:6.2f}s {text}")
+ if not text.startswith("data:"):
+ continue
+ payload = text[5:].strip()
+ if payload == "[DONE]":
+ break
+ try:
+ chunk = json.loads(payload)
+ except Exception:
+ continue
+ delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
+ content = str(delta.get("content") or "")
+ if content:
+ parts.append(content)
+ saved_paths = save_images_from_text("".join(parts), "chat_completions_image_stream")
+ print("image stream saved files:")
+ for path in saved_paths:
+ print(path)
diff --git a/test/test_v1_images_edits.py b/test/test_v1_images_edits.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee09106687cff47774ed92c1313a6b9d961f62f2
--- /dev/null
+++ b/test/test_v1_images_edits.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import json
+import time
+import unittest
+from pathlib import Path
+
+import requests
+
+from test.utils import save_image
+from utils.log import logger
+
+AUTH_KEY = "chatgpt2api"
+BASE_URL = "http://localhost:8000"
+ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
+
+
+def load_asset_bytes(name: str) -> bytes:
+ return (ASSETS_DIR / name).read_bytes()
+
+
+def summarize_chunk(chunk: dict[str, object]) -> dict[str, object]:
+ data = chunk.get("data")
+ data_items = data if isinstance(data, list) else []
+ return {
+ "object": chunk.get("object"),
+ "index": chunk.get("index"),
+ "total": chunk.get("total"),
+ "created": chunk.get("created"),
+ "finish_reason": chunk.get("finish_reason"),
+ "progress_text": chunk.get("progress_text"),
+ "upstream_event_type": chunk.get("upstream_event_type"),
+ "data_count": len(data_items),
+ "has_b64_json": any(isinstance(item, dict) and bool(item.get("b64_json")) for item in data_items),
+ }
+
+
+class ImageEditsTests(unittest.TestCase):
+ def test_image_edit_http(self):
+ """测试图片编辑的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/images/edits",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ data={
+ "model": "gpt-image-2",
+ "prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。",
+ "n": "1",
+ "response_format": "b64_json",
+ },
+ files={"image": ("chery_studio.png", load_asset_bytes("chery_studio.png"), "image/png")},
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ payload = response.json()
+ saved_paths = []
+ for index, item in enumerate(payload.get("data") or [], start=1):
+ b64_json = str((item or {}).get("b64_json") or "")
+ if b64_json:
+ saved_paths.append(save_image(b64_json, f"images_edits_non_stream_{index}"))
+ self.assertGreater(len(saved_paths), 0, "非流式接口未输出图片。")
+ logger.info({
+ "event": "test_images_edits_non_stream_done",
+ "status_code": response.status_code,
+ "created": payload.get("created"),
+ "saved_paths": [str(path) for path in saved_paths],
+ "image_count": len(saved_paths),
+ })
+
+ def test_image_edit_stream_http(self):
+ """测试图片编辑的流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/images/edits",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ data={
+ "model": "gpt-image-2",
+ "prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。",
+ "n": "1",
+ "response_format": "b64_json",
+ "stream": "true",
+ },
+ files=[
+ ("image", ("image.png", load_asset_bytes("image.png"), "image/png")),
+ ("image", ("image_edit.png", load_asset_bytes("image_edit.png"), "image/png")),
+ ],
+ stream=True,
+ timeout=300,
+ )
+ image_items: list[dict[str, object]] = []
+ stream_errors: list[dict[str, object]] = []
+ started_at = time.time()
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertTrue(
+ response.headers.get("content-type", "").startswith("text/event-stream"),
+ response.headers.get("content-type", ""),
+ )
+ logger.info({
+ "event": "test_images_edits_stream_start",
+ "status_code": response.status_code,
+ "content_type": response.headers.get("content-type"),
+ })
+ try:
+ for line in response.iter_lines():
+ if not line:
+ continue
+ text = line.decode("utf-8", errors="replace")
+ if not text.startswith("data:"):
+ continue
+ payload = text[5:].strip()
+ if payload == "[DONE]":
+ break
+ try:
+ chunk = json.loads(payload)
+ except Exception:
+ continue
+ elapsed = time.time() - started_at
+ if isinstance(chunk.get("error"), dict):
+ stream_errors.append(chunk["error"])
+ logger.info({
+ "event": "test_images_edits_stream_chunk",
+ "elapsed_seconds": round(elapsed, 2),
+ "chunk": summarize_chunk(chunk),
+ })
+ data = chunk.get("data")
+ if isinstance(data, list):
+ image_items.extend(item for item in data if isinstance(item, dict))
+ finally:
+ response.close()
+
+ saved_paths = []
+ for index, item in enumerate(image_items, start=1):
+ b64_json = str(item.get("b64_json") or "")
+ if b64_json:
+ saved_paths.append(save_image(b64_json, f"images_edits_stream_{index}"))
+ self.assertFalse(stream_errors, f"流式接口返回错误: {stream_errors}")
+ self.assertGreater(len(saved_paths), 0, "流式接口未输出图片。")
+ logger.info({
+ "event": "test_images_edits_stream_done",
+ "saved_paths": [str(path) for path in saved_paths],
+ "image_count": len(saved_paths),
+ })
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_v1_images_edits_api.py b/test/test_v1_images_edits_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..22e2027fb714fd522cceb9643d2e7224a0f387c7
--- /dev/null
+++ b/test/test_v1_images_edits_api.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import base64
+import unittest
+from unittest import mock
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+import api.ai as ai_module
+
+
+AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
+PNG_BYTES = b"\x89PNG\r\n\x1a\n"
+DATA_IMAGE_URL = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode('ascii')}"
+
+
+class ImagesEditsApiTests(unittest.TestCase):
+ def setUp(self):
+ self.handle_calls = []
+
+ def fake_handle(payload):
+ self.handle_calls.append(payload)
+ return {"created": 1, "data": [{"b64_json": base64.b64encode(b"out").decode("ascii")}]}
+
+ self.handler_patcher = mock.patch.object(ai_module.openai_v1_image_edit, "handle", fake_handle)
+ self.handler_patcher.start()
+ self.addCleanup(self.handler_patcher.stop)
+ app = FastAPI()
+ app.include_router(ai_module.create_router())
+ self.client = TestClient(app)
+
+ def test_edit_accepts_json_image_url(self):
+ """测试图片编辑接口支持官方 JSON image_url 引用。"""
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ json={
+ "model": "gpt-image-2",
+ "prompt": "edit",
+ "images": [{"image_url": DATA_IMAGE_URL}],
+ "n": 1,
+ "response_format": "b64_json",
+ },
+ )
+
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(len(self.handle_calls), 1)
+ payload = self.handle_calls[0]
+ self.assertEqual(payload["prompt"], "edit")
+ self.assertEqual(payload["n"], 1)
+ self.assertEqual(payload["images"], [(PNG_BYTES, "image_url.png", "image/png")])
+
+ def test_edit_rejects_file_id_reference(self):
+ """测试图片编辑接口对暂不支持的 file_id 返回明确错误。"""
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ json={
+ "model": "gpt-image-2",
+ "prompt": "edit",
+ "images": [{"file_id": "file-abc123"}],
+ },
+ )
+
+ self.assertEqual(response.status_code, 400, response.text)
+ self.assertIn("file_id image references are not supported", response.text)
+ self.assertEqual(self.handle_calls, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_v1_images_edits_json.py b/test/test_v1_images_edits_json.py
new file mode 100644
index 0000000000000000000000000000000000000000..c3d83502865cea60c93b3f3dc95f6587d93feb48
--- /dev/null
+++ b/test/test_v1_images_edits_json.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import base64
+import os
+import unittest
+from unittest import mock
+
+os.environ.setdefault("CHATGPT2API_AUTH_KEY", "chatgpt2api")
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+import api.ai as ai_module
+
+AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
+PNG_DATA_URL = "data:image/png;base64," + base64.b64encode(b"fake-png").decode("ascii")
+JPEG_DATA_URL = "data:image/jpeg;base64," + base64.b64encode(b"fake-jpeg").decode("ascii")
+
+
+class ImageEditsJsonApiTests(unittest.TestCase):
+ def setUp(self):
+ self.calls = []
+
+ def fake_handle(payload):
+ self.calls.append(payload)
+ return {"created": 1, "data": [{"b64_json": "ZmFrZQ=="}]}
+
+ self.handle_patcher = mock.patch.object(ai_module.openai_v1_image_edit, "handle", fake_handle)
+ self.filter_patcher = mock.patch.object(ai_module, "filter_or_log", mock.AsyncMock())
+ self.handle_patcher.start()
+ self.filter_patcher.start()
+ self.addCleanup(self.handle_patcher.stop)
+ self.addCleanup(self.filter_patcher.stop)
+
+ app = FastAPI()
+ app.include_router(ai_module.create_router())
+ self.client = TestClient(app)
+
+ def test_json_model_omitted_uses_existing_default_logic(self):
+ response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "未传 model", "image": PNG_DATA_URL})
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(self.calls[0]["model"], "gpt-image-2")
+
+ def test_json_model_is_not_overwritten_when_provided(self):
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ json={"model": "codex-gpt-image-2", "prompt": "保留 model", "image": PNG_DATA_URL},
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(self.calls[0]["model"], "codex-gpt-image-2")
+
+ def test_image_edit_accepts_json_image_url(self):
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ json={
+ "model": "gpt-image-2",
+ "prompt": "把图片改成夜景风格",
+ "n": 1,
+ "size": "1024x1536",
+ "response_format": "b64_json",
+ "images": [{"image_url": PNG_DATA_URL}],
+ },
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ payload = self.calls[0]
+ self.assertEqual(payload["images"], [(b"fake-png", "image_1.png", "image/png")])
+ self.assertEqual(payload["size"], "1024x1536")
+
+ def test_image_edit_accepts_json_multiple_images_and_b64_json(self):
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ json={
+ "prompt": "把两张图合成海报",
+ "images": [
+ PNG_DATA_URL,
+ {"b64_json": base64.b64encode(b"raw-jpeg").decode("ascii"), "mime_type": "image/jpeg", "filename": "two.jpg"},
+ {"image_url": {"url": JPEG_DATA_URL}},
+ ],
+ },
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(self.calls[0]["images"], [
+ (b"fake-png", "image_1.png", "image/png"),
+ (b"raw-jpeg", "two.jpg", "image/jpeg"),
+ (b"fake-jpeg", "image_3.jpg", "image/jpeg"),
+ ])
+
+ def test_image_edit_keeps_original_multipart_multiple_image_logic(self):
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ data={"prompt": "multipart 多图仍然可用", "model": "gpt-image-2", "n": "1"},
+ files=[
+ ("image", ("one.png", b"one", "image/png")),
+ ("image", ("two.jpg", b"two", "image/jpeg")),
+ ("image[]", ("three.webp", b"three", "image/webp")),
+ ],
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertEqual(self.calls[0]["images"], [
+ (b"one", "one.png", "image/png"),
+ (b"two", "two.jpg", "image/jpeg"),
+ (b"three", "three.webp", "image/webp"),
+ ])
+
+ def test_image_edit_rejects_json_without_image(self):
+ response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "缺少图片"})
+ self.assertEqual(response.status_code, 400, response.text)
+ self.assertIn("image file is required", response.text)
+
+ def test_image_edit_rejects_remote_json_url(self):
+ response = self.client.post(
+ "/v1/images/edits",
+ headers=AUTH_HEADERS,
+ json={"prompt": "不允许远程拉图", "images": [{"image_url": "https://example.com/a.png"}]},
+ )
+ self.assertEqual(response.status_code, 400, response.text)
+ self.assertIn("remote image URLs are not supported", response.text)
+
+ def test_image_edit_rejects_json_n_out_of_range(self):
+ response = self.client.post("/v1/images/edits", headers=AUTH_HEADERS, json={"prompt": "n 越界", "n": 5, "image": PNG_DATA_URL})
+ self.assertEqual(response.status_code, 400, response.text)
+ self.assertFalse(self.calls)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_v1_images_generations.py b/test/test_v1_images_generations.py
new file mode 100644
index 0000000000000000000000000000000000000000..980e16dc94e1f361ea30be7ceb22364648a8667a
--- /dev/null
+++ b/test/test_v1_images_generations.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import json
+import time
+import unittest
+
+import requests
+
+from test.utils import save_image
+
+AUTH_KEY = "chatgpt2api"
+BASE_URL = "http://localhost:8000"
+
+
+class ImageGenerationsTests(unittest.TestCase):
+ def test_image_generation_http(self):
+ """测试图片生成的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/images/generations",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": "gpt-image-2",
+ "prompt": "我想做一张南京城市宣传海报图。",
+ "n": 1,
+ "response_format": "b64_json",
+ },
+ timeout=300,
+ )
+ payload = response.json()
+ saved_paths = []
+ for index, item in enumerate(payload.get("data") or [], start=1):
+ b64_json = str((item or {}).get("b64_json") or "")
+ if b64_json:
+ saved_paths.append(save_image(b64_json, f"images_generations_non_stream_{index}"))
+ print("images generations non-stream status:")
+ print(response.status_code)
+ print("images generations non-stream created:")
+ print(payload.get("created"))
+ print("images generations non-stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+ def test_image_generation_stream_http(self):
+ """测试图片生成的流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/images/generations",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": "gpt-image-2",
+ "prompt": "我想做一张南京城市宣传海报图。",
+ "n": 1,
+ "response_format": "b64_json",
+ "stream": True,
+ },
+ stream=True,
+ timeout=300,
+ )
+ image_items: list[dict[str, object]] = []
+ started_at = time.time()
+ print("images generations stream status:")
+ print(response.status_code)
+ print("images generations stream chunks:")
+ for line in response.iter_lines():
+ if not line:
+ continue
+ text = line.decode("utf-8", errors="replace")
+ print(f"{time.time() - started_at:6.2f}s {text}")
+ if not text.startswith("data:"):
+ continue
+ payload = text[5:].strip()
+ if payload == "[DONE]":
+ break
+ try:
+ chunk = json.loads(payload)
+ except Exception:
+ continue
+ data = chunk.get("data")
+ if isinstance(data, list):
+ image_items.extend(item for item in data if isinstance(item, dict))
+
+ saved_paths = []
+ for index, item in enumerate(image_items, start=1):
+ b64_json = str(item.get("b64_json") or "")
+ if b64_json:
+ saved_paths.append(save_image(b64_json, f"images_generations_stream_{index}"))
+ print("images generations stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_v1_messages.py b/test/test_v1_messages.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2194bb4c83a5346f578e28faf3ebb88fe356f10
--- /dev/null
+++ b/test/test_v1_messages.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import json
+import time
+import unittest
+
+import requests
+
+AUTH_KEY = "chatgpt2api"
+BASE_URL = "http://localhost:8000"
+MODEL = "auto"
+
+
+class AnthropicMessagesTests(unittest.TestCase):
+ @staticmethod
+ def _headers() -> dict[str, str]:
+ return {
+ "x-api-key": AUTH_KEY,
+ "anthropic-version": "2023-06-01",
+ }
+
+ def test_message_http(self):
+ """测试 Anthropic Messages 的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ headers=self._headers(),
+ json={
+ "model": MODEL,
+ "messages": [
+ {"role": "user", "content": "你好,请简单介绍一下你自己。"},
+ ],
+ },
+ timeout=300,
+ )
+ print("messages non-stream status:")
+ print(response.status_code)
+ print("messages non-stream result:")
+ try:
+ print(json.dumps(response.json(), ensure_ascii=False, indent=2))
+ except Exception:
+ print(response.text)
+
+ def test_message_stream_http(self):
+ """测试 Anthropic Messages 的流式 HTTP 调用。"""
+ started_at = time.time()
+ response = requests.post(
+ f"{BASE_URL}/v1/messages",
+ headers=self._headers(),
+ json={
+ "model": MODEL,
+ "stream": True,
+ "messages": [
+ {"role": "user", "content": "你好,请简单介绍一下你自己。"},
+ ],
+ },
+ stream=True,
+ timeout=300,
+ )
+ headers_at = time.time()
+ print("messages stream status:")
+ print(response.status_code)
+ print("messages stream content-type:")
+ print(response.headers.get("content-type", ""))
+ print("messages stream response headers:")
+ print(f"{headers_at - started_at:6.2f}s")
+ if response.status_code != 200:
+ print(response.text)
+ return
+ print("messages stream chunks:")
+ for line in response.iter_lines(chunk_size=1):
+ if not line:
+ continue
+ text = line.decode("utf-8", errors="replace")
+ print(f"{time.time() - started_at:6.2f}s {text}")
+ if not text.startswith("data:"):
+ continue
+ try:
+ payload = json.loads(text[5:].strip())
+ except Exception:
+ continue
+ if payload.get("type") == "message_stop":
+ break
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_v1_models.py b/test/test_v1_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..3cd3d8535d9edd4d5307ba8255406dd070ac9823
--- /dev/null
+++ b/test/test_v1_models.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import json
+import unittest
+from unittest import mock
+
+import requests
+
+from services.protocol import openai_v1_models
+
+
+AUTH_KEY = "chatgpt2api"
+BASE_URL = "http://localhost:8000"
+
+
+class ModelListTests(unittest.TestCase):
+ def test_list_models_only_returns_image_models_backed_by_account_types(self):
+ with (
+ mock.patch.object(
+ openai_v1_models.OpenAIBackendAPI,
+ "list_models",
+ return_value={"object": "list", "data": []},
+ ),
+ mock.patch.object(
+ openai_v1_models.account_service,
+ "list_accounts",
+ return_value=[
+ {"access_token": "token-free", "type": "free"},
+ {"access_token": "token-web-team", "type": "Team", "source_type": "web"},
+ {"access_token": "token-codex-team", "type": "Team", "source_type": "codex"},
+ ],
+ ),
+ ):
+ result = openai_v1_models.list_models()
+
+ ids = {item["id"] for item in result["data"]}
+ self.assertIn("gpt-image-2", ids)
+ self.assertIn("codex-gpt-image-2", ids)
+ self.assertIn("team-codex-gpt-image-2", ids)
+ self.assertNotIn("plus-codex-gpt-image-2", ids)
+ self.assertNotIn("pro-codex-gpt-image-2", ids)
+
+ def test_list_models_does_not_return_codex_models_for_web_plus_accounts(self):
+ with (
+ mock.patch.object(
+ openai_v1_models.OpenAIBackendAPI,
+ "list_models",
+ return_value={"object": "list", "data": []},
+ ),
+ mock.patch.object(
+ openai_v1_models.account_service,
+ "list_accounts",
+ return_value=[
+ {"access_token": "token-web-plus", "type": "Plus", "source_type": "web"},
+ ],
+ ),
+ ):
+ result = openai_v1_models.list_models()
+
+ ids = {item["id"] for item in result["data"]}
+ self.assertIn("gpt-image-2", ids)
+ self.assertNotIn("codex-gpt-image-2", ids)
+ self.assertNotIn("plus-codex-gpt-image-2", ids)
+
+ def test_list_models_function(self):
+ """测试直接调用服务层获取模型列表。"""
+ result = openai_v1_models.list_models()
+ print("function result:")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+ def test_list_models_http(self):
+ """测试通过 HTTP 接口获取模型列表。"""
+ response = requests.get(
+ f"{BASE_URL}/v1/models",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ timeout=30,
+ )
+ print("http status:")
+ print(response.status_code)
+ print("http result:")
+ print(json.dumps(response.json(), ensure_ascii=False, indent=2))
diff --git a/test/test_v1_responses.py b/test/test_v1_responses.py
new file mode 100644
index 0000000000000000000000000000000000000000..a06bf7681980aae0bfc470ffd138445dc9425da0
--- /dev/null
+++ b/test/test_v1_responses.py
@@ -0,0 +1,294 @@
+from __future__ import annotations
+
+import json
+import time
+import unittest
+
+import requests
+
+from test.utils import save_image
+
+AUTH_KEY = "chatgpt2api"
+BASE_URL = "http://localhost:8000"
+TEXT_MODEL = "auto"
+IMAGE_MODEL = "gpt-image-2"
+CODEX_IMAGE_MODEL = "codex-gpt-image-2"
+
+
+class ResponsesTests(unittest.TestCase):
+ @staticmethod
+ def _iter_sse_payloads(response: requests.Response):
+ for line in response.iter_lines():
+ if not line:
+ continue
+ text = line.decode("utf-8", errors="replace")
+ yield text
+
+ def test_text_response_http(self):
+ """测试 Responses 文本的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/responses",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": TEXT_MODEL,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "你好,请简单介绍一下你自己。"},
+ ],
+ }
+ ],
+ },
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ print("responses text non-stream status:")
+ print(response.status_code)
+ print("responses text non-stream result:")
+ try:
+ payload = response.json()
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ self.assertEqual(payload.get("object"), "response")
+ self.assertEqual(payload.get("status"), "completed")
+ self.assertTrue(isinstance(payload.get("output"), list) and payload.get("output"))
+ except Exception:
+ print(response.text)
+ raise
+
+ def test_text_response_stream_http(self):
+ """测试 Responses 文本的流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/responses",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": TEXT_MODEL,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "你好,请简单介绍一下你自己。"},
+ ],
+ }
+ ],
+ "stream": True,
+ },
+ stream=True,
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertTrue(
+ response.headers.get("content-type", "").startswith("text/event-stream"),
+ response.headers.get("content-type", ""),
+ )
+ started_at = time.time()
+ print("responses text stream status:")
+ print(response.status_code)
+ print("responses text stream chunks:")
+ event_types = []
+ for text in self._iter_sse_payloads(response):
+ print(f"{time.time() - started_at:6.2f}s {text}")
+ if not text.startswith("data:"):
+ continue
+ payload_text = text[5:].strip()
+ if payload_text == "[DONE]":
+ break
+ try:
+ payload = json.loads(payload_text)
+ except Exception:
+ continue
+ event_type = str(payload.get("type") or "")
+ if event_type:
+ event_types.append(event_type)
+ self.assertIn("response.created", event_types)
+ self.assertIn("response.completed", event_types)
+
+ def test_image_response_http(self):
+ """测试 Responses 画图的非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/responses",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": IMAGE_MODEL,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
+ ],
+ }
+ ],
+ "tools": [{"type": "image_generation"}],
+ },
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ saved_paths = []
+ try:
+ payload = response.json()
+ except Exception:
+ payload = {}
+ for index, item in enumerate(payload.get("output") or [], start=1):
+ if not isinstance(item, dict):
+ continue
+ image_b64 = str(item.get("result") or "")
+ if image_b64:
+ saved_paths.append(save_image(image_b64, f"responses_image_non_stream_{index}"))
+ print("responses image non-stream status:")
+ print(response.status_code)
+ print("responses image non-stream result:")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ print("responses image non-stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+ def test_image_response_stream_http(self):
+ """测试 Responses 画图的流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/responses",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": IMAGE_MODEL,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
+ ],
+ }
+ ],
+ "tools": [{"type": "image_generation"}],
+ "stream": True,
+ },
+ stream=True,
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertTrue(
+ response.headers.get("content-type", "").startswith("text/event-stream"),
+ response.headers.get("content-type", ""),
+ )
+ started_at = time.time()
+ saved_paths = []
+ print("responses image stream status:")
+ print(response.status_code)
+ print("responses image stream chunks:")
+ for text in self._iter_sse_payloads(response):
+ print(f"{time.time() - started_at:6.2f}s {text}")
+ if not text.startswith("data:"):
+ continue
+ payload_text = text[5:].strip()
+ if payload_text == "[DONE]":
+ break
+ try:
+ payload = json.loads(payload_text)
+ except Exception:
+ continue
+ if payload.get("type") != "response.output_item.done":
+ continue
+ item = payload.get("item") or {}
+ if str(item.get("type") or "") != "image_generation_call":
+ continue
+ image_b64 = str(item.get("result") or "")
+ if image_b64:
+ saved_paths.append(save_image(image_b64, f"responses_image_stream_{len(saved_paths) + 1}"))
+ print("responses image stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+ def test_codex_image_response_http(self):
+ """测试 Responses 的 codex 画图非流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/responses",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": CODEX_IMAGE_MODEL,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
+ ],
+ }
+ ],
+ "tools": [{"type": "image_generation"}],
+ },
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ saved_paths = []
+ try:
+ payload = response.json()
+ except Exception:
+ payload = {}
+ for index, item in enumerate(payload.get("output") or [], start=1):
+ if not isinstance(item, dict):
+ continue
+ image_b64 = str(item.get("result") or "")
+ if image_b64:
+ saved_paths.append(save_image(image_b64, f"responses_codex_image_non_stream_{index}"))
+ print("responses codex image non-stream status:")
+ print(response.status_code)
+ print("responses codex image non-stream result:")
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
+ print("responses codex image non-stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+ def test_codex_image_response_stream_http(self):
+ """测试 Responses 的 codex 画图流式 HTTP 调用。"""
+ response = requests.post(
+ f"{BASE_URL}/v1/responses",
+ headers={"Authorization": f"Bearer {AUTH_KEY}"},
+ json={
+ "model": CODEX_IMAGE_MODEL,
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "input_text", "text": "我想做一张南京城市宣传海报图。"},
+ ],
+ }
+ ],
+ "tools": [{"type": "image_generation"}],
+ "stream": True,
+ },
+ stream=True,
+ timeout=300,
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertTrue(
+ response.headers.get("content-type", "").startswith("text/event-stream"),
+ response.headers.get("content-type", ""),
+ )
+ started_at = time.time()
+ saved_paths = []
+ print("responses codex image stream status:")
+ print(response.status_code)
+ print("responses codex image stream chunks:")
+ for text in self._iter_sse_payloads(response):
+ print(f"{time.time() - started_at:6.2f}s {text}")
+ if not text.startswith("data:"):
+ continue
+ payload_text = text[5:].strip()
+ if payload_text == "[DONE]":
+ break
+ try:
+ payload = json.loads(payload_text)
+ except Exception:
+ continue
+ if payload.get("type") != "response.output_item.done":
+ continue
+ item = payload.get("item") or {}
+ if str(item.get("type") or "") != "image_generation_call":
+ continue
+ image_b64 = str(item.get("result") or "")
+ if image_b64:
+ saved_paths.append(save_image(image_b64, f"responses_codex_image_stream_{len(saved_paths) + 1}"))
+ print("responses codex image stream saved files:")
+ for path in saved_paths:
+ print(path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/utils.py b/test/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..bfb5ba0f6e65325392f6cbfa55a55ca97f20653c
--- /dev/null
+++ b/test/utils.py
@@ -0,0 +1,47 @@
+import base64
+import json
+import sys
+import time
+import urllib.request
+from pathlib import Path
+
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+OUTPUT_DIR = ROOT_DIR / "data" / "output"
+BASE_URL = "http://127.0.0.1:8000"
+
+if str(ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(ROOT_DIR))
+
+
+def load_auth_key() -> str:
+ return json.loads((ROOT_DIR / "config.json").read_text(encoding="utf-8"))["auth-key"]
+
+
+def post_json(path: str, payload: dict) -> dict:
+ request = urllib.request.Request(
+ BASE_URL + path,
+ data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"},
+ method="POST",
+ )
+ with urllib.request.urlopen(request) as response:
+ return json.loads(response.read().decode())
+
+
+def detect_ext(image_bytes: bytes) -> str:
+ if image_bytes.startswith(b"\xff\xd8\xff"):
+ return ".jpg"
+ if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
+ return ".webp"
+ if image_bytes.startswith((b"GIF87a", b"GIF89a")):
+ return ".gif"
+ return ".png"
+
+
+def save_image(image_b64: str, name: str) -> Path:
+ image_bytes = base64.b64decode(image_b64)
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ path = OUTPUT_DIR / f"{name}_{int(time.time())}{detect_ext(image_bytes)}"
+ path.write_bytes(image_bytes)
+ return path
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/utils/helper.py b/utils/helper.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b37e447ff2593f3465ee96370a6238d18772623
--- /dev/null
+++ b/utils/helper.py
@@ -0,0 +1,469 @@
+import base64
+import hashlib
+import json
+import mimetypes
+import re
+import time
+import uuid
+from pathlib import Path
+from typing import Any, Iterator
+from urllib.parse import urlparse
+
+from curl_cffi import requests
+from fastapi import HTTPException
+from services.proxy_service import proxy_settings
+from utils.log import logger
+
+BASE_IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"}
+IMAGE_MODEL_PLAN_TYPES = ("plus", "team", "pro")
+CODEX_IMAGE_MODEL = "codex-gpt-image-2"
+PREFIXED_CODEX_IMAGE_MODELS = {
+ f"{plan_type}-{CODEX_IMAGE_MODEL}"
+ for plan_type in IMAGE_MODEL_PLAN_TYPES
+}
+IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
+PUBLIC_IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
+OUTPUT_DIR = Path(__file__).resolve().parent / "output"
+
+SUPPORTED_JSON_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
+MAX_JSON_IMAGE_BYTES = 10 * 1024 * 1024
+MAX_JSON_EDIT_IMAGES = 10
+DATA_URL_IMAGE_RE = re.compile(r"^data:(?P[-+./\w]+);base64,(?P.*)$", re.DOTALL)
+REMOTE_IMAGE_TIMEOUT_SECONDS = 20
+
+
+def _image_extension(mime_type: str) -> str:
+ image_type = mime_type.split("/", 1)[1].split(";", 1)[0].lower() if "/" in mime_type else "png"
+ return "jpg" if image_type == "jpeg" else image_type or "png"
+
+
+def _decode_json_image_string(value: str, index: int, filename: str | None = None, mime_type: str | None = None) -> tuple[bytes, str, str]:
+ text = value.strip()
+ if not text:
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
+ match = DATA_URL_IMAGE_RE.match(text)
+ if match:
+ resolved_mime = (match.group("mime") or "image/png").lower()
+ encoded = match.group("data")
+ else:
+ if text.startswith(("http://", "https://")):
+ raise HTTPException(status_code=400, detail={"error": "remote image URLs are not supported"})
+ resolved_mime = (mime_type or "image/png").lower()
+ encoded = text
+ if resolved_mime == "image/jpg":
+ resolved_mime = "image/jpeg"
+ if resolved_mime not in SUPPORTED_JSON_IMAGE_MIME_TYPES:
+ raise HTTPException(status_code=400, detail={"error": "unsupported image mime type"})
+ try:
+ image_data = base64.b64decode(encoded, validate=True)
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
+ if not image_data:
+ raise HTTPException(status_code=400, detail={"error": "image file is empty"})
+ if len(image_data) > MAX_JSON_IMAGE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image file is too large"})
+ return image_data, filename or f"image_{index}.{_image_extension(resolved_mime)}", resolved_mime
+
+
+def _extract_json_image_value(item: object) -> tuple[str, str | None, str | None]:
+ if isinstance(item, str):
+ return item, None, None
+ if not isinstance(item, dict):
+ raise HTTPException(status_code=400, detail={"error": "image entry must be a base64 string or object"})
+ filename = str(item.get("filename") or item.get("file_name") or "").strip() or None
+ mime_type = str(item.get("mime_type") or item.get("mimeType") or "").strip() or None
+ value = item.get("b64_json") or item.get("base64")
+ if not value:
+ image_url = item.get("image_url") or item.get("url")
+ if isinstance(image_url, dict):
+ filename = filename or str(image_url.get("filename") or image_url.get("file_name") or "").strip() or None
+ mime_type = mime_type or str(image_url.get("mime_type") or image_url.get("mimeType") or "").strip() or None
+ value = image_url.get("url") or image_url.get("image_url")
+ else:
+ value = image_url
+ if not isinstance(value, str) or not value.strip():
+ raise HTTPException(status_code=400, detail={"error": "image entry must include image data"})
+ return value, filename, mime_type
+
+
+def normalize_json_edit_images(image: object = None, images: object = None) -> list[tuple[bytes, str, str]]:
+ raw_images = images if images is not None else image
+ if raw_images is None:
+ raise HTTPException(status_code=400, detail={"error": "image file is required"})
+ entries = raw_images if isinstance(raw_images, list) else [raw_images]
+ if not entries:
+ raise HTTPException(status_code=400, detail={"error": "image file is required"})
+ if len(entries) > MAX_JSON_EDIT_IMAGES:
+ raise HTTPException(status_code=400, detail={"error": f"images supports up to {MAX_JSON_EDIT_IMAGES} items"})
+ normalized = []
+ for index, item in enumerate(entries, start=1):
+ value, filename, mime_type = _extract_json_image_value(item)
+ normalized.append(_decode_json_image_string(value, index, filename, mime_type))
+ return normalized
+
+
+def new_uuid() -> str:
+ return str(uuid.uuid4())
+
+
+def split_image_model(model: object) -> tuple[str | None, str | None]:
+ normalized = str(model or "").strip().lower()
+ if not normalized:
+ return None, None
+ if normalized in BASE_IMAGE_MODELS:
+ return None, normalized
+ for plan_type in IMAGE_MODEL_PLAN_TYPES:
+ prefix = f"{plan_type}-"
+ if normalized.startswith(prefix):
+ base_model = normalized[len(prefix):]
+ if base_model == CODEX_IMAGE_MODEL:
+ return plan_type, base_model
+ return None, None
+
+
+def is_supported_image_model(model: object) -> bool:
+ _, base_model = split_image_model(model)
+ return base_model is not None
+
+
+def is_codex_image_model(model: object) -> bool:
+ _, base_model = split_image_model(model)
+ return base_model == CODEX_IMAGE_MODEL
+
+
+def is_image_chat_request(body: dict[str, object]) -> bool:
+ model = str(body.get("model") or "").strip()
+ modalities = body.get("modalities")
+ if is_supported_image_model(model):
+ return True
+ return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
+
+
+_UPSTREAM_BODY_LOG_LIMIT = 500
+
+
+class UpstreamHTTPError(RuntimeError):
+ """Raised when an upstream HTTP call returns a non-2xx status.
+
+ Carries structured fields (status_code, body, retry_after) so callers can
+ branch on status code instead of string-matching on str(exc). The full
+ body is preserved on the instance; the formatted message truncates it
+ to keep log lines reasonable.
+ """
+
+ def __init__(
+ self,
+ context: str,
+ status_code: int,
+ body: Any,
+ retry_after: int | None = None,
+ ) -> None:
+ self.context = context
+ self.status_code = status_code
+ self.body = body
+ self.retry_after = retry_after
+ if isinstance(body, (dict, list)):
+ try:
+ body_str = json.dumps(body, ensure_ascii=False)
+ except (TypeError, ValueError):
+ body_str = repr(body)
+ else:
+ body_str = str(body)
+ if len(body_str) > _UPSTREAM_BODY_LOG_LIMIT:
+ body_str = body_str[:_UPSTREAM_BODY_LOG_LIMIT] + "…[truncated]"
+ super().__init__(f"{context} failed: status={status_code}, body={body_str}")
+
+
+def ensure_ok(response: requests.Response, context: str) -> None:
+ if 200 <= response.status_code < 300:
+ return
+ body: Any = response.text
+ try:
+ body = response.json()
+ except Exception:
+ pass
+ retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None
+ retry_after: int | None = None
+ if retry_after_header is not None:
+ ra_str = str(retry_after_header).strip()
+ if ra_str.isdigit():
+ retry_after = int(ra_str)
+ raise UpstreamHTTPError(context, response.status_code, body, retry_after=retry_after)
+
+
+def sse_json_stream(items) -> Iterator[str]:
+ yield ": stream-open\n\n"
+ try:
+ for item in items:
+ yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
+ except Exception as exc:
+ logger.warning({
+ "event": "sse_stream_error",
+ "error_type": exc.__class__.__name__,
+ "error": str(exc),
+ })
+ error = exc.to_openai_error() if hasattr(exc, "to_openai_error") else {
+ "error": {"message": str(exc), "type": exc.__class__.__name__}
+ }
+ yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
+ yield "data: [DONE]\n\n"
+
+
+def anthropic_sse_stream(items) -> Iterator[str]:
+ try:
+ for item in items:
+ event = str(item.get("type") or "message_delta") if isinstance(item, dict) else "message_delta"
+ yield f"event: {event}\n"
+ yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
+ except Exception as exc:
+ logger.warning({
+ "event": "anthropic_sse_stream_error",
+ "error_type": exc.__class__.__name__,
+ "error": str(exc),
+ })
+ error = {"type": "error", "error": {"type": exc.__class__.__name__, "message": str(exc)}}
+ yield "event: error\n"
+ yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
+
+
+def iter_sse_payloads(response: requests.Response) -> Iterator[str]:
+ for raw_line in response.iter_lines():
+ if not raw_line:
+ continue
+ line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
+ if not line.startswith("data:"):
+ continue
+ payload = line[5:].strip()
+ if payload:
+ yield payload
+
+
+def save_images_from_text(text: str, prefix: str) -> list[Path]:
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
+ saved_paths: list[Path] = []
+ timestamp = int(time.time() * 1000)
+ for index, data_url in enumerate(matches, start=1):
+ header, encoded = data_url.split(",", 1)
+ image_type = header.split(";")[0].removeprefix("data:image/").strip() or "png"
+ extension = "jpg" if image_type == "jpeg" else image_type
+ output_path = OUTPUT_DIR / f"{prefix}_{timestamp}_{index}.{extension}"
+ output_path.write_bytes(base64.b64decode(encoded))
+ saved_paths.append(output_path)
+ return saved_paths
+
+
+def anonymize_token(token: object) -> str:
+ value = str(token or "").strip()
+ if not value:
+ return "token:empty"
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:10]
+ return f"token:{digest}"
+
+
+def extract_response_prompt(input_value: object) -> str:
+ if isinstance(input_value, str):
+ return input_value.strip()
+ if isinstance(input_value, dict):
+ role = str(input_value.get("role") or "").strip().lower()
+ if role and role != "user":
+ return ""
+ return extract_prompt_from_message_content(input_value.get("content"))
+ if not isinstance(input_value, list):
+ return ""
+ prompt_parts: list[str] = []
+ for item in input_value:
+ if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_text":
+ text = str(item.get("text") or "").strip()
+ if text:
+ prompt_parts.append(text)
+ continue
+ if not isinstance(item, dict):
+ continue
+ role = str(item.get("role") or "").strip().lower()
+ if role and role != "user":
+ continue
+ prompt = extract_prompt_from_message_content(item.get("content"))
+ if prompt:
+ prompt_parts.append(prompt)
+ return "\n".join(prompt_parts).strip()
+
+
+def has_response_image_generation_tool(body: dict[str, object]) -> bool:
+ tools = body.get("tools")
+ if isinstance(tools, list):
+ for tool in tools:
+ if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
+ return True
+ tool_choice = body.get("tool_choice")
+ return isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation"
+
+
+def extract_prompt_from_message_content(content: object) -> str:
+ if isinstance(content, str):
+ return content.strip()
+ if not isinstance(content, list):
+ return ""
+ parts: list[str] = []
+ for item in content:
+ if not isinstance(item, dict):
+ continue
+ item_type = str(item.get("type") or "").strip()
+ if item_type == "text":
+ text = str(item.get("text") or "").strip()
+ if text:
+ parts.append(text)
+ elif item_type == "input_text":
+ text = str(item.get("text") or item.get("input_text") or "").strip()
+ if text:
+ parts.append(text)
+ return "\n".join(parts).strip()
+
+
+def _message_image_url(value: object) -> str:
+ if isinstance(value, dict):
+ return str(value.get("url") or value.get("image_url") or "").strip()
+ return str(value or "").strip()
+
+
+def _decode_message_image_url(value: object) -> tuple[bytes, str] | None:
+ source = _message_image_url(value)
+ if source.startswith("data:"):
+ header, _, data = source.partition(",")
+ mime = header.split(";")[0].removeprefix("data:") or "image/png"
+ return base64.b64decode(data), mime
+ if not source.startswith(("http://", "https://")):
+ return None
+ parsed = urlparse(source)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ return None
+
+ try:
+ response = requests.get(
+ source,
+ headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api vision fetcher"},
+ timeout=REMOTE_IMAGE_TIMEOUT_SECONDS,
+ allow_redirects=True,
+ **proxy_settings.build_session_kwargs(),
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
+ if not 200 <= response.status_code < 300:
+ raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
+ content_length = str(response.headers.get("content-length") or "").strip()
+ if content_length.isdigit() and int(content_length) > MAX_JSON_IMAGE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 10MB limit"})
+ image_data = response.content
+ if not image_data:
+ raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
+ if len(image_data) > MAX_JSON_IMAGE_BYTES:
+ raise HTTPException(status_code=400, detail={"error": "image_url exceeds 10MB limit"})
+ mime = str(response.headers.get("content-type") or "image/png").split(";", 1)[0].lower()
+ guessed_mime = mimetypes.guess_type(parsed.path)[0] or ""
+ if mime and not mime.startswith("image/") and mime not in {"application/octet-stream", "binary/octet-stream"}:
+ raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
+ if not mime.startswith("image/") and guessed_mime.startswith("image/"):
+ mime = guessed_mime
+ if not mime.startswith("image/"):
+ mime = "image/png"
+ return image_data, mime
+
+
+def _decode_message_image_object(item: dict[str, object]) -> tuple[bytes, str] | None:
+ data = item.get("data")
+ if isinstance(data, (bytes, bytearray)):
+ return bytes(data), str(item.get("mime") or item.get("mime_type") or "image/png")
+ for key in ("image_url", "url"):
+ image = _decode_message_image_url(item.get(key))
+ if image:
+ return image
+ value = item.get("b64_json") or item.get("base64")
+ if isinstance(value, str) and value.strip():
+ image_data, _, mime = _decode_json_image_string(
+ value,
+ 1,
+ mime_type=str(item.get("mime") or item.get("mime_type") or item.get("mimeType") or "image/png"),
+ )
+ return image_data, mime
+ source = item.get("source")
+ if isinstance(source, dict) and str(source.get("type") or "") == "base64":
+ encoded = str(source.get("data") or "")
+ mime = str(source.get("media_type") or source.get("mime_type") or "image/png")
+ image_data, _, resolved_mime = _decode_json_image_string(encoded, 1, mime_type=mime)
+ return image_data, resolved_mime
+ return None
+
+
+def extract_image_from_message_content(content: object) -> list[tuple[bytes, str]]:
+ if not isinstance(content, list):
+ return []
+ images = []
+ for item in content:
+ if not isinstance(item, dict):
+ continue
+ item_type = str(item.get("type") or "").strip()
+ if item_type == "image_url":
+ image = _decode_message_image_url(item.get("image_url") or item.get("url") or item)
+ if image:
+ images.append(image)
+ elif item_type in {"input_image", "image"}:
+ image = _decode_message_image_object(item)
+ if image:
+ images.append(image)
+ return images
+
+
+def extract_chat_image(body: dict[str, object]) -> list[tuple[bytes, str]]:
+ messages = body.get("messages")
+ if not isinstance(messages, list):
+ return []
+ for message in reversed(messages):
+ if not isinstance(message, dict):
+ continue
+ if str(message.get("role") or "").strip().lower() != "user":
+ continue
+ images = extract_image_from_message_content(message.get("content"))
+ if images:
+ return images
+ return []
+
+
+def extract_chat_prompt(body: dict[str, object]) -> str:
+ direct_prompt = str(body.get("prompt") or "").strip()
+ if direct_prompt:
+ return direct_prompt
+ messages = body.get("messages")
+ if not isinstance(messages, list):
+ return ""
+ prompt_parts: list[str] = []
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ if str(message.get("role") or "").strip().lower() != "user":
+ continue
+ prompt = extract_prompt_from_message_content(message.get("content"))
+ if prompt:
+ prompt_parts.append(prompt)
+ return "\n".join(prompt_parts).strip()
+
+
+def parse_image_count(raw_value: object) -> int:
+ try:
+ value = int(raw_value or 1)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
+ if value < 1 or value > 4:
+ raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
+ return value
+
+
+def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
+ image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
+ markdown_images: list[str] = []
+ for index, item in enumerate(image_items, start=1):
+ if not isinstance(item, dict):
+ continue
+ b64_json = str(item.get("b64_json") or "").strip()
+ if b64_json:
+ markdown_images.append(f"")
+ return "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
diff --git a/utils/image_tokens.py b/utils/image_tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ad27b20f733cac4eed34f508d9f9cdb46b37f35
--- /dev/null
+++ b/utils/image_tokens.py
@@ -0,0 +1,346 @@
+from __future__ import annotations
+
+import base64
+import math
+import re
+from io import BytesIO
+from typing import Any
+
+from PIL import Image
+
+DEFAULT_IMAGE_SIZE = (1024, 1024)
+IMAGE_INPUT_TOKEN_MODEL = "gpt-5.4-mini"
+
+PATCH_SIZE = 32
+TILE_SIZE = 512
+TILE_HIGH_SHORT_SIDE = 768
+
+PATCH_1536_MODELS = (
+ "gpt-5.4-mini",
+ "gpt-5.4-nano",
+ "gpt-5-mini",
+ "gpt-5-nano",
+ "gpt-5.2",
+ "gpt-5.3-codex",
+ "gpt-5-codex-mini",
+ "gpt-5.1-codex-mini",
+ "gpt-5.2-codex",
+ "gpt-5.2-chat-latest",
+ "o4-mini",
+ "gpt-4.1-mini",
+ "gpt-4.1-nano",
+)
+
+PATCH_MULTIPLIERS = {
+ "gpt-5.4-mini": 1.62,
+ "gpt-5.4-nano": 2.46,
+ "gpt-5-mini": 1.62,
+ "gpt-5-nano": 2.46,
+ "gpt-4.1-mini": 1.62,
+ "gpt-4.1-nano": 2.46,
+ "o4-mini": 1.72,
+}
+
+
+def _model_name(model: str) -> str:
+ return str(model or "").strip().lower()
+
+
+def image_size_from_bytes(data: bytes) -> tuple[int, int] | None:
+ if not data:
+ return None
+ try:
+ with Image.open(BytesIO(data)) as image:
+ width, height = image.size
+ except Exception:
+ return None
+ if width <= 0 or height <= 0:
+ return None
+ return int(width), int(height)
+
+
+def _decode_data_url(value: str) -> bytes:
+ text = str(value or "").strip()
+ payload = text.split(",", 1)[1] if text.startswith("data:") and "," in text else text
+ return base64.b64decode(payload)
+
+
+def image_size_from_data_url(value: str) -> tuple[int, int] | None:
+ try:
+ return image_size_from_bytes(_decode_data_url(value))
+ except Exception:
+ return None
+
+
+def parse_image_size(size: object, default: tuple[int, int] = DEFAULT_IMAGE_SIZE) -> tuple[int, int]:
+ if isinstance(size, (tuple, list)) and len(size) >= 2:
+ try:
+ width = int(size[0])
+ height = int(size[1])
+ if width > 0 and height > 0:
+ return width, height
+ except (TypeError, ValueError):
+ pass
+ match = re.search(r"(\d{2,5})\D+(\d{2,5})", str(size or ""))
+ if not match:
+ return default
+ width, height = int(match.group(1)), int(match.group(2))
+ return (width, height) if width > 0 and height > 0 else default
+
+
+def _patch_count(width: float, height: float) -> int:
+ return math.ceil(width / PATCH_SIZE) * math.ceil(height / PATCH_SIZE)
+
+
+def _patch_multiplier(model: str) -> float:
+ name = _model_name(model)
+ for prefix, multiplier in PATCH_MULTIPLIERS.items():
+ if name.startswith(prefix):
+ return multiplier
+ return 1.0
+
+
+def _patch_limits(model: str, detail: str) -> tuple[int, int] | None:
+ name = _model_name(model)
+ if any(name.startswith(prefix) for prefix in PATCH_1536_MODELS):
+ return 1536, 2048
+ if name.startswith("gpt-5.5"):
+ return (10000, 6000) if detail in {"auto", "original"} else (2500, 2048)
+ if name.startswith("gpt-5.4"):
+ return (10000, 6000) if detail == "original" else (2500, 2048)
+ return None
+
+
+def _patch_tokens(width: int, height: int, model: str, detail: str) -> int:
+ multiplier = _patch_multiplier(model)
+ if detail == "low":
+ return math.ceil(256 * multiplier)
+
+ limits = _patch_limits(model, detail)
+ if limits is None:
+ return 0
+ patch_budget, max_dimension = limits
+ scale = min(1.0, max_dimension / max(width, height))
+ resized_width = width * scale
+ resized_height = height * scale
+
+ if _patch_count(resized_width, resized_height) > patch_budget:
+ shrink_factor = math.sqrt((PATCH_SIZE * PATCH_SIZE * patch_budget) / (resized_width * resized_height))
+ width_units = resized_width * shrink_factor / PATCH_SIZE
+ height_units = resized_height * shrink_factor / PATCH_SIZE
+ adjusted_shrink_factor = shrink_factor * min(
+ math.floor(width_units) / width_units if width_units else 1,
+ math.floor(height_units) / height_units if height_units else 1,
+ )
+ resized_width *= adjusted_shrink_factor
+ resized_height *= adjusted_shrink_factor
+
+ tokens = min(_patch_count(max(1, resized_width), max(1, resized_height)), patch_budget)
+ return math.ceil(tokens * multiplier)
+
+
+def _tile_rates(model: str) -> tuple[int, int]:
+ name = _model_name(model)
+ if name in {"gpt-5", "gpt-5-chat-latest"}:
+ return 70, 140
+ if name.startswith("gpt-4o-mini"):
+ return 2833, 5667
+ if name.startswith(("o1", "o1-pro", "o3")):
+ return 75, 150
+ if name.startswith("computer-use-preview"):
+ return 65, 129
+ return 85, 170
+
+
+def _tile_tokens(width: int, height: int, model: str, detail: str) -> int:
+ base_tokens, tile_tokens = _tile_rates(model)
+ if detail == "low":
+ return base_tokens
+
+ scale = min(1.0, 2048 / max(width, height))
+ resized_width = width * scale
+ resized_height = height * scale
+ short_side = min(resized_width, resized_height)
+ if short_side > 0:
+ scale = TILE_HIGH_SHORT_SIDE / short_side
+ resized_width *= scale
+ resized_height *= scale
+
+ tiles = math.ceil(resized_width / TILE_SIZE) * math.ceil(resized_height / TILE_SIZE)
+ return base_tokens + tiles * tile_tokens
+
+
+def count_image_input_tokens(
+ width: int,
+ height: int,
+ model: str,
+ detail: str = "auto",
+ input_fidelity: str = "low",
+) -> int:
+ if width <= 0 or height <= 0:
+ return 0
+ detail = str(detail or "auto").strip().lower() or "auto"
+ return _patch_tokens(width, height, IMAGE_INPUT_TOKEN_MODEL, detail)
+
+
+def _part_size(part: dict[str, Any]) -> tuple[int, int] | None:
+ try:
+ width = int(part.get("width") or 0)
+ height = int(part.get("height") or 0)
+ except (TypeError, ValueError):
+ width = height = 0
+ if width > 0 and height > 0:
+ return width, height
+
+ data = part.get("data")
+ if isinstance(data, (bytes, bytearray)):
+ return image_size_from_bytes(bytes(data))
+
+ image_url = part.get("image_url")
+ if isinstance(image_url, dict):
+ image_url = image_url.get("url") or image_url.get("image_url")
+ if isinstance(image_url, str) and image_url.startswith("data:"):
+ return image_size_from_data_url(image_url)
+
+ source = part.get("source")
+ if isinstance(source, dict) and str(source.get("type") or "") == "base64":
+ try:
+ return image_size_from_bytes(base64.b64decode(str(source.get("data") or "")))
+ except Exception:
+ return None
+ return None
+
+
+def count_image_content_tokens(content: object, model: str, default_detail: str = "auto") -> int:
+ if not isinstance(content, list):
+ return 0
+ total = 0
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+ part_type = str(part.get("type") or "").strip()
+ if part_type not in {"image", "image_url", "input_image"} and not part.get("source"):
+ continue
+ size = _part_size(part)
+ if not size:
+ continue
+ total += count_image_input_tokens(
+ size[0],
+ size[1],
+ model,
+ str(part.get("detail") or default_detail or "auto"),
+ str(part.get("input_fidelity") or part.get("inputFidelity") or "low"),
+ )
+ return total
+
+
+def count_image_inputs_tokens(images: object, model: str, default_detail: str = "auto") -> int:
+ if not images:
+ return 0
+ total = 0
+ entries = images if isinstance(images, list) else [images]
+ for image in entries:
+ data = image[0] if isinstance(image, tuple) and image else image
+ if not isinstance(data, (bytes, bytearray)):
+ continue
+ size = image_size_from_bytes(bytes(data))
+ if size:
+ total += count_image_input_tokens(size[0], size[1], model, default_detail)
+ return total
+
+
+def count_generated_image_tokens(width: int, height: int, quality: str = "auto") -> int:
+ patches = _patch_count(width, height)
+ quality = str(quality or "auto").strip().lower()
+ if quality == "low":
+ return math.ceil(patches * 17 / 64)
+ if quality in {"high", "hd"}:
+ return math.ceil(patches * 65 / 16)
+ return math.ceil(patches * 33 / 32)
+
+
+def count_image_output_tokens(size: object = None, quality: str = "auto", count: int = 1) -> int:
+ width, height = parse_image_size(size)
+ return max(0, int(count or 0)) * count_generated_image_tokens(width, height, quality)
+
+
+def count_image_output_items_tokens(
+ items: object,
+ size: object = None,
+ quality: str = "auto",
+) -> int:
+ if not isinstance(items, list) or not items:
+ return 0
+ fallback_size = parse_image_size(size)
+ total = 0
+ for item in items:
+ image_size = None
+ if isinstance(item, dict):
+ b64_json = str(item.get("b64_json") or "").strip()
+ if b64_json:
+ try:
+ image_size = image_size_from_bytes(base64.b64decode(b64_json))
+ except Exception:
+ image_size = None
+ width, height = image_size or fallback_size
+ total += count_generated_image_tokens(width, height, quality)
+ return total
+
+
+def token_usage(
+ input_text_tokens: int = 0,
+ input_image_tokens: int = 0,
+ output_text_tokens: int = 0,
+ output_image_tokens: int = 0,
+) -> dict[str, Any]:
+ input_tokens = max(0, int(input_text_tokens or 0)) + max(0, int(input_image_tokens or 0))
+ output_tokens = max(0, int(output_text_tokens or 0)) + max(0, int(output_image_tokens or 0))
+ return {
+ "input_tokens": input_tokens,
+ "output_tokens": output_tokens,
+ "total_tokens": input_tokens + output_tokens,
+ "input_tokens_details": {
+ "text_tokens": max(0, int(input_text_tokens or 0)),
+ "image_tokens": max(0, int(input_image_tokens or 0)),
+ "cached_tokens": 0,
+ },
+ "output_tokens_details": {
+ "text_tokens": max(0, int(output_text_tokens or 0)),
+ "image_tokens": max(0, int(output_image_tokens or 0)),
+ "reasoning_tokens": 0,
+ },
+ }
+
+
+def image_usage(
+ input_text_tokens: int = 0,
+ input_image_tokens: int = 0,
+ output_tokens: int = 0,
+) -> dict[str, Any]:
+ return token_usage(
+ input_text_tokens=input_text_tokens,
+ input_image_tokens=input_image_tokens,
+ output_image_tokens=output_tokens,
+ )
+
+
+def chat_usage_from_image_usage(usage: dict[str, Any]) -> dict[str, Any]:
+ input_tokens = int(usage.get("input_tokens") or 0)
+ output_tokens = int(usage.get("output_tokens") or 0)
+ input_details = usage.get("input_tokens_details") if isinstance(usage.get("input_tokens_details"), dict) else {}
+ output_details = usage.get("output_tokens_details") if isinstance(usage.get("output_tokens_details"), dict) else {}
+ return {
+ "prompt_tokens": input_tokens,
+ "completion_tokens": output_tokens,
+ "total_tokens": input_tokens + output_tokens,
+ "prompt_tokens_details": {
+ "text_tokens": int(input_details.get("text_tokens") or 0),
+ "image_tokens": int(input_details.get("image_tokens") or 0),
+ "cached_tokens": int(input_details.get("cached_tokens") or 0),
+ },
+ "completion_tokens_details": {
+ "text_tokens": int(output_details.get("text_tokens") or 0),
+ "image_tokens": int(output_details.get("image_tokens") or 0),
+ "reasoning_tokens": int(output_details.get("reasoning_tokens") or 0),
+ },
+ }
diff --git a/utils/log.py b/utils/log.py
new file mode 100644
index 0000000000000000000000000000000000000000..5532dde6c7493af19bb29e055b1e8dceef60a6ee
--- /dev/null
+++ b/utils/log.py
@@ -0,0 +1,110 @@
+import base64
+import binascii
+import json
+import logging
+import re
+from typing import Any
+
+
+class Logger:
+ _DATA_URL_RE = re.compile(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+")
+ _JSON_B64_RE = re.compile(r'("b64_json"\s*:\s*")([A-Za-z0-9+/=]+)(")')
+
+ def __init__(self, name: str = "chatgpt2api") -> None:
+ self._logger = logging.getLogger(name)
+ if not self._logger.handlers:
+ handler = logging.StreamHandler()
+ handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
+ self._logger.addHandler(handler)
+ self._logger.setLevel(logging.DEBUG)
+ self._logger.propagate = False
+
+ def _enabled(self, level: str) -> bool:
+ try:
+ from services.config import config
+ levels = set(config.log_levels)
+ except Exception:
+ levels = set()
+ return level in (levels or {"info", "warning", "error"})
+
+ def _mask_string(self, value: str, keep: int = 10) -> str:
+ if len(value) <= keep:
+ return value
+ return value[:keep] + "..."
+
+ def _mask_base64(self, value: str) -> str:
+ if value.startswith("data:") and ";base64," in value:
+ header, _, data = value.partition(",")
+ return f"{header},{self._mask_string(data, 24)} (base64 len={len(data)})"
+ return f"{self._mask_string(value, 24)} (base64 len={len(value)})"
+
+ def _is_base64_string(self, value: str) -> bool:
+ if len(value) < 64 or len(value) % 4 != 0:
+ return False
+ if not any(char in value for char in "+/="):
+ return False
+ try:
+ base64.b64decode(value, validate=True)
+ return True
+ except (binascii.Error, ValueError):
+ return False
+
+ def _sanitize_string(self, value: str) -> str:
+ stripped = value.strip()
+ if stripped.startswith("data:") and ";base64," in stripped:
+ return self._mask_base64(stripped)
+ if self._is_base64_string(stripped):
+ return self._mask_base64(stripped)
+ sanitized = self._DATA_URL_RE.sub(lambda match: self._mask_base64(match.group(0)), value)
+ sanitized = self._JSON_B64_RE.sub(
+ lambda match: f'{match.group(1)}{self._mask_base64(match.group(2))}{match.group(3)}',
+ sanitized,
+ )
+ if sanitized != value:
+ return sanitized
+ return value
+
+ def _sanitize(self, value: Any) -> Any:
+ if isinstance(value, dict):
+ sanitized = {}
+ for key, item in value.items():
+ lowered_key = key.lower()
+ if isinstance(item, str) and ("token" in lowered_key or lowered_key == "dx"):
+ sanitized[key] = self._mask_string(item)
+ elif isinstance(item, str) and ("base64" in lowered_key or lowered_key == "b64_json"):
+ sanitized[key] = self._mask_base64(item)
+ else:
+ sanitized[key] = self._sanitize(item)
+ return sanitized
+ if isinstance(value, list):
+ return [self._sanitize(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(self._sanitize(item) for item in value)
+ if isinstance(value, str):
+ return self._sanitize_string(value)
+ return value
+
+ def _message(self, value: Any) -> str:
+ sanitized = self._sanitize(value)
+ if isinstance(sanitized, str):
+ return sanitized
+ return json.dumps(sanitized, ensure_ascii=False, default=str)
+
+ def debug(self, message: Any) -> None:
+ if self._enabled("debug"):
+ self._logger.debug(self._message(message))
+
+ def info(self, message: Any) -> None:
+ if self._enabled("info"):
+ self._logger.info(self._message(message))
+
+ def warning(self, message: Any) -> None:
+ if self._enabled("warning"):
+ self._logger.warning(self._message(message))
+
+ def error(self, message: Any) -> None:
+ if self._enabled("error"):
+ self._logger.error(self._message(message))
+
+
+logger = Logger()
diff --git a/utils/pkce.py b/utils/pkce.py
new file mode 100644
index 0000000000000000000000000000000000000000..526ba59373d8d051f25a5bd5b7044a2b5767c52a
--- /dev/null
+++ b/utils/pkce.py
@@ -0,0 +1,19 @@
+"""PKCE (Proof Key for Code Exchange) 工具函数"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import secrets
+
+
+def generate_pkce() -> tuple[str, str]:
+ """生成 PKCE code_verifier 与对应的 code_challenge(S256)。
+
+ Returns:
+ (code_verifier, code_challenge) 元组
+ """
+ code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
+ code_challenge = base64.urlsafe_b64encode(
+ hashlib.sha256(code_verifier.encode("ascii")).digest()
+ ).rstrip(b"=").decode("ascii")
+ return code_verifier, code_challenge
diff --git a/utils/pow.py b/utils/pow.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcbbb90b3fc4fff695f1dfb5fe58bd069c46ba30
--- /dev/null
+++ b/utils/pow.py
@@ -0,0 +1,204 @@
+import hashlib
+import json
+import random
+import re
+import time
+from datetime import datetime, timedelta, timezone
+from html.parser import HTMLParser
+from typing import Any, Sequence
+
+import pybase64
+
+DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
+from utils.helper import new_uuid
+
+
+CORES = [8, 16, 24, 32]
+DOCUMENT_KEYS = ["_reactListeningo743lnnpvdg", "location"]
+
+
+class ScriptSrcParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.script_sources: list[str] = []
+ self.data_build = ""
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag != "script":
+ return
+ attrs_dict = dict(attrs)
+ src = attrs_dict.get("src")
+ if not src:
+ return
+ self.script_sources.append(src)
+ match = re.search(r"c/[^/]*/_", src)
+ if match:
+ self.data_build = match.group(0)
+
+
+def parse_pow_resources(html_content: str) -> tuple[list[str], str]:
+ parser = ScriptSrcParser()
+ parser.feed(html_content)
+ script_sources = parser.script_sources or [DEFAULT_POW_SCRIPT]
+ data_build = parser.data_build
+ if not data_build:
+ match = re.search(r']*data-build="([^"]*)"', html_content)
+ if match:
+ data_build = match.group(1)
+ return script_sources, data_build
+
+
+def _legacy_parse_time() -> str:
+ now = datetime.now(timezone(timedelta(hours=-5)))
+ return now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)"
+
+
+def build_pow_config(
+ user_agent: str,
+ script_sources: Sequence[str] | None = None,
+ data_build: str = "",
+) -> list[Any]:
+ navigator_key = random.choice([
+ "registerProtocolHandler−function registerProtocolHandler() { [native code] }",
+ "storage−[object StorageManager]",
+ "locks−[object LockManager]",
+ "appCodeName−Mozilla",
+ "permissions−[object Permissions]",
+ "share−function share() { [native code] }",
+ "webdriver−false",
+ "managed−[object NavigatorManagedData]",
+ "canShare−function canShare() { [native code] }",
+ "vendor−Google Inc.",
+ "mediaDevices−[object MediaDevices]",
+ "vibrate−function vibrate() { [native code] }",
+ "storageBuckets−[object StorageBucketManager]",
+ "mediaCapabilities−[object MediaCapabilities]",
+ "cookieEnabled−true",
+ "virtualKeyboard−[object VirtualKeyboard]",
+ "product−Gecko",
+ "presentation−[object Presentation]",
+ "onLine−true",
+ "mimeTypes−[object MimeTypeArray]",
+ "credentials−[object CredentialsContainer]",
+ "serviceWorker−[object ServiceWorkerContainer]",
+ "keyboard−[object Keyboard]",
+ "gpu−[object GPU]",
+ "doNotTrack",
+ "serial−[object Serial]",
+ "pdfViewerEnabled−true",
+ "language−zh-CN",
+ "geolocation−[object Geolocation]",
+ "userAgentData−[object NavigatorUAData]",
+ "getUserMedia−function getUserMedia() { [native code] }",
+ "sendBeacon−function sendBeacon() { [native code] }",
+ "hardwareConcurrency−32",
+ "windowControlsOverlay−[object WindowControlsOverlay]",
+ ])
+ window_key = random.choice([
+ "0",
+ "window",
+ "self",
+ "document",
+ "name",
+ "location",
+ "customElements",
+ "history",
+ "navigation",
+ "innerWidth",
+ "innerHeight",
+ "scrollX",
+ "scrollY",
+ "visualViewport",
+ "screenX",
+ "screenY",
+ "outerWidth",
+ "outerHeight",
+ "devicePixelRatio",
+ "screen",
+ "chrome",
+ "navigator",
+ "onresize",
+ "performance",
+ "crypto",
+ "indexedDB",
+ "sessionStorage",
+ "localStorage",
+ "scheduler",
+ "alert",
+ "atob",
+ "btoa",
+ "fetch",
+ "matchMedia",
+ "postMessage",
+ "queueMicrotask",
+ "requestAnimationFrame",
+ "setInterval",
+ "setTimeout",
+ "caches",
+ "__NEXT_DATA__",
+ "__BUILD_MANIFEST",
+ "__NEXT_PRELOADREADY",
+ ])
+ script_source = random.choice(list(script_sources)) if script_sources else DEFAULT_POW_SCRIPT
+ return [
+ random.choice([3000, 4000, 5000]),
+ _legacy_parse_time(),
+ 4294705152,
+ 0,
+ user_agent,
+ script_source,
+ data_build,
+ "en-US",
+ "en-US,es-US,en,es",
+ 0,
+ navigator_key,
+ random.choice(DOCUMENT_KEYS),
+ window_key,
+ time.perf_counter() * 1000,
+ new_uuid(),
+ "",
+ random.choice(CORES),
+ time.time() * 1000 - (time.perf_counter() * 1000),
+ ]
+
+
+def _pow_generate(seed: str, difficulty: str, config: list[Any], limit: int = 500000) -> tuple[str, bool]:
+ target = bytes.fromhex(difficulty)
+ diff_len = len(difficulty) // 2
+ seed_bytes = seed.encode()
+ static_1 = (json.dumps(config[:3], separators=(",", ":"), ensure_ascii=False)[:-1] + ",").encode()
+ static_2 = ("," + json.dumps(config[4:9], separators=(",", ":"), ensure_ascii=False)[1:-1] + ",").encode()
+ static_3 = ("," + json.dumps(config[10:], separators=(",", ":"), ensure_ascii=False)[1:]).encode()
+ for i in range(limit):
+ final_json = static_1 + str(i).encode() + static_2 + str(i >> 1).encode() + static_3
+ encoded = pybase64.b64encode(final_json)
+ digest = hashlib.sha3_512(seed_bytes + encoded).digest()
+ if digest[:diff_len] <= target:
+ return encoded.decode(), True
+ fallback = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + pybase64.b64encode(f'"{seed}"'.encode()).decode()
+ return fallback, False
+
+
+def build_legacy_requirements_token(
+ user_agent: str,
+ script_sources: Sequence[str] | None = None,
+ data_build: str = "",
+) -> str:
+ seed = format(random.random())
+ config = build_pow_config(user_agent, script_sources=script_sources, data_build=data_build)
+ answer, _ = _pow_generate(seed, "0fffff", config)
+ return "gAAAAAC" + answer
+
+
+def build_proof_token(
+ seed: str,
+ difficulty: str,
+ user_agent: str,
+ script_sources: Sequence[str] | None = None,
+ data_build: str = "",
+) -> str:
+ config = build_pow_config(user_agent, script_sources=script_sources, data_build=data_build)
+ answer, solved = _pow_generate(seed, difficulty, config)
+ if not solved:
+ raise RuntimeError(f"failed to solve proof token: difficulty={difficulty}")
+ return "gAAAAAB" + answer
diff --git a/utils/sentinel.py b/utils/sentinel.py
new file mode 100644
index 0000000000000000000000000000000000000000..926b9f0b0b91cb94b16d23c4fcc27aaecb8c4f4f
--- /dev/null
+++ b/utils/sentinel.py
@@ -0,0 +1,159 @@
+"""OpenAI Sentinel Token (PoW) 生成与请求工具函数。
+
+用于密码登录、注册等需要 sentinel token 的流程。
+"""
+from __future__ import annotations
+
+import base64
+import json
+import random
+import time
+import uuid
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from curl_cffi.requests import Session
+
+
+class SentinelTokenGenerator:
+ """Sentinel Token 生成器(PoW - Proof of Work)。"""
+ MAX_ATTEMPTS = 500_000
+ ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
+
+ def __init__(self, device_id: str, ua: str):
+ self.device_id = device_id
+ self.user_agent = ua
+ self.sid = str(uuid.uuid4())
+
+ @staticmethod
+ def _fnv1a_32(text: str) -> str:
+ h = 2166136261
+ for ch in text:
+ h ^= ord(ch)
+ h = (h * 16777619) & 0xFFFFFFFF
+ h ^= h >> 16
+ h = (h * 2246822507) & 0xFFFFFFFF
+ h ^= h >> 13
+ h = (h * 3266489909) & 0xFFFFFFFF
+ h ^= h >> 16
+ return format(h & 0xFFFFFFFF, "08x")
+
+ def _get_config(self) -> list:
+ perf_now = random.uniform(1000, 50000)
+ return [
+ "1920x1080",
+ time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()),
+ 4294705152,
+ random.random(),
+ self.user_agent,
+ "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js",
+ None,
+ None,
+ "en-US",
+ random.random(),
+ random.choice(["vendorSub-undefined", "plugins-undefined", "mimeTypes-undefined", "hardwareConcurrency-undefined"]),
+ random.choice(["location", "implementation", "URL", "documentURI", "compatMode"]),
+ random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"]),
+ perf_now,
+ self.sid,
+ "",
+ random.choice([4, 8, 12, 16]),
+ time.time() * 1000 - perf_now,
+ ]
+
+ @staticmethod
+ def _b64(data) -> str:
+ return base64.b64encode(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).decode("ascii")
+
+ def generate_requirements_token(self) -> str:
+ data = self._get_config()
+ data[3] = 1
+ data[9] = round(random.uniform(5, 50))
+ return "gAAAAAC" + self._b64(data)
+
+ def generate_token(self, seed: str, difficulty: str) -> str:
+ start = time.time()
+ data = self._get_config()
+ difficulty = str(difficulty or "0")
+ for i in range(self.MAX_ATTEMPTS):
+ data[3] = i
+ data[9] = round((time.time() - start) * 1000)
+ payload = self._b64(data)
+ if self._fnv1a_32(seed + payload)[: len(difficulty)] <= difficulty:
+ return "gAAAAAB" + payload + "~S"
+ return "gAAAAAB" + self.ERROR_PREFIX + self._b64(str(None))
+
+
+# ── 默认 User-Agent 和 sec-ch-ua ──────────────────────────────
+DEFAULT_SENTINEL_USER_AGENT = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/145.0.0.0 Safari/537.36"
+)
+DEFAULT_SENTINEL_SEC_CH_UA = '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"'
+
+
+def build_sentinel_token(
+ session: "Session",
+ device_id: str,
+ flow: str,
+ *,
+ user_agent: str = "",
+ sec_ch_ua: str = "",
+) -> tuple[str, str]:
+ """请求 sentinel token 并返回 (sentinel_header_value, oai_sc_cookie_value)。
+
+ Args:
+ session: curl_cffi Session 实例
+ device_id: 设备 ID
+ flow: 流程标识(如 "password_verify", "username_password_create" 等)
+ user_agent: 可选的 User-Agent 覆盖
+ sec_ch_ua: 可选的 sec-ch-ua 覆盖
+
+ Returns:
+ (openai-sentinel-token header value, oai-sc cookie value) 元组
+
+ Raises:
+ RuntimeError: sentinel 请求失败
+ """
+ ua = user_agent or DEFAULT_SENTINEL_USER_AGENT
+ ch_ua = sec_ch_ua or DEFAULT_SENTINEL_SEC_CH_UA
+ generator = SentinelTokenGenerator(device_id, ua)
+ resp = session.post(
+ "https://sentinel.openai.com/backend-api/sentinel/req",
+ data=json.dumps({"p": generator.generate_requirements_token(), "id": device_id, "flow": flow}),
+ headers={
+ "Content-Type": "text/plain;charset=UTF-8",
+ "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html",
+ "Origin": "https://sentinel.openai.com",
+ "User-Agent": ua,
+ "sec-ch-ua": ch_ua,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ },
+ timeout=20,
+ verify=False,
+ )
+
+ try:
+ data = resp.json() if resp.text else {}
+ except Exception:
+ fallback = json.dumps(
+ {"p": generator.generate_requirements_token(), "t": "", "c": "", "id": device_id, "flow": flow},
+ separators=(",", ":"),
+ )
+ return fallback, ""
+
+ token = str(data.get("token") or "").strip()
+ if resp.status_code != 200 or not token:
+ raise RuntimeError(f"sentinel_req_failed_{resp.status_code}")
+ pow_data = data.get("proofofwork") or {}
+ p_value = (
+ generator.generate_token(str(pow_data.get("seed") or ""), str(pow_data.get("difficulty") or "0"))
+ if pow_data.get("required") and pow_data.get("seed")
+ else generator.generate_requirements_token()
+ )
+ sentinel_value = json.dumps({"p": p_value, "t": "", "c": token, "id": device_id, "flow": flow}, separators=(",", ":"))
+ # oai-sc cookie = "0" + sentinel token "c" value (the challenge token from the server)
+ oai_sc_value = "0" + token
+ return sentinel_value, oai_sc_value
diff --git a/utils/turnstile.py b/utils/turnstile.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed2ce7ef83e76e1daac3cb3ae62b95597f8417fc
--- /dev/null
+++ b/utils/turnstile.py
@@ -0,0 +1,184 @@
+import base64
+import json
+import random
+import time
+from typing import Any, Dict, Optional
+
+
+class OrderedMap:
+ def __init__(self) -> None:
+ self.keys = []
+ self.values = {}
+
+ def add(self, key: str, value: Any) -> None:
+ if key not in self.values:
+ self.keys.append(key)
+ self.values[key] = value
+
+
+def _turnstile_to_str(value: Any) -> str:
+ if value is None:
+ return "undefined"
+ if isinstance(value, float):
+ return str(value)
+ if isinstance(value, str):
+ special = {
+ "window.Math": "[object Math]",
+ "window.Reflect": "[object Reflect]",
+ "window.performance": "[object Performance]",
+ "window.localStorage": "[object Storage]",
+ "window.Object": "function Object() { [native code] }",
+ "window.Reflect.set": "function set() { [native code] }",
+ "window.performance.now": "function () { [native code] }",
+ "window.Object.create": "function create() { [native code] }",
+ "window.Object.keys": "function keys() { [native code] }",
+ "window.Math.random": "function random() { [native code] }",
+ }
+ return special.get(value, value)
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
+ return ",".join(value)
+ return str(value)
+
+
+def _xor_string(text: str, key: str) -> str:
+ if not key:
+ return text
+ return "".join(chr(ord(ch) ^ ord(key[i % len(key)])) for i, ch in enumerate(text))
+
+
+def solve_turnstile_token(dx: str, p: str) -> Optional[str]:
+ try:
+ decoded = base64.b64decode(dx).decode()
+ token_list = json.loads(_xor_string(decoded, p))
+ except Exception:
+ return None
+
+ process_map: Dict[Any, Any] = {}
+ start_time = time.time()
+ result = ""
+
+ def func_1(e: float, t: float) -> None:
+ process_map[e] = _xor_string(_turnstile_to_str(process_map[e]), _turnstile_to_str(process_map[t]))
+
+ def func_2(e: float, t: Any) -> None:
+ process_map[e] = t
+
+ def func_3(e: str) -> None:
+ nonlocal result
+ result = base64.b64encode(e.encode()).decode()
+
+ def func_5(e: float, t: float) -> None:
+ current = process_map[e]
+ incoming = process_map[t]
+ if isinstance(current, (list, tuple)):
+ process_map[e] = list(current) + [incoming]
+ return
+ if isinstance(current, (str, float)) or isinstance(incoming, (str, float)):
+ process_map[e] = _turnstile_to_str(current) + _turnstile_to_str(incoming)
+ return
+ process_map[e] = "NaN"
+
+ def func_6(e: float, t: float, n: float) -> None:
+ tv = process_map[t]
+ nv = process_map[n]
+ if isinstance(tv, str) and isinstance(nv, str):
+ value = f"{tv}.{nv}"
+ process_map[e] = "https://chatgpt.com/" if value == "window.document.location" else value
+
+ def func_7(e: float, *args: float) -> None:
+ target = process_map[e]
+ values = [process_map[arg] for arg in args]
+ if isinstance(target, str) and target == "window.Reflect.set":
+ obj, key_name, val = values
+ obj.add(str(key_name), val)
+ elif callable(target):
+ target(*values)
+
+ def func_8(e: float, t: float) -> None:
+ process_map[e] = process_map[t]
+
+ def func_14(e: float, t: float) -> None:
+ process_map[e] = json.loads(process_map[t])
+
+ def func_15(e: float, t: float) -> None:
+ process_map[e] = json.dumps(process_map[t])
+
+ def func_17(e: float, t: float, *args: float) -> None:
+ call_args = [process_map[arg] for arg in args]
+ target = process_map[t]
+ if target == "window.performance.now":
+ elapsed_ns = time.time_ns() - int(start_time * 1e9)
+ process_map[e] = (elapsed_ns + random.random()) / 1e6
+ elif target == "window.Object.create":
+ process_map[e] = OrderedMap()
+ elif target == "window.Object.keys":
+ if call_args and call_args[0] == "window.localStorage":
+ process_map[e] = [
+ "STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
+ "STATSIG_LOCAL_STORAGE_STABLE_ID",
+ "client-correlated-secret",
+ "oai/apps/capExpiresAt",
+ "oai-did",
+ "STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
+ "UiState.isNavigationCollapsed.1",
+ ]
+ elif target == "window.Math.random":
+ process_map[e] = random.random()
+ elif callable(target):
+ process_map[e] = target(*call_args)
+
+ def func_18(e: float) -> None:
+ process_map[e] = base64.b64decode(_turnstile_to_str(process_map[e])).decode()
+
+ def func_19(e: float) -> None:
+ process_map[e] = base64.b64encode(_turnstile_to_str(process_map[e]).encode()).decode()
+
+ def func_20(e: float, t: float, n: float, *args: float) -> None:
+ if process_map[e] == process_map[t]:
+ target = process_map[n]
+ if callable(target):
+ target(*[process_map[arg] for arg in args])
+
+ def func_21(*_: Any) -> None:
+ return
+
+ def func_23(e: float, t: float, *args: float) -> None:
+ if process_map[e] is not None and callable(process_map[t]):
+ process_map[t](*args)
+
+ def func_24(e: float, t: float, n: float) -> None:
+ tv = process_map[t]
+ nv = process_map[n]
+ if isinstance(tv, str) and isinstance(nv, str):
+ process_map[e] = f"{tv}.{nv}"
+
+ process_map.update({
+ 1: func_1,
+ 2: func_2,
+ 3: func_3,
+ 5: func_5,
+ 6: func_6,
+ 7: func_7,
+ 8: func_8,
+ 9: token_list,
+ 10: "window",
+ 14: func_14,
+ 15: func_15,
+ 16: p,
+ 17: func_17,
+ 18: func_18,
+ 19: func_19,
+ 20: func_20,
+ 21: func_21,
+ 23: func_23,
+ 24: func_24,
+ })
+
+ for token in token_list:
+ try:
+ fn = process_map.get(token[0])
+ if callable(fn):
+ fn(*token[1:])
+ except Exception:
+ continue
+ return result or None
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000000000000000000000000000000000000..4face99fcbc955f1cdb30e923dd2a2c5be65b2d7
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,904 @@
+version = 1
+revision = 2
+requires-python = ">=3.13"
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d" },
+]
+
+[[package]]
+name = "chatgpt2api"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "curl-cffi" },
+ { name = "fastapi" },
+ { name = "gitpython" },
+ { name = "pillow" },
+ { name = "psycopg2-binary" },
+ { name = "pybase64" },
+ { name = "python-multipart" },
+ { name = "sqlalchemy" },
+ { name = "tiktoken" },
+ { name = "uvicorn" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "httpx" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "curl-cffi", specifier = ">=0.15.0" },
+ { name = "fastapi", specifier = ">=0.136.0" },
+ { name = "gitpython", specifier = ">=3.1.0" },
+ { name = "pillow", specifier = ">=12.2.0" },
+ { name = "psycopg2-binary", specifier = ">=2.9.0" },
+ { name = "pybase64", specifier = ">=1.4.3" },
+ { name = "python-multipart", specifier = ">=0.0.26" },
+ { name = "sqlalchemy", specifier = ">=2.0.0" },
+ { name = "tiktoken", specifier = ">=0.12.0" },
+ { name = "uvicorn", specifier = ">=0.44.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "httpx", specifier = ">=0.28.1" }]
+
+[[package]]
+name = "click"
+version = "8.3.2"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
+]
+
+[[package]]
+name = "curl-cffi"
+version = "0.15.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "cffi" },
+ { name = "rich" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.136.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4" },
+]
+
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.47"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.4.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.2.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb" },
+]
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.12"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980" },
+]
+
+[[package]]
+name = "pybase64"
+version = "1.4.3"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.2"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.2"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.26"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.4.4"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" },
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.3"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f" },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.49"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.0.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b" },
+]
+
+[[package]]
+name = "tiktoken"
+version = "0.12.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "regex" },
+ { name = "requests" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.44.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89" },
+]
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..e19b9356c83a4a99d720e27a773d01761b70377e
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+.idea
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/web/.npmrc b/web/.npmrc
new file mode 100644
index 0000000000000000000000000000000000000000..b7425b9ee858205989c4bb03c6b447948e22c55f
--- /dev/null
+++ b/web/.npmrc
@@ -0,0 +1 @@
+enable-pre-post-scripts=true
\ No newline at end of file
diff --git a/web/bun.lock b/web/bun.lock
new file mode 100644
index 0000000000000000000000000000000000000000..6ddcc3573b47efd43f7f1b97f4e1f461ae561d1a
--- /dev/null
+++ b/web/bun.lock
@@ -0,0 +1,1501 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 0,
+ "workspaces": {
+ "": {
+ "name": "next-starter",
+ "dependencies": {
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.3",
+ "axios": "^1.9.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "immer": "^10.1.3",
+ "localforage": "^1.10.0",
+ "lucide-react": "^1.17.0",
+ "motion": "^12.38.0",
+ "next": "16.2.3",
+ "radix-ui": "^1.4.3",
+ "react": "19.2.5",
+ "react-day-picker": "^9.14.0",
+ "react-dom": "19.2.5",
+ "react-hook-form": "^7.62.0",
+ "react-markdown": "^10.1.0",
+ "react-medium-image-zoom": "^5.3.0",
+ "remark-gfm": "^4.0.1",
+ "sonner": "^2.0.6",
+ "tailwind-merge": "^3.3.1",
+ "zustand": "^5.0.8",
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "19.1.12",
+ "@types/react-dom": "19.1.9",
+ "@umijs/openapi": "^1.13.15",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.3",
+ "eslint-config-prettier": "^10.1.8",
+ "prettier-plugin-organize-imports": "^4.2.0",
+ "prettier-plugin-tailwindcss": "^0.6.14",
+ "tailwindcss": "^4",
+ "tw-animate-css": "^1.3.4",
+ "typescript": "^5",
+ },
+ },
+ },
+ "overrides": {
+ "@types/react": "19.1.12",
+ "@types/react-dom": "19.1.9",
+ },
+ "packages": {
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
+
+ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
+
+ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
+
+ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
+
+ "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
+
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+
+ "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
+
+ "@emnapi/core": ["@emnapi/core@1.4.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
+
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
+
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.20.1", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.20.1.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="],
+
+ "@eslint/core": ["@eslint/core@0.14.0", "https://registry.npmmirror.com/@eslint/core/-/core-0.14.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="],
+
+ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
+
+ "@eslint/js": ["@eslint/js@9.29.0", "https://registry.npmmirror.com/@eslint/js/-/js-9.29.0.tgz", {}, "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="],
+
+ "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "https://registry.npmmirror.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="],
+
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+
+ "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.6", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.6.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+
+ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="],
+
+ "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
+
+ "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.3", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA=="],
+
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
+
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
+
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
+
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
+
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
+
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
+
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
+
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
+
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
+ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "https://registry.npmmirror.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
+
+ "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+
+ "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
+
+ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "https://registry.npmmirror.com/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
+
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
+
+ "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
+
+ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
+
+ "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
+
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "https://registry.npmmirror.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+
+ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "https://registry.npmmirror.com/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
+ "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "https://registry.npmmirror.com/@radix-ui/react-form/-/react-form-0.1.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
+
+ "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+
+ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
+
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+
+ "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "https://registry.npmmirror.com/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
+
+ "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "https://registry.npmmirror.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
+
+ "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "https://registry.npmmirror.com/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
+
+ "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "https://registry.npmmirror.com/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
+
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
+ "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
+
+ "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "https://registry.npmmirror.com/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
+
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
+
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.2.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
+
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
+
+ "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "https://registry.npmmirror.com/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
+
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
+
+ "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
+
+ "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
+
+ "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
+
+ "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+
+ "@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
+
+ "@swc/helpers": ["@swc/helpers@0.5.15", "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
+
+ "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "https://registry.npmmirror.com/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.1.10", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.10.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.10.tgz", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", { "os": "android", "cpu": "arm64" }, "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", { "os": "linux", "cpu": "arm" }, "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", { "os": "linux", "cpu": "x64" }, "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", { "os": "linux", "cpu": "x64" }, "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", { "cpu": "none" }, "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.10", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", { "os": "win32", "cpu": "x64" }, "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA=="],
+
+ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.10.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="],
+
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
+
+ "@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+ "@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+ "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
+ "@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
+ "@types/node": ["@types/node@20.19.1", "https://registry.npmmirror.com/@types/node/-/node-20.19.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
+
+ "@types/react": ["@types/react@19.1.12", "https://registry.npmmirror.com/@types/react/-/react-19.1.12.tgz", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
+
+ "@types/react-dom": ["@types/react-dom@19.1.9", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.1.9.tgz", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
+
+ "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
+
+ "@umijs/openapi": ["@umijs/openapi@1.13.15", "https://registry.npmmirror.com/@umijs/openapi/-/openapi-1.13.15.tgz", { "dependencies": { "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "dayjs": "^1.10.3", "glob": "^7.1.6", "lodash": "^4.17.21", "memoizee": "^0.4.15", "mock.js": "^0.2.0", "mockjs": "^1.1.0", "node-fetch": "^2.6.1", "number-to-words": "^1.2.4", "nunjucks": "^3.2.2", "openapi3-ts": "^2.0.1", "prettier": "^2.2.1", "reserved-words": "^0.1.2", "rimraf": "^3.0.2", "swagger2openapi": "^7.0.4", "tiny-pinyin": "^1.3.2" }, "bin": { "openapi2ts": "dist/cli.js" } }, "sha512-+oJBEXV9Liu7tZzkYANs72hXiwqEngVhpUQN+XLVsAr49+D6thr+Fyb0cezcrydulOSsxa+VPaPMXPmXbAVuYA=="],
+
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
+
+ "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A=="],
+
+ "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q=="],
+
+ "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ=="],
+
+ "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ=="],
+
+ "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw=="],
+
+ "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw=="],
+
+ "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg=="],
+
+ "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA=="],
+
+ "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA=="],
+
+ "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw=="],
+
+ "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ=="],
+
+ "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg=="],
+
+ "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ=="],
+
+ "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w=="],
+
+ "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw=="],
+
+ "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ=="],
+
+ "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw=="],
+
+ "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA=="],
+
+ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.2", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg=="],
+
+ "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "https://registry.npmmirror.com/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="],
+
+ "acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
+
+ "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
+ "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
+ "aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
+
+ "array-includes": ["array-includes@3.1.9", "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
+
+ "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "https://registry.npmmirror.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
+
+ "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "https://registry.npmmirror.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
+
+ "array.prototype.flat": ["array.prototype.flat@1.3.3", "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
+
+ "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
+
+ "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "https://registry.npmmirror.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="],
+
+ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+
+ "asap": ["asap@2.0.6", "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
+
+ "ast-types-flow": ["ast-types-flow@0.0.8", "https://registry.npmmirror.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
+
+ "async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
+
+ "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+
+ "axe-core": ["axe-core@4.10.3", "https://registry.npmmirror.com/axe-core/-/axe-core-4.10.3.tgz", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="],
+
+ "axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
+ "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": "dist/cli.cjs" }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
+
+ "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+
+ "braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
+
+ "call-bind": ["call-bind@1.0.8", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
+ "call-me-maybe": ["call-me-maybe@1.0.2", "https://registry.npmmirror.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="],
+
+ "callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
+
+ "ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+ "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
+ "character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "character-reference-invalid": ["character-reference-invalid@2.0.1", "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
+ "chownr": ["chownr@3.0.0", "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "client-only": ["client-only@0.0.1", "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+
+ "cliui": ["cliui@8.0.1", "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+
+ "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+ "commander": ["commander@14.0.0", "https://registry.npmmirror.com/commander/-/commander-14.0.0.tgz", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
+
+ "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "cosmiconfig": ["cosmiconfig@9.0.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.1.3", "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+ "d": ["d@1.0.2", "https://registry.npmmirror.com/d/-/d-1.0.2.tgz", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
+
+ "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
+
+ "data-view-buffer": ["data-view-buffer@1.0.2", "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
+
+ "data-view-byte-length": ["data-view-byte-length@1.0.2", "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
+
+ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
+
+ "date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+
+ "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "https://registry.npmmirror.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
+
+ "dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
+ "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
+ "define-data-property": ["define-data-property@1.1.4", "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
+
+ "define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
+
+ "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
+ "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+ "devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+ "doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
+
+ "emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.18.2", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
+
+ "env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
+
+ "error-ex": ["error-ex@1.3.2", "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
+
+ "es-abstract": ["es-abstract@1.24.0", "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.0.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "https://registry.npmmirror.com/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
+
+ "es-to-primitive": ["es-to-primitive@1.3.0", "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
+
+ "es5-ext": ["es5-ext@0.10.64", "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
+
+ "es6-iterator": ["es6-iterator@2.0.3", "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
+
+ "es6-promise": ["es6-promise@3.3.1", "https://registry.npmmirror.com/es6-promise/-/es6-promise-3.3.1.tgz", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="],
+
+ "es6-symbol": ["es6-symbol@3.1.4", "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
+
+ "es6-weak-map": ["es6-weak-map@2.0.3", "https://registry.npmmirror.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="],
+
+ "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "eslint": ["eslint@9.29.0", "https://registry.npmmirror.com/eslint/-/eslint-9.29.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="],
+
+ "eslint-config-next": ["eslint-config-next@16.2.3", "", { "dependencies": { "@next/eslint-plugin-next": "16.2.3", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" } }, "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA=="],
+
+ "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
+
+ "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "https://registry.npmmirror.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
+
+ "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "https://registry.npmmirror.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
+
+ "eslint-module-utils": ["eslint-module-utils@2.12.1", "https://registry.npmmirror.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
+
+ "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "https://registry.npmmirror.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
+
+ "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "https://registry.npmmirror.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
+
+ "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "https://registry.npmmirror.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
+
+ "eslint-scope": ["eslint-scope@8.4.0", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+
+ "esniff": ["esniff@2.0.1", "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
+
+ "espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
+
+ "esquery": ["esquery@1.6.0", "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
+ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+
+ "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "event-emitter": ["event-emitter@0.3.5", "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
+
+ "ext": ["ext@1.7.0", "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
+
+ "extend": ["extend@3.0.2", "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-glob": ["fast-glob@3.3.1", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.1.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
+
+ "fastq": ["fastq@1.19.1", "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
+
+ "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
+
+ "for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
+
+ "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
+ "framer-motion": ["framer-motion@12.38.0", "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.38.0.tgz", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
+
+ "fs.realpath": ["fs.realpath@1.0.0", "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+
+ "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
+
+ "functions-have-names": ["functions-have-names@1.2.3", "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
+ "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "get-symbol-description": ["get-symbol-description@1.1.0", "https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
+
+ "get-tsconfig": ["get-tsconfig@4.10.1", "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
+
+ "glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
+
+ "globalthis": ["globalthis@1.0.4", "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
+
+ "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "has-bigints": ["has-bigints@1.1.0", "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
+
+ "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
+
+ "has-proto": ["has-proto@1.2.0", "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
+ "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+ "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
+
+ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+
+ "html-url-attributes": ["html-url-attributes@3.0.1", "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
+ "http2-client": ["http2-client@1.3.5", "https://registry.npmmirror.com/http2-client/-/http2-client-1.3.5.tgz", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="],
+
+ "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
+
+ "immer": ["immer@10.1.3", "https://registry.npmmirror.com/immer/-/immer-10.1.3.tgz", {}, "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw=="],
+
+ "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+
+ "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "inline-style-parser": ["inline-style-parser@0.2.7", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+
+ "internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+
+ "is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+ "is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+ "is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
+
+ "is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
+
+ "is-async-function": ["is-async-function@2.1.1", "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
+
+ "is-bigint": ["is-bigint@1.1.0", "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
+
+ "is-boolean-object": ["is-boolean-object@1.2.2", "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
+
+ "is-bun-module": ["is-bun-module@2.0.0", "https://registry.npmmirror.com/is-bun-module/-/is-bun-module-2.0.0.tgz", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="],
+
+ "is-callable": ["is-callable@1.2.7", "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
+
+ "is-core-module": ["is-core-module@2.16.1", "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
+ "is-data-view": ["is-data-view@1.0.2", "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
+
+ "is-date-object": ["is-date-object@1.1.0", "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
+
+ "is-decimal": ["is-decimal@2.0.1", "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
+
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "is-generator-function": ["is-generator-function@1.1.0", "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
+ "is-map": ["is-map@2.0.3", "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
+
+ "is-negative-zero": ["is-negative-zero@2.0.3", "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
+
+ "is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "is-number-object": ["is-number-object@1.1.1", "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
+
+ "is-plain-obj": ["is-plain-obj@4.1.0", "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+ "is-promise": ["is-promise@2.2.2", "https://registry.npmmirror.com/is-promise/-/is-promise-2.2.2.tgz", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="],
+
+ "is-regex": ["is-regex@1.2.1", "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
+
+ "is-set": ["is-set@2.0.3", "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
+
+ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
+
+ "is-string": ["is-string@1.1.1", "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
+
+ "is-symbol": ["is-symbol@1.1.1", "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
+
+ "is-typed-array": ["is-typed-array@1.1.15", "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
+
+ "is-weakmap": ["is-weakmap@2.0.2", "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
+
+ "is-weakref": ["is-weakref@1.1.1", "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
+
+ "is-weakset": ["is-weakset@2.0.4", "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
+
+ "isarray": ["isarray@2.0.5", "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
+
+ "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "iterator.prototype": ["iterator.prototype@1.1.5", "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
+
+ "jiti": ["jiti@2.4.2", "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz", { "bin": "lib/jiti-cli.mjs" }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "js-yaml": ["js-yaml@4.1.0", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
+ "json5": ["json5@1.0.2", "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
+
+ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
+
+ "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "language-subtag-registry": ["language-subtag-registry@0.3.23", "https://registry.npmmirror.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
+
+ "language-tags": ["language-tags@1.0.9", "https://registry.npmmirror.com/language-tags/-/language-tags-1.0.9.tgz", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
+
+ "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
+ "lie": ["lie@3.1.1", "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
+
+ "lightningcss": ["lightningcss@1.30.1", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.1.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
+
+ "lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+
+ "localforage": ["localforage@1.10.0", "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
+
+ "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lodash": ["lodash@4.17.21", "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+
+ "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+
+ "longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
+ "loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "lru-queue": ["lru-queue@0.1.0", "https://registry.npmmirror.com/lru-queue/-/lru-queue-0.1.0.tgz", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="],
+
+ "lucide-react": ["lucide-react@1.17.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.17.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
+
+ "magic-string": ["magic-string@0.30.17", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
+
+ "markdown-table": ["markdown-table@3.0.4", "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+ "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+ "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+ "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
+
+ "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+ "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+ "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+ "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
+ "memoizee": ["memoizee@0.4.17", "https://registry.npmmirror.com/memoizee/-/memoizee-0.4.17.tgz", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="],
+
+ "merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
+ "micromark": ["micromark@4.0.2", "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+ "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+ "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+ "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+ "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
+
+ "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+ "micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "minimatch": ["minimatch@3.1.2", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+
+ "minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+
+ "minipass": ["minipass@7.1.2", "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+
+ "minizlib": ["minizlib@3.0.2", "https://registry.npmmirror.com/minizlib/-/minizlib-3.0.2.tgz", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
+
+ "mkdirp": ["mkdirp@3.0.1", "https://registry.npmmirror.com/mkdirp/-/mkdirp-3.0.1.tgz", { "bin": "dist/cjs/src/bin.js" }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
+
+ "mock.js": ["mock.js@0.2.0", "https://registry.npmmirror.com/mock.js/-/mock.js-0.2.0.tgz", {}, "sha512-DKI8Rh/h7Mma+fg+6aD0uUvwn0QXAjKG6q3s+lTaCboCQ/kvQMBN9IXRBzgEaz4aPiYoRnKU9jVsfZp0mHpWrQ=="],
+
+ "mockjs": ["mockjs@1.1.0", "https://registry.npmmirror.com/mockjs/-/mockjs-1.1.0.tgz", { "dependencies": { "commander": "*" }, "bin": { "random": "bin/random" } }, "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ=="],
+
+ "motion": ["motion@12.38.0", "https://registry.npmmirror.com/motion/-/motion-12.38.0.tgz", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
+
+ "motion-dom": ["motion-dom@12.38.0", "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.38.0.tgz", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
+
+ "motion-utils": ["motion-utils@12.36.0", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.36.0.tgz", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
+
+ "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "napi-postinstall": ["napi-postinstall@0.2.4", "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.2.4.tgz", { "bin": "lib/cli.js" }, "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg=="],
+
+ "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
+
+ "next-tick": ["next-tick@1.1.0", "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
+
+ "node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
+ "node-fetch-h2": ["node-fetch-h2@2.3.0", "https://registry.npmmirror.com/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="],
+
+ "node-readfiles": ["node-readfiles@0.2.0", "https://registry.npmmirror.com/node-readfiles/-/node-readfiles-0.2.0.tgz", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="],
+
+ "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
+
+ "number-to-words": ["number-to-words@1.2.4", "https://registry.npmmirror.com/number-to-words/-/number-to-words-1.2.4.tgz", {}, "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw=="],
+
+ "nunjucks": ["nunjucks@3.2.4", "https://registry.npmmirror.com/nunjucks/-/nunjucks-3.2.4.tgz", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="],
+
+ "oas-kit-common": ["oas-kit-common@1.0.8", "https://registry.npmmirror.com/oas-kit-common/-/oas-kit-common-1.0.8.tgz", { "dependencies": { "fast-safe-stringify": "^2.0.7" } }, "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ=="],
+
+ "oas-linter": ["oas-linter@3.2.2", "https://registry.npmmirror.com/oas-linter/-/oas-linter-3.2.2.tgz", { "dependencies": { "@exodus/schemasafe": "^1.0.0-rc.2", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ=="],
+
+ "oas-resolver": ["oas-resolver@2.5.6", "https://registry.npmmirror.com/oas-resolver/-/oas-resolver-2.5.6.tgz", { "dependencies": { "node-fetch-h2": "^2.3.0", "oas-kit-common": "^1.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "resolve": "resolve.js" } }, "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ=="],
+
+ "oas-schema-walker": ["oas-schema-walker@1.1.5", "https://registry.npmmirror.com/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", {}, "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ=="],
+
+ "oas-validator": ["oas-validator@5.0.8", "https://registry.npmmirror.com/oas-validator/-/oas-validator-5.0.8.tgz", { "dependencies": { "call-me-maybe": "^1.0.1", "oas-kit-common": "^1.0.8", "oas-linter": "^3.2.2", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "reftools": "^1.1.9", "should": "^13.2.1", "yaml": "^1.10.0" } }, "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw=="],
+
+ "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
+
+ "object.assign": ["object.assign@4.1.7", "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
+
+ "object.entries": ["object.entries@1.1.9", "https://registry.npmmirror.com/object.entries/-/object.entries-1.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
+
+ "object.fromentries": ["object.fromentries@2.0.8", "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
+
+ "object.groupby": ["object.groupby@1.0.3", "https://registry.npmmirror.com/object.groupby/-/object.groupby-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
+
+ "object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
+
+ "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "openapi3-ts": ["openapi3-ts@2.0.2", "https://registry.npmmirror.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz", { "dependencies": { "yaml": "^1.10.2" } }, "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw=="],
+
+ "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "own-keys": ["own-keys@1.0.1", "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
+
+ "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+
+ "parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
+ "parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
+
+ "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+
+ "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
+ "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+
+ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
+
+ "postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+ "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "prettier": ["prettier@2.8.8", "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz", { "bin": "bin-prettier.js" }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
+
+ "prettier-plugin-organize-imports": ["prettier-plugin-organize-imports@4.2.0", "https://registry.npmmirror.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.2.0.tgz", { "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", "vue-tsc": "^2.1.0 || 3" }, "optionalPeers": ["vue-tsc"] }, "sha512-Zdy27UhlmyvATZi67BTnLcKTo8fm6Oik59Sz6H64PgZJVs6NJpPD1mT240mmJn62c98/QaL+r3kx9Q3gRpDajg=="],
+
+ "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.6.14", "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg=="],
+
+ "prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
+ "property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
+ "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
+
+ "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+
+ "radix-ui": ["radix-ui@1.4.3", "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
+
+ "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
+
+ "react-day-picker": ["react-day-picker@9.14.0", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.14.0.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
+
+ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
+
+ "react-hook-form": ["react-hook-form@7.62.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.62.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="],
+
+ "react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
+ "react-markdown": ["react-markdown@10.1.0", "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
+ "react-medium-image-zoom": ["react-medium-image-zoom@5.3.0", "https://registry.npmmirror.com/react-medium-image-zoom/-/react-medium-image-zoom-5.3.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-RCIzVlsKqy3BYgGgYbolUfuvx0aSKC7YhX/IJGEp+WJxsqdIVYJHkBdj++FAj6VD7RiWj6VVmdCfa/9vJE9hZg=="],
+
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
+ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
+
+ "reftools": ["reftools@1.1.9", "https://registry.npmmirror.com/reftools/-/reftools-1.1.9.tgz", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="],
+
+ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
+
+ "remark-gfm": ["remark-gfm@4.0.1", "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
+ "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "reserved-words": ["reserved-words@0.1.2", "https://registry.npmmirror.com/reserved-words/-/reserved-words-0.1.2.tgz", {}, "sha512-0S5SrIUJ9LfpbVl4Yzij6VipUdafHrOTzvmfazSw/jeZrZtQK303OPZW+obtkaw7jQlTQppy0UvZWm9872PbRw=="],
+
+ "resolve": ["resolve@1.22.10", "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
+
+ "resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+ "reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+
+ "rimraf": ["rimraf@3.0.2", "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
+
+ "run-parallel": ["run-parallel@1.2.0", "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "safe-array-concat": ["safe-array-concat@1.1.3", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
+
+ "safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
+
+ "safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
+
+ "set-function-name": ["set-function-name@2.0.2", "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
+
+ "set-proto": ["set-proto@1.0.0", "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
+
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "should": ["should@13.2.3", "https://registry.npmmirror.com/should/-/should-13.2.3.tgz", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
+
+ "should-equal": ["should-equal@2.0.0", "https://registry.npmmirror.com/should-equal/-/should-equal-2.0.0.tgz", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
+
+ "should-format": ["should-format@3.0.3", "https://registry.npmmirror.com/should-format/-/should-format-3.0.3.tgz", { "dependencies": { "should-type": "^1.3.0", "should-type-adaptors": "^1.0.1" } }, "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q=="],
+
+ "should-type": ["should-type@1.4.0", "https://registry.npmmirror.com/should-type/-/should-type-1.4.0.tgz", {}, "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ=="],
+
+ "should-type-adaptors": ["should-type-adaptors@1.1.0", "https://registry.npmmirror.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", { "dependencies": { "should-type": "^1.3.0", "should-util": "^1.0.0" } }, "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA=="],
+
+ "should-util": ["should-util@1.0.1", "https://registry.npmmirror.com/should-util/-/should-util-1.0.1.tgz", {}, "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g=="],
+
+ "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+
+ "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "sonner": ["sonner@2.0.6", "https://registry.npmmirror.com/sonner/-/sonner-2.0.6.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+ "stable-hash": ["stable-hash@0.0.5", "https://registry.npmmirror.com/stable-hash/-/stable-hash-0.0.5.tgz", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
+
+ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
+
+ "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "string.prototype.includes": ["string.prototype.includes@2.0.1", "https://registry.npmmirror.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
+
+ "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
+
+ "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "https://registry.npmmirror.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
+
+ "string.prototype.trim": ["string.prototype.trim@1.2.10", "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
+
+ "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
+
+ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
+
+ "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+ "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
+
+ "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+
+ "style-to-js": ["style-to-js@1.1.21", "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+ "style-to-object": ["style-to-object@1.0.14", "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+
+ "styled-jsx": ["styled-jsx@5.1.6", "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+
+ "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "swagger2openapi": ["swagger2openapi@7.0.8", "https://registry.npmmirror.com/swagger2openapi/-/swagger2openapi-7.0.8.tgz", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "boast": "boast.js", "oas-validate": "oas-validate.js", "swagger2openapi": "swagger2openapi.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="],
+
+ "tailwind-merge": ["tailwind-merge@3.3.1", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.3.1.tgz", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
+
+ "tailwindcss": ["tailwindcss@4.1.10", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.10.tgz", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="],
+
+ "tapable": ["tapable@2.2.2", "https://registry.npmmirror.com/tapable/-/tapable-2.2.2.tgz", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
+
+ "tar": ["tar@7.4.3", "https://registry.npmmirror.com/tar/-/tar-7.4.3.tgz", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
+
+ "timers-ext": ["timers-ext@0.1.8", "https://registry.npmmirror.com/timers-ext/-/timers-ext-0.1.8.tgz", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="],
+
+ "tiny-pinyin": ["tiny-pinyin@1.3.2", "https://registry.npmmirror.com/tiny-pinyin/-/tiny-pinyin-1.3.2.tgz", {}, "sha512-uHNGu4evFt/8eNLldazeAM1M8JrMc1jshhJJfVRARTN3yT8HEEibofeQ7QETWQ5ISBjd6fKtTVBCC/+mGS6FpA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
+ "trim-lines": ["trim-lines@3.0.1", "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "trough": ["trough@2.2.0", "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
+ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
+
+ "tsconfig-paths": ["tsconfig-paths@3.15.0", "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
+
+ "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "tw-animate-css": ["tw-animate-css@1.3.4", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.3.4.tgz", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="],
+
+ "type": ["type@2.7.3", "https://registry.npmmirror.com/type/-/type-2.7.3.tgz", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
+
+ "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "typed-array-buffer": ["typed-array-buffer@1.0.3", "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
+
+ "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
+
+ "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
+
+ "typed-array-length": ["typed-array-length@1.0.7", "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
+
+ "typescript": ["typescript@5.8.3", "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+
+ "typescript-eslint": ["typescript-eslint@8.58.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ=="],
+
+ "unbox-primitive": ["unbox-primitive@1.1.0", "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
+
+ "undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "unified": ["unified@11.0.5", "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+ "unrs-resolver": ["unrs-resolver@1.9.2", "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.9.2.tgz", { "dependencies": { "napi-postinstall": "^0.2.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.9.2", "@unrs/resolver-binding-android-arm64": "1.9.2", "@unrs/resolver-binding-darwin-arm64": "1.9.2", "@unrs/resolver-binding-darwin-x64": "1.9.2", "@unrs/resolver-binding-freebsd-x64": "1.9.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", "@unrs/resolver-binding-linux-x64-musl": "1.9.2", "@unrs/resolver-binding-wasm32-wasi": "1.9.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" } }, "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
+ "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
+ "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
+
+ "vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+ "webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
+ "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
+
+ "which-builtin-type": ["which-builtin-type@1.2.1", "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
+
+ "which-collection": ["which-collection@1.0.2", "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
+
+ "which-typed-array": ["which-typed-array@1.1.19", "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.19.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
+ "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
+ "yallist": ["yallist@5.0.0", "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
+
+ "yaml": ["yaml@1.10.2", "https://registry.npmmirror.com/yaml/-/yaml-1.10.2.tgz", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
+
+ "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+
+ "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
+ "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
+ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
+ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
+
+ "zustand": ["zustand@5.0.8", "https://registry.npmmirror.com/zustand/-/zustand-5.0.8.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
+
+ "zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
+ "@babel/core/json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.1", "https://registry.npmmirror.com/@eslint/core/-/core-0.15.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="],
+
+ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.3.1.tgz", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
+
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+
+ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+
+ "eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
+
+ "eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
+
+ "eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
+
+ "eslint-plugin-import/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
+
+ "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.5.tgz", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
+
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "is-bun-module/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+ "micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "next/postcss": ["postcss@8.4.31", "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+
+ "nunjucks/commander": ["commander@5.1.0", "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
+
+ "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "prettier-plugin-tailwindcss/prettier": ["prettier@3.6.2", "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz", { "bin": "bin/prettier.cjs" }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
+
+ "sharp/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ }
+}
diff --git a/web/components.json b/web/components.json
new file mode 100644
index 0000000000000000000000000000000000000000..2ad646365ee2d7b6edf69dadea868da75cfc51d8
--- /dev/null
+++ b/web/components.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {
+ "@magicui": "https://magicui.design/r/{name}"
+ }
+}
diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..f9cc07d758e19e887a1b6ee3e3c559bd156864ef
--- /dev/null
+++ b/web/eslint.config.mjs
@@ -0,0 +1,17 @@
+import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
+import nextTypescript from 'eslint-config-next/typescript';
+import prettier from 'eslint-config-prettier/flat';
+
+const eslintConfig = [
+ ...nextCoreWebVitals,
+ ...nextTypescript,
+ prettier,
+ {
+ rules: {
+ '@typescript-eslint/no-unused-vars': 'off', // 不检查未使用的变量
+ '@typescript-eslint/no-explicit-any': 'off', // 关闭 any 报错
+ },
+ },
+];
+
+export default eslintConfig;
diff --git a/web/next.config.ts b/web/next.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..99e63ef118a75bc558b4023919766c36b32611f7
--- /dev/null
+++ b/web/next.config.ts
@@ -0,0 +1,40 @@
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { NextConfig } from 'next'
+import { parseChangelog } from './src/lib/release'
+
+const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
+
+function readAppVersion() {
+ try {
+ const version = readFileSync(join(projectRoot, 'VERSION'), 'utf-8').trim()
+ return version || '0.0.0'
+ } catch {
+ return '0.0.0'
+ }
+}
+
+const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
+let appReleases = '[]'
+try {
+ appReleases = JSON.stringify(parseChangelog(readFileSync(join(projectRoot, 'CHANGELOG.md'), 'utf-8')))
+} catch {}
+
+const nextConfig: NextConfig = {
+ allowedDevOrigins: ['127.0.0.1'],
+ env: {
+ NEXT_PUBLIC_APP_VERSION: appVersion,
+ NEXT_PUBLIC_APP_RELEASES: appReleases,
+ },
+ output: 'export',
+ trailingSlash: true,
+ images: {
+ unoptimized: true,
+ },
+ typescript: {
+ ignoreBuildErrors: true,
+ },
+}
+
+export default nextConfig
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..0624e9ab9a73fa1d1c2d14138e2c4ae97ed47b53
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "next-starter",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev --webpack -H 0.0.0.0",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.3",
+ "axios": "^1.9.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "immer": "^10.1.3",
+ "localforage": "^1.10.0",
+ "lucide-react": "^1.17.0",
+ "motion": "^12.38.0",
+ "next": "16.2.3",
+ "radix-ui": "^1.4.3",
+ "react": "19.2.5",
+ "react-day-picker": "^9.14.0",
+ "react-dom": "19.2.5",
+ "react-hook-form": "^7.62.0",
+ "react-markdown": "^10.1.0",
+ "react-medium-image-zoom": "^5.3.0",
+ "remark-gfm": "^4.0.1",
+ "sonner": "^2.0.6",
+ "tailwind-merge": "^3.3.1",
+ "zustand": "^5.0.8"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "19.1.12",
+ "@types/react-dom": "19.1.9",
+ "@umijs/openapi": "^1.13.15",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.3",
+ "eslint-config-prettier": "^10.1.8",
+ "prettier-plugin-organize-imports": "^4.2.0",
+ "prettier-plugin-tailwindcss": "^0.6.14",
+ "tailwindcss": "^4",
+ "tw-animate-css": "^1.3.4",
+ "typescript": "^5"
+ },
+ "overrides": {
+ "@types/react": "19.1.12",
+ "@types/react-dom": "19.1.9"
+ }
+}
diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..e2b63c7953ce053b43e5f00d3a9b15157413915d
--- /dev/null
+++ b/web/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ['@tailwindcss/postcss'],
+};
+
+export default config;
diff --git a/web/public/github.svg b/web/public/github.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7a51b8e0ee1054826a64377e6b75d8090f93e406
--- /dev/null
+++ b/web/public/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/next.svg b/web/public/next.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398
--- /dev/null
+++ b/web/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/openai.svg b/web/public/openai.svg
new file mode 100644
index 0000000000000000000000000000000000000000..78caf4fa20ffb0ae8ebefd74408871c11c1c7fd2
--- /dev/null
+++ b/web/public/openai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/vercel.svg b/web/public/vercel.svg
new file mode 100644
index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9
--- /dev/null
+++ b/web/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/app/accounts/components/account-import-dialog.tsx b/web/src/app/accounts/components/account-import-dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f0fdc5863e111e476b28b6beedd3ef83154ae98f
--- /dev/null
+++ b/web/src/app/accounts/components/account-import-dialog.tsx
@@ -0,0 +1,899 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useRef, useState, type ChangeEvent } from "react";
+import {
+ ArrowLeft,
+ Copy,
+ ExternalLink,
+ FileJson,
+ FileText,
+ Files,
+ KeyRound,
+ LoaderCircle,
+ LogIn,
+ ServerCog,
+ Upload,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ createAccounts,
+ finishOAuthLogin,
+ startOAuthLogin,
+ type Account,
+ type AccountImportPayload,
+ type OAuthLoginStartResponse,
+} from "@/lib/api";
+import { cn } from "@/lib/utils";
+
+type ImportMethod = "menu" | "token" | "session" | "codex-auth" | "cpa" | "oauth";
+
+type AccountImportDialogProps = {
+ disabled?: boolean;
+ onImported: (items: Account[]) => void;
+};
+
+type PendingCpaImport = {
+ tokens: string[];
+ accounts: AccountImportPayload[];
+ parsedFileCount: number;
+ errorCount: number;
+};
+
+const sessionUrl = "https://chatgpt.com/api/auth/session";
+
+function splitTokens(value: string) {
+ return value
+ .split(/\r?\n/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function getSessionAccessToken(value: unknown) {
+ const token = (value as { accessToken?: unknown })?.accessToken;
+ return typeof token === "string" ? token.trim() : "";
+}
+
+function getCpaAccount(value: unknown): AccountImportPayload | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+ const raw = value as Record;
+ const tokenValue = raw.access_token ?? raw.accessToken;
+ const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
+ if (!token) {
+ return null;
+ }
+
+ const payload: AccountImportPayload = {
+ ...raw,
+ access_token: token,
+ source_type: "codex",
+ };
+ delete payload.accessToken;
+ if (payload.type === "codex") {
+ payload.export_type = "codex";
+ delete payload.type;
+ }
+ return payload;
+}
+
+function getCodexAuthAccount(value: unknown): AccountImportPayload | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+ const raw = value as Record;
+ const tokenValue = raw.access_token ?? raw.accessToken;
+ const token = typeof tokenValue === "string" ? tokenValue.trim() : "";
+ if (!token) {
+ return null;
+ }
+
+ const payload: AccountImportPayload = {
+ ...raw,
+ access_token: token,
+ export_type: "codex",
+ source_type: "codex",
+ };
+ delete payload.accessToken;
+ if (payload.type === "codex") {
+ delete payload.type;
+ }
+ return payload;
+}
+
+function readFileAsText(file: File) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
+ reader.onerror = () => reject(reader.error ?? new Error(`读取文件失败: ${file.name}`));
+ reader.readAsText(file);
+ });
+}
+
+function MethodCard({
+ title,
+ description,
+ icon: Icon,
+ onClick,
+}: {
+ title: string;
+ description: string;
+ icon: typeof KeyRound;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+export function AccountImportDialog({ disabled, onImported }: AccountImportDialogProps) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [method, setMethod] = useState("menu");
+ const [tokenInput, setTokenInput] = useState("");
+ const [sessionInput, setSessionInput] = useState("");
+ const [codexAuthInput, setCodexAuthInput] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [pendingCpaImport, setPendingCpaImport] = useState(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [oauthEmailHint, setOauthEmailHint] = useState("");
+ const [oauthSession, setOauthSession] = useState(null);
+ const [oauthCallbackInput, setOauthCallbackInput] = useState("");
+ const [oauthStarting, setOauthStarting] = useState(false);
+
+ const txtInputRef = useRef(null);
+ const cpaInputRef = useRef(null);
+
+ const resetState = () => {
+ setMethod("menu");
+ setTokenInput("");
+ setSessionInput("");
+ setCodexAuthInput("");
+ setPendingCpaImport(null);
+ setConfirmOpen(false);
+ setOauthEmailHint("");
+ setOauthSession(null);
+ setOauthCallbackInput("");
+ setOauthStarting(false);
+ };
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ resetState();
+ }
+ };
+
+ const submitTokens = async (tokens: string[], successText?: string, accountPayloads: AccountImportPayload[] = []) => {
+ const normalizedTokens = tokens.map((item) => item.trim()).filter(Boolean);
+
+ if (normalizedTokens.length === 0) {
+ toast.error("请先提供至少一个可用 Token");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const data = await createAccounts(normalizedTokens, accountPayloads);
+ onImported(data.items);
+ setOpen(false);
+ resetState();
+
+ if ((data.errors?.length ?? 0) > 0) {
+ const firstError = data.errors?.[0]?.error;
+ toast.error(
+ `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
+ );
+ } else {
+ toast.success(
+ `${successText ?? "导入完成"},新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
+ );
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "导入账户失败";
+ toast.error(message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleImportTokenText = async () => {
+ await submitTokens(splitTokens(tokenInput), "Access Token 导入完成");
+ };
+
+ // 起授权:拿 authorize URL,立刻在新窗口打开,方便用户登录
+ const handleStartOAuth = async () => {
+ setOauthStarting(true);
+ try {
+ const data = await startOAuthLogin(oauthEmailHint.trim());
+ setOauthSession(data);
+ setOauthCallbackInput("");
+ if (typeof window !== "undefined") {
+ window.open(data.authorize_url, "_blank", "noopener,noreferrer");
+ }
+ toast.success("已打开 OpenAI 授权页面,请在登录后复制 callback URL 回来");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "OAuth 起始失败";
+ toast.error(message);
+ } finally {
+ setOauthStarting(false);
+ }
+ };
+
+ // 用粘贴回来的 callback URL 完成换 token + 落盘
+ const handleFinishOAuth = async () => {
+ if (!oauthSession) {
+ toast.error("请先点击\"打开授权页面\"获取 session");
+ return;
+ }
+ const trimmed = oauthCallbackInput.trim();
+ if (!trimmed) {
+ toast.error("请粘贴 callback URL 或 code");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const data = await finishOAuthLogin(oauthSession.session_id, trimmed);
+ onImported(data.items);
+ setOpen(false);
+ resetState();
+
+ if ((data.errors?.length ?? 0) > 0) {
+ const firstError = data.errors?.[0]?.error;
+ toast.error(
+ `OAuth 登录完成,新增 ${data.added ?? 0} 个,已刷新 ${data.refreshed ?? 0} 个,失败 ${data.errors?.length ?? 0} 个${firstError ? `,首个错误:${firstError}` : ""}`,
+ );
+ } else {
+ toast.success(
+ `OAuth 登录完成,新增 ${data.added ?? 0} 个,跳过 ${data.skipped ?? 0} 个重复项,已自动刷新账号信息`,
+ );
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "OAuth 换 token 失败";
+ toast.error(message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 复制 authorize URL 到剪贴板(适配浏览器和 fallback)
+ const handleCopyAuthorizeUrl = async () => {
+ if (!oauthSession) {
+ return;
+ }
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(oauthSession.authorize_url);
+ toast.success("授权 URL 已复制到剪贴板");
+ } else {
+ toast.error("当前环境不支持自动复制,请手动选择并复制");
+ }
+ } catch {
+ toast.error("复制失败,请手动选择并复制");
+ }
+ };
+
+ const handleTxtSelected = async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+
+ if (!file) {
+ return;
+ }
+
+ try {
+ const content = await readFileAsText(file);
+ const tokens = splitTokens(content);
+
+ if (tokens.length === 0) {
+ toast.error("TXT 文件里没有读取到有效 Token");
+ return;
+ }
+
+ setTokenInput((prev) => {
+ const next = [...splitTokens(prev), ...tokens];
+ return next.join("\n");
+ });
+ toast.success(`已从 ${file.name} 读取 ${tokens.length} 个 Token`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "读取 TXT 文件失败";
+ toast.error(message);
+ }
+ };
+
+ const handleImportSessionJson = async () => {
+ if (!sessionInput.trim()) {
+ toast.error("请先粘贴完整 Session JSON");
+ return;
+ }
+
+ try {
+ const payload = JSON.parse(sessionInput) as unknown;
+ const token = getSessionAccessToken(payload);
+
+ if (!token) {
+ toast.error("未从 Session JSON 中提取到 accessToken");
+ return;
+ }
+
+ await submitTokens([token], "Session JSON 导入完成");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Session JSON 解析失败";
+ toast.error(message);
+ }
+ };
+
+ const handleImportCodexAuthJson = async () => {
+ if (!codexAuthInput.trim()) {
+ toast.error("请先粘贴 Codex 认证 JSON");
+ return;
+ }
+
+ try {
+ const payload = JSON.parse(codexAuthInput) as unknown;
+ const account = getCodexAuthAccount(payload);
+
+ if (!account) {
+ toast.error("未从 Codex 认证 JSON 中提取到 access_token");
+ return;
+ }
+
+ await submitTokens([account.access_token], "Codex 认证 JSON 导入完成", [account]);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Codex 认证 JSON 解析失败";
+ toast.error(message);
+ }
+ };
+
+ const handleCpaSelected = async (event: ChangeEvent) => {
+ const files = Array.from(event.target.files ?? []);
+ event.target.value = "";
+
+ if (files.length === 0) {
+ return;
+ }
+
+ try {
+ const results = await Promise.all(
+ files.map(async (file) => {
+ const raw = await readFileAsText(file);
+ const parsed = JSON.parse(raw) as unknown;
+ const account = getCpaAccount(parsed);
+ return {
+ account,
+ };
+ }),
+ );
+
+ const accounts = results.map((item) => item.account).filter((item): item is AccountImportPayload => Boolean(item));
+ const tokens = accounts.map((item) => item.access_token);
+ const parsedFileCount = accounts.length;
+ const errorCount = results.length - parsedFileCount;
+
+ if (parsedFileCount === 0) {
+ toast.error("这些 CPA JSON 文件里没有读取到可用 access_token");
+ return;
+ }
+
+ setPendingCpaImport({
+ tokens,
+ accounts,
+ parsedFileCount,
+ errorCount,
+ });
+ setConfirmOpen(true);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "读取 CPA JSON 文件失败";
+ toast.error(message);
+ }
+ };
+
+ const renderMethodBody = () => {
+ if (method === "token") {
+ const tokenCount = splitTokens(tokenInput).length;
+
+ return (
+
+
+
+
当前识别 {tokenCount} 个 Token
+
+
+
+
+
+
+
+
从 TXT 文件导入
+
支持 `.txt`,文件内容也是一行一个 Token。
+
+
+
+
+
void handleTxtSelected(event)}
+ />
+
+ );
+ }
+
+ if (method === "session") {
+ return (
+
+
+
+
+
风险提示
+
+ 不要使用自己的大号,尽量使用不常用的小号进行导入,避免出现封号风险。本项目不承担任何封号风险责任。
+
+
+
+
+
+
+ );
+ }
+
+ if (method === "oauth") {
+ return (
+
+
+
+
操作步骤
+
+ - (可选)填写你 ChatGPT 账号的邮箱,登录页会预填。
+ - 点击下方"打开授权页面",在新标签里登录自己的 ChatGPT 账号。
+ - 登录完成后浏览器会跳到
platform.openai.com/auth/callback?code=...。立刻从地址栏复制整段 URL(或开 F12 在 Network 里抓到 callback 那一行,右键 Copy → Copy URL)。
+ - 把 callback URL 粘到下面输入框,点"完成导入"。
+
+
+
+
+ setOauthEmailHint(event.target.value)}
+ disabled={Boolean(oauthSession) || oauthStarting}
+ className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm outline-none focus:border-stone-400"
+ />
+
+ {!oauthSession ? (
+
+ ) : (
+
+
+ {oauthSession.authorize_url}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
注意
+
+ 授权码(code)只能使用一次。如果浏览器的 callback 页加载完成、显示了 OpenAI 的错误页,那 code 大概率已经被消耗,
+ 请点击"重新生成"再走一次。整个流程在 10 分钟内完成即可。
+
+
+
+ );
+ }
+
+ if (method === "cpa") {
+ return (
+
+
+
+
+
多选本地 CPA JSON 文件
+
+ 每个文件应为一个 JSON 对象。系统会从对象中自动提取 `access_token` 或 `accessToken`,
+
+
+
+
+
void handleCpaSelected(event)}
+ />
+ {pendingCpaImport ? (
+
+ 最近一次读取到 {pendingCpaImport.parsedFileCount} 个 Token
+ {pendingCpaImport.errorCount > 0 ? `,另有 ${pendingCpaImport.errorCount} 个文件未提取成功` : ""}。
+
+ ) : null}
+
+ );
+ }
+
+ if (method === "codex-auth") {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ setMethod("oauth")}
+ />
+ setMethod("token")}
+ />
+ setMethod("session")}
+ />
+ setMethod("codex-auth")}
+ />
+ setMethod("cpa")}
+ />
+ {
+ setOpen(false);
+ resetState();
+ router.push("/settings");
+ }}
+ />
+ {
+ setOpen(false);
+ resetState();
+ router.push("/settings");
+ }}
+ />
+
+ );
+ };
+
+ const footerDisabled = disabled || isSubmitting;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/accounts/page.tsx b/web/src/app/accounts/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..32a11af507576edbf943981b1cefd2025d075fc2
--- /dev/null
+++ b/web/src/app/accounts/page.tsx
@@ -0,0 +1,1315 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { ComponentProps } from "react";
+import {
+ Ban,
+ CheckCircle2,
+ ChevronLeft,
+ ChevronRight,
+ CircleAlert,
+ CircleOff,
+ Copy,
+ Download,
+ Link2,
+ LoaderCircle,
+ LogIn,
+ Pencil,
+ RefreshCw,
+ Search,
+ Trash2,
+ UserRound,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ deleteAccounts,
+ fetchAccounts,
+ fetchModels,
+ fetchRefreshProgress,
+ fetchReLoginProgress,
+ reLoginAccounts,
+ refreshAccounts,
+ testProxy,
+ updateAccount,
+ type Account,
+ type AccountRefreshResponse,
+ type AccountStatus,
+ type Model,
+ type RefreshProgressResponse,
+} from "@/lib/api";
+import { useAuthGuard } from "@/lib/use-auth-guard";
+import { cn } from "@/lib/utils";
+
+import { AccountImportDialog } from "./components/account-import-dialog";
+
+const accountStatusOptions: { label: string; value: AccountStatus | "all" }[] = [
+ { label: "全部状态", value: "all" },
+ { label: "正常", value: "正常" },
+ { label: "限流", value: "限流" },
+ { label: "异常", value: "异常" },
+ { label: "禁用", value: "禁用" },
+];
+
+const statusMeta: Record<
+ AccountStatus,
+ {
+ icon: typeof CheckCircle2;
+ badge: ComponentProps["variant"];
+ }
+> = {
+ 正常: { icon: CheckCircle2, badge: "success" },
+ 限流: { icon: CircleAlert, badge: "warning" },
+ 异常: { icon: CircleOff, badge: "danger" },
+ 禁用: { icon: Ban, badge: "secondary" },
+};
+
+const metricCards = [
+ { key: "total", label: "账户总数", color: "text-stone-900", icon: UserRound },
+ { key: "active", label: "正常账户", color: "text-emerald-600", icon: CheckCircle2 },
+ { key: "limited", label: "限流账户", color: "text-orange-500", icon: CircleAlert },
+ { key: "abnormal", label: "异常账户", color: "text-rose-500", icon: CircleOff },
+ { key: "disabled", label: "禁用账户", color: "text-stone-500", icon: Ban },
+ { key: "quota", label: "剩余额度", color: "text-blue-500", icon: RefreshCw },
+] as const;
+
+function isUnlimitedImageQuotaAccount(account: Account) {
+ return account.type === "pro" || account.type === "prolite";
+}
+
+function imageQuotaUnknown(account: Account) {
+ return Boolean(account.image_quota_unknown);
+}
+
+function formatCompact(value: number) {
+ if (value >= 1000) {
+ return `${(value / 1000).toFixed(1)}k`;
+ }
+ return String(value);
+}
+
+function formatQuota(account: Account) {
+ if (isUnlimitedImageQuotaAccount(account)) {
+ return "∞";
+ }
+ if (imageQuotaUnknown(account)) {
+ return "未知";
+ }
+ return String(Math.max(0, account.quota));
+}
+
+function formatRestoreAt(value?: string | null) {
+ if (!value) {
+ return { absolute: "—", relative: "" };
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return { absolute: value, relative: "" };
+ }
+
+ const diffMs = Math.max(0, date.getTime() - Date.now());
+ const totalHours = Math.ceil(diffMs / (1000 * 60 * 60));
+ const days = Math.floor(totalHours / 24);
+ const hours = totalHours % 24;
+ const relative = diffMs > 0 ? `剩余 ${days}d ${hours}h` : "已到恢复时间";
+
+ const pad = (num: number) => String(num).padStart(2, "0");
+ const absolute = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
+ date.getHours(),
+ )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+
+ return { absolute, relative };
+}
+
+function formatQuotaSummary(accounts: Account[]) {
+ const availableAccounts = accounts.filter((account) => account.status === "正常");
+ if (availableAccounts.some(isUnlimitedImageQuotaAccount)) {
+ return "∞";
+ }
+ if (availableAccounts.some(imageQuotaUnknown)) {
+ return "未知";
+ }
+ return formatCompact(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
+}
+
+function maskToken(token?: string) {
+ if (!token) return "—";
+ if (token.length <= 18) return token;
+ return `${token.slice(0, 16)}...${token.slice(-8)}`;
+}
+
+function downloadTokens(accounts: Account[]) {
+ const content = `${accounts.map((account) => account.access_token).join("\n")}\n`;
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `accounts-${Date.now()}.txt`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+function displayAccountType(account: Account) {
+ return account.type || "Free";
+}
+
+function displayAccountSource(account: Account) {
+ const source = String(account.source_type || "").trim().toLowerCase();
+ if (!source) {
+ return "web";
+ }
+ if (source === "web") {
+ return "web";
+ }
+ return source;
+}
+
+function AccountsPageContent() {
+ const didLoadRef = useRef(false);
+ const [accounts, setAccounts] = useState([]);
+ const [availableModels, setAvailableModels] = useState([]);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [query, setQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState("all");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState("10");
+ const [editingAccount, setEditingAccount] = useState(null);
+ const [editStatus, setEditStatus] = useState("正常");
+ const [editProxy, setEditProxy] = useState("");
+ const [isTestingProxy, setIsTestingProxy] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isLoadingModels, setIsLoadingModels] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [refreshingTokens, setRefreshingTokens] = useState>(new Set());
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isUpdating, setIsUpdating] = useState(false);
+ const [isRelogining, setIsRelogining] = useState(false);
+ const [progress, setProgress] = useState<{
+ visible: boolean;
+ current: number;
+ total: number;
+ message: string;
+ email: string;
+ }>({
+ visible: false,
+ current: 0,
+ total: 0,
+ message: "",
+ email: "",
+ });
+ const progressRef = useRef | null>(null);
+ const [refreshSummary, setRefreshSummary] = useState | null>(null);
+
+ const loadAccounts = async (silent = false) => {
+ if (!silent) {
+ setIsLoading(true);
+ }
+ try {
+ const data = await fetchAccounts();
+ setAccounts(data.items);
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "加载账户失败";
+ toast.error(message);
+ } finally {
+ if (!silent) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ const loadModels = async () => {
+ setIsLoadingModels(true);
+ try {
+ const data = await fetchModels();
+ setAvailableModels(Array.isArray(data.data) ? data.data : []);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "加载模型列表失败";
+ toast.error(message);
+ } finally {
+ setIsLoadingModels(false);
+ }
+ };
+
+ useEffect(() => {
+ if (didLoadRef.current) {
+ return;
+ }
+ didLoadRef.current = true;
+ void loadAccounts();
+ void loadModels();
+
+ // 清理进度条定时器
+ return () => {
+ if (progressRef.current) clearInterval(progressRef.current);
+ };
+ }, []);
+
+ const filteredAccounts = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+ return accounts.filter((account) => {
+ const searchMatched =
+ normalizedQuery.length === 0 || (account.email ?? "").toLowerCase().includes(normalizedQuery);
+ const typeMatched = typeFilter === "all" || displayAccountType(account) === typeFilter;
+ const statusMatched = statusFilter === "all" || account.status === statusFilter;
+ return searchMatched && typeMatched && statusMatched;
+ });
+ }, [accounts, query, statusFilter, typeFilter]);
+
+ const pageCount = Math.max(1, Math.ceil(filteredAccounts.length / Number(pageSize)));
+ const safePage = Math.min(page, pageCount);
+ const startIndex = (safePage - 1) * Number(pageSize);
+ const currentRows = filteredAccounts.slice(startIndex, startIndex + Number(pageSize));
+ const allCurrentSelected =
+ currentRows.length > 0 && currentRows.every((row) => selectedIds.includes(row.access_token));
+
+ const summary = useMemo(() => {
+ const total = accounts.length;
+ const active = accounts.filter((item) => item.status === "正常").length;
+ const limited = accounts.filter((item) => item.status === "限流").length;
+ const abnormal = accounts.filter((item) => item.status === "异常").length;
+ const disabled = accounts.filter((item) => item.status === "禁用").length;
+ const quota = formatQuotaSummary(accounts);
+
+ return { total, active, limited, abnormal, disabled, quota };
+ }, [accounts]);
+
+ const accountTypeOptions = useMemo(
+ () => [
+ { label: "全部类型", value: "all" },
+ ...Array.from(new Set(accounts.map(displayAccountType))).map((type) => ({ label: type, value: type })),
+ ],
+ [accounts],
+ );
+
+ const selectedTokens = useMemo(() => {
+ const selectedSet = new Set(selectedIds);
+ return accounts.filter((item) => selectedSet.has(item.access_token)).map((item) => item.access_token);
+ }, [accounts, selectedIds]);
+
+ const abnormalTokens = useMemo(() => {
+ return accounts.filter((item) => item.status === "异常").map((item) => item.access_token);
+ }, [accounts]);
+
+ const paginationItems = useMemo(() => {
+ const items: (number | "...")[] = [];
+ const start = Math.max(1, safePage - 1);
+ const end = Math.min(pageCount, safePage + 1);
+
+ if (start > 1) items.push(1);
+ if (start > 2) items.push("...");
+ for (let current = start; current <= end; current += 1) items.push(current);
+ if (end < pageCount - 1) items.push("...");
+ if (end < pageCount) items.push(pageCount);
+
+ return items;
+ }, [pageCount, safePage]);
+
+ const handleDeleteTokens = async (tokens: string[]) => {
+ if (tokens.length === 0) {
+ toast.error("请先选择要删除的账户");
+ return;
+ }
+
+ setIsDeleting(true);
+ try {
+ const data = await deleteAccounts(tokens);
+ setAccounts(data.items);
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
+ toast.success(`删除 ${data.removed ?? 0} 个账户`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "删除账户失败";
+ toast.error(message);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleRefreshAccounts = async (accessTokens: string[]) => {
+ if (accessTokens.length === 0) {
+ toast.error("没有需要刷新的账户");
+ return;
+ }
+
+ if (accessTokens.length === 1) {
+ setRefreshingTokens((prev) => new Set([...prev, accessTokens[0]]));
+ try {
+ const { progress_id } = await refreshAccounts(accessTokens);
+ // 单账号:轮询等待完成
+ await pollRefreshProgress(progress_id, (progress) => {
+ if (progress.done && progress.result) {
+ setAccounts(progress.result.items);
+ setSelectedIds((prev) => prev.filter((id) => progress.result!.items.some((item) => item.access_token === id)));
+ }
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "刷新账户失败";
+ toast.error(message);
+ } finally {
+ setRefreshingTokens((prev) => {
+ const next = new Set(prev);
+ next.delete(accessTokens[0]);
+ return next;
+ });
+ }
+ return;
+ }
+
+ setIsRefreshing(true);
+
+ // 计算非选中账号的基数(统计卡片联动用)
+ const selectedTokenSet = new Set(accessTokens);
+ const baseAccountsList = accounts.filter((a) => !selectedTokenSet.has(a.access_token));
+ const baseActive = baseAccountsList.filter((a) => a.status === "正常").length;
+ const baseLimited = baseAccountsList.filter((a) => a.status === "限流").length;
+ const baseAbnormal = baseAccountsList.filter((a) => a.status === "异常").length;
+ const baseDisabled = baseAccountsList.filter((a) => a.status === "禁用").length;
+ const baseNormalAccounts = baseAccountsList.filter((a) => a.status === "正常");
+ const baseHasUnlimited = baseNormalAccounts.some(isUnlimitedImageQuotaAccount);
+ const baseHasUnknown = baseNormalAccounts.some(imageQuotaUnknown);
+ const baseQuotaNum = baseNormalAccounts.reduce((s, a) => s + Math.max(0, a.quota), 0);
+
+ // 显示进度条(只显示当前任务,不含分类统计)
+ const total = accessTokens.length;
+ setProgress({
+ visible: true,
+ current: 0,
+ total,
+ message: "正在刷新账号信息...",
+ email: "",
+ });
+
+ try {
+ const { progress_id } = await refreshAccounts(accessTokens);
+
+ // 轮询进度到完成
+ const data = await new Promise((resolve, reject) => {
+ const pollTimer = setInterval(async () => {
+ try {
+ const p = await fetchRefreshProgress(progress_id);
+ if (p.done) {
+ clearInterval(pollTimer);
+ if (p.error) {
+ reject(new Error(p.error));
+ return;
+ }
+ if (!p.result) {
+ reject(new Error("刷新结果为空"));
+ return;
+ }
+ // 更新最终进度显示
+ setProgress((prev) => ({
+ ...prev,
+ current: prev.total,
+ message: "刷新完成",
+ }));
+ // 清除联动统计
+ setRefreshSummary(null);
+ resolve(p.result);
+ } else {
+ // 实时更新进度
+ setProgress((prev) => ({
+ ...prev,
+ current: p.processed,
+ }));
+ // 实时更新统计卡片:基数 + 已刷新的累加结果
+ const runningActive = baseActive + ((p.status_counts?.["正常"]) ?? 0);
+ const runningLimited = baseLimited + ((p.status_counts?.["限流"]) ?? 0);
+ const runningAbnormal = baseAbnormal + ((p.status_counts?.["异常"]) ?? 0);
+ const runningDisabled = baseDisabled + ((p.status_counts?.["禁用"]) ?? 0);
+ let runningQuota: string | number;
+ if (baseHasUnlimited) {
+ runningQuota = "∞";
+ } else if (baseHasUnknown) {
+ runningQuota = "未知";
+ } else {
+ runningQuota = formatCompact(baseQuotaNum + (p.total_quota ?? 0));
+ }
+ setRefreshSummary({
+ total: accounts.length,
+ active: runningActive,
+ limited: runningLimited,
+ abnormal: runningAbnormal,
+ disabled: runningDisabled,
+ quota: runningQuota,
+ });
+ }
+ } catch (err) {
+ clearInterval(pollTimer);
+ reject(err);
+ }
+ }, 300);
+ });
+
+ // 刷新完成,更新数据
+ setAccounts(data.items);
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
+
+ const relogined = data.relogined ?? 0;
+
+ // 显示重新登录进度
+ if (relogined > 0) {
+ setProgress({
+ visible: true,
+ current: 0,
+ total: relogined,
+ message: `正在尝试对 ${relogined} 个账号进行移除异常状态`,
+ email: "",
+ });
+ // 模拟重新登录进度
+ let reCount = 0;
+ await new Promise((resolve) => {
+ const timer = setInterval(() => {
+ reCount += 1;
+ if (reCount >= relogined) {
+ clearInterval(timer);
+ setProgress({
+ visible: true,
+ current: relogined,
+ total: relogined,
+ message: "移除异常状态完成",
+ email: "",
+ });
+ setTimeout(() => setProgress({ visible: false, current: 0, total: 0, message: "", email: "" }), 800);
+ resolve();
+ } else {
+ setProgress((prev) => ({ ...prev, current: reCount }));
+ }
+ }, 150);
+ setTimeout(resolve, 2000);
+ });
+ } else {
+ setProgress({
+ visible: true,
+ current: total,
+ total,
+ message: "刷新完成",
+ email: "",
+ });
+ setTimeout(() => setProgress({ visible: false, current: 0, total: 0, message: "", email: "" }), 800);
+ }
+
+ if ((data.errors ?? []).length > 0) {
+ const firstError = data.errors?.[0]?.error;
+ toast.error(
+ `刷新成功 ${data.refreshed} 个,失败 ${(data.errors ?? []).length} 个${firstError ? `,首个错误:${firstError}` : ""}`,
+ );
+ } else {
+ toast.success(`刷新成功 ${data.refreshed} 个账户${relogined > 0 ? `,已触发 ${relogined} 个账号重新登录` : ""}`);
+ }
+ } catch (error) {
+ setProgress({ visible: false, current: 0, total: 0, message: "", email: "" });
+ setRefreshSummary(null);
+ const message = error instanceof Error ? error.message : "刷新账户失败";
+ toast.error(message);
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const pollRefreshProgress = async (
+ progressId: string,
+ onUpdate: (p: RefreshProgressResponse) => void,
+ ): Promise => {
+ return new Promise((resolve, reject) => {
+ const timer = setInterval(async () => {
+ try {
+ const p = await fetchRefreshProgress(progressId);
+ if (p.done) {
+ clearInterval(timer);
+ if (p.error) {
+ reject(new Error(p.error));
+ } else {
+ onUpdate(p);
+ resolve();
+ }
+ }
+ } catch (err) {
+ clearInterval(timer);
+ reject(err);
+ }
+ }, 500);
+ });
+ };
+
+ const handleReLogin = async (accessTokens: string[]) => {
+ if (accessTokens.length === 0) {
+ toast.error("请先选择要恢复的账户");
+ return;
+ }
+
+ // 只处理异常账号,过滤非异常账号
+ const abnormalTokens = accessTokens.filter((token) => {
+ const account = accounts.find((a) => a.access_token === token);
+ return account?.status === "异常";
+ });
+
+ if (abnormalTokens.length === 0) {
+ toast.error("选中账号中没有异常账号");
+ return;
+ }
+
+ if (abnormalTokens.length < accessTokens.length) {
+ toast.info(`已过滤 ${accessTokens.length - abnormalTokens.length} 个非异常账号`);
+ }
+
+ setIsRelogining(true);
+
+ // 计算非选中账号的基数(统计卡片联动用)
+ const selectedTokenSet = new Set(abnormalTokens);
+ const baseAccountsList = accounts.filter((a) => !selectedTokenSet.has(a.access_token));
+ const baseActive = baseAccountsList.filter((a) => a.status === "正常").length;
+ const baseLimited = baseAccountsList.filter((a) => a.status === "限流").length;
+ const baseAbnormal = baseAccountsList.filter((a) => a.status === "异常").length;
+ const baseDisabled = baseAccountsList.filter((a) => a.status === "禁用").length;
+
+ // 显示进度条(真实进度)
+ const total = abnormalTokens.length;
+ setProgress({ visible: true, current: 0, total, message: "正在尝试恢复异常账号...", email: "" });
+
+ try {
+ const { progress_id } = await reLoginAccounts(abnormalTokens);
+
+ // 轮询进度到完成
+ await new Promise((resolve, reject) => {
+ const pollTimer = setInterval(async () => {
+ try {
+ const p = await fetchReLoginProgress(progress_id);
+ if (p.done) {
+ clearInterval(pollTimer);
+ if (p.error) {
+ reject(new Error(p.error));
+ return;
+ }
+ setProgress((prev) => ({ ...prev, current: prev.total, message: "恢复流程已完成" }));
+ setRefreshSummary(null);
+ resolve();
+ } else {
+ // 实时更新进度
+ const results = p.results ?? [];
+ // 找到最新一条有错误的结果
+ const lastErrorResult = [...results].reverse().find((r) => r.error);
+ const emailHint = lastErrorResult
+ ? `失败: ${lastErrorResult.token} ${lastErrorResult.error ?? ""}`
+ : `已处理 ${p.processed}/${p.total}`;
+ setProgress((prev) => ({
+ ...prev,
+ current: p.processed,
+ email: emailHint,
+ message: "正在尝试恢复异常账号...",
+ }));
+
+ // 实时更新统计卡片:基数 + 已处理的恢复结果
+ let runningActive = baseActive;
+ let runningAbnormal = baseAbnormal;
+ let runningDisabled = baseDisabled;
+ for (const r of results) {
+ if (r.status === "成功") {
+ runningActive += 1;
+ runningAbnormal -= 1;
+ } else if (r.status === "禁用") {
+ runningDisabled += 1;
+ runningAbnormal -= 1;
+ }
+ // "异常"或"跳过":保持异常状态不变
+ }
+ setRefreshSummary({
+ total: accounts.length,
+ active: runningActive,
+ limited: baseLimited,
+ abnormal: runningAbnormal,
+ disabled: runningDisabled,
+ quota: summary.quota,
+ });
+ }
+ } catch (err) {
+ clearInterval(pollTimer);
+ reject(err);
+ }
+ }, 300);
+ });
+
+ // 等待后台线程完成,再拉取最新数据
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ try {
+ const freshData = await fetchAccounts();
+ setAccounts(freshData.items);
+ setSelectedIds((prev) => prev.filter((id) => freshData.items.some((item) => item.access_token === id)));
+ } catch { /* 静默失败 */ }
+
+ setProgress({
+ visible: true,
+ current: total,
+ total,
+ message: "恢复完成",
+ email: "",
+ });
+ setTimeout(() => setProgress({ visible: false, current: 0, total: 0, message: "", email: "" }), 800);
+
+ toast.success(`恢复流程已全部完成`);
+ } catch (error) {
+ setProgress({ visible: false, current: 0, total: 0, message: "", email: "" });
+ setRefreshSummary(null);
+ const message = error instanceof Error ? error.message : "重新登录失败";
+ toast.error(message);
+ } finally {
+ setIsRelogining(false);
+ }
+ };
+
+ const openEditDialog = (account: Account) => {
+ setEditingAccount(account);
+ setEditStatus(account.status);
+ setEditProxy(account.proxy ?? "");
+ };
+
+ const handleTestAccountProxy = async () => {
+ const candidate = editProxy.trim();
+ if (!candidate) {
+ toast.error("请先填写代理地址");
+ return;
+ }
+ setIsTestingProxy(true);
+ try {
+ const data = await testProxy(candidate);
+ data.result.ok
+ ? toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`)
+ : toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "测试代理失败");
+ } finally {
+ setIsTestingProxy(false);
+ }
+ };
+
+ const handleUpdateAccount = async () => {
+ if (!editingAccount) {
+ return;
+ }
+
+ setIsUpdating(true);
+ try {
+ const data = await updateAccount(editingAccount.access_token, {
+ status: editStatus,
+ proxy: editProxy.trim(),
+ });
+ setAccounts(data.items);
+ setSelectedIds((prev) => prev.filter((id) => data.items.some((item) => item.access_token === id)));
+ setEditingAccount(null);
+ toast.success("账号信息已更新");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "更新账号失败";
+ toast.error(message);
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ const toggleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedIds((prev) => Array.from(new Set([...prev, ...currentRows.map((item) => item.access_token)])));
+ return;
+ }
+ setSelectedIds((prev) => prev.filter((id) => !currentRows.some((row) => row.access_token === id)));
+ };
+
+ return (
+ <>
+
+
+
+ Account Pool
+
+
号池管理
+
+
+
+
+
+
{
+ setAccounts(items);
+ setSelectedIds([]);
+ setPage(1);
+ }}
+ />
+
+
+
+
+ {/* 进度条 */}
+ {progress.visible && (
+
+
+
+
+ {progress.message}
+ {progress.email && {progress.email}}
+
+
+ {progress.current}/{progress.total}
+
+
+
+
0 ? (progress.current / progress.total) * 100 : 0}%` }}
+ />
+
+
+
+ )}
+
+
+
+
+
+ {metricCards.map((item) => {
+ const Icon = item.icon;
+ const value = (refreshSummary ?? summary)[item.key];
+ return (
+
+
+
+ {item.label}
+
+
+
+
+ {typeof value === "number" ? formatCompact(value) : value}
+
+
+
+
+ );
+ })}
+
+
+
+
+ 系统可用模型
+ ({availableModels.length})
+
+
+ {availableModels.length > 0 ? (
+ availableModels.map((model) => (
+
+ ))
+ ) : isLoadingModels ? (
+
正在加载模型列表...
+ ) : (
+
当前暂无可用模型
+ )}
+
+
+
+
+
+
+
+
+
账户列表
+
+ {filteredAccounts.length}
+
+
+
+
+
+
+ {
+ setQuery(event.target.value);
+ setPage(1);
+ }}
+ placeholder="搜索邮箱"
+ className="h-10 rounded-xl border-stone-200 bg-white/85 pl-10"
+ />
+
+
+
+
+
+
+ {isLoading && accounts.length === 0 ? (
+
+
+
+
+
+
+
正在加载账户
+
从后端同步账号列表和状态。
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {selectedIds.length > 0 ? (
+
+ 已选择 {selectedIds.length} 项
+
+ ) : null}
+
+
+
+
+
+
+
+ |
+ toggleSelectAll(Boolean(checked))}
+ />
+ |
+ token |
+ 类型 |
+ 来源 |
+ 状态 |
+ 账号信息 |
+ 创建时间 |
+ 额度 |
+ 恢复时间 |
+ 成功 |
+ 失败 |
+ 操作 |
+
+
+
+ {currentRows.map((account) => {
+ const status = statusMeta[account.status];
+ const StatusIcon = status.icon;
+
+ return (
+
+ |
+ {
+ setSelectedIds((prev) =>
+ checked
+ ? Array.from(new Set([...prev, account.access_token]))
+ : prev.filter((item) => item !== account.access_token),
+ );
+ }}
+ />
+ |
+
+
+
+ {maskToken(account.access_token)}
+
+
+
+ |
+
+
+ {displayAccountType(account)}
+
+ |
+
+
+ {displayAccountSource(account)}
+
+ |
+
+
+
+ {account.status}
+
+ |
+
+ {account.email ?? "—"}
+ |
+
+ {(() => {
+ const raw = (account as any).created_at;
+ if (!raw) return "—";
+ try {
+ const d = new Date(raw + "Z");
+ if (isNaN(d.getTime())) return String(raw).slice(0, 10);
+ return d.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
+ } catch { return String(raw).slice(0, 10); }
+ })()}
+ |
+
+
+ {formatQuota(account)}
+
+ |
+
+ {(() => {
+ const restore = formatRestoreAt(account.restore_at);
+ return (
+
+ {restore.relative ? {restore.relative} : null}
+ {restore.absolute}
+
+ );
+ })()}
+ |
+ {account.success} |
+ {account.fail} |
+
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+ {!isLoading && currentRows.length === 0 ? (
+
+
+
+
+
+
没有匹配的账户
+
调整筛选条件或搜索关键字后重试。
+
+
+ ) : null}
+
+
+
+
+
+ 显示第 {filteredAccounts.length === 0 ? 0 : startIndex + 1} -{" "}
+ {Math.min(startIndex + Number(pageSize), filteredAccounts.length)} 条,共{" "}
+ {filteredAccounts.length} 条
+
+
+
+ {safePage} / {pageCount} 页
+
+
+
+ {paginationItems.map((item, index) =>
+ item === "..." ? (
+
+ ...
+
+ ) : (
+
+ ),
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+export default function AccountsPage() {
+ const { isCheckingAuth, session } = useAuthGuard(["admin"]);
+
+ if (isCheckingAuth || !session || session.role !== "admin") {
+ return (
+
+
+
+ );
+ }
+
+ return
;
+}
diff --git a/web/src/app/debug/components/chat-panel.tsx b/web/src/app/debug/components/chat-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a74d099a3ee2b008e10cd7d005944d2b65608db3
--- /dev/null
+++ b/web/src/app/debug/components/chat-panel.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useState } from "react";
+import { LoaderCircle, Send } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { httpRequest } from "@/lib/request";
+
+import { pretty, type ChatCompletionResponse, type ChatMessage } from "./types";
+
+export function ChatPanel() {
+ const [model, setModel] = useState("auto");
+ const [input, setInput] = useState("你好,先记住我的项目叫 chatgpt2api。");
+ const [messages, setMessages] = useState
([]);
+ const [raw, setRaw] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const sendChat = async () => {
+ const content = input.trim();
+ if (!content) return;
+ const nextMessages: ChatMessage[] = [...messages, { role: "user", content }];
+ setMessages(nextMessages);
+ setInput("");
+ setLoading(true);
+ setError("");
+ try {
+ const result = await httpRequest("/v1/chat/completions", { method: "POST", body: { model: model.trim() || "auto", messages: nextMessages } });
+ setRaw(result);
+ setMessages([...nextMessages, { role: "assistant", content: String(result.choices?.[0]?.message?.content || "") }]);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const clearChat = () => {
+ setMessages([]);
+ setRaw(null);
+ setError("");
+ };
+
+ return (
+
+
+
+
请求
+
+
+
+
+ setModel(event.target.value)} className="rounded-md border-stone-200/70 bg-transparent shadow-none dark:border-white/10" />
+
+
+
+
+
+
+
+
+ {error ?
{error}
: null}
+
+
+
+
+
+
对话
+
+
+ {messages.length ? messages.map((message, index) => (
+
+
{message.role}
+
{message.content}
+
+ )) : (
+
暂无对话消息
+ )}
+
+
+
+ );
+}
diff --git a/web/src/app/debug/components/editable-file-panel.tsx b/web/src/app/debug/components/editable-file-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a356871514b30ff8549e17467423ecc9680a6082
--- /dev/null
+++ b/web/src/app/debug/components/editable-file-panel.tsx
@@ -0,0 +1,443 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type { ReactNode } from "react";
+import { AlertCircle, CheckCircle2, Clock3, FileArchive, FileText, History, ImagePlus, LoaderCircle, Pencil, Play, Plus, RefreshCw, Trash2, XCircle } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { httpRequest } from "@/lib/request";
+import { cn } from "@/lib/utils";
+import {
+ listDeletedEditableFileIds,
+ listEditableFileDrafts,
+ saveDeletedEditableFileIds,
+ saveEditableFileDrafts,
+ type EditableFileDraft,
+} from "@/store/editable-file-history";
+
+import type { EditableFileTask } from "./types";
+
+type Props = {
+ title: string;
+ kind: "ppt" | "psd";
+ endpoint: string;
+ defaultPrompt: string;
+ imageRequired?: boolean;
+};
+
+const MAX_HISTORY = 20;
+const DRAFT_ID = "__draft__";
+const taskIdOf = (task: EditableFileTask | null | undefined) => task?.taskId || task?.id || "";
+const isRunning = (task: EditableFileTask | null | undefined) => task?.status === "queued" || task?.status === "running";
+const statusText = (status: string) => ({ queued: "排队中", running: "生成中", success: "已完成", error: "失败" }[status] || status);
+const statusClass = (status: string) => status === "success" ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/20 dark:text-emerald-300" : status === "error" ? "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/20 dark:text-rose-300" : "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/20 dark:text-amber-300";
+const formatElapsed = (seconds: number) => `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
+const titleOfPrompt = (prompt: string, fallback: string) => prompt.trim().replace(/\s+/g, " ").slice(0, 24) || fallback;
+
+const readFile = (file: File) => new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ""));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+});
+
+const mergeTasks = (current: EditableFileTask[], updates: EditableFileTask[]) => {
+ const next = [...current];
+ for (const update of updates) {
+ const id = taskIdOf(update);
+ if (!id) continue;
+ const index = next.findIndex((item) => taskIdOf(item) === id);
+ const merged = { ...(index >= 0 ? next[index] : {}), ...update, polled_at: Date.now() };
+ if (index >= 0) next[index] = merged;
+ else next.unshift(merged);
+ }
+ return next.slice(0, MAX_HISTORY);
+};
+
+const removeTasks = (current: EditableFileTask[], ids: string[]) => {
+ const missing = new Set(ids);
+ return missing.size ? current.filter((task) => !missing.has(taskIdOf(task))) : current;
+};
+
+const fileNameOf = (url: string) => {
+ try {
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() || "");
+ } catch {
+ return decodeURIComponent(url.split("/").pop() || "");
+ }
+};
+
+function ResultFile({ href, icon, label }: { href?: string; icon: ReactNode; label: string }) {
+ if (!href) return null;
+ return (
+
+
+ {icon}
+
+
+
{label}
+
{fileNameOf(href)}
+
+
+
+ );
+}
+
+export function EditableFilePanel({ title, kind, endpoint, defaultPrompt, imageRequired }: Props) {
+ const [prompt, setPrompt] = useState(defaultPrompt);
+ const [images, setImages] = useState([]);
+ const [tasks, setTasks] = useState([]);
+ const [selectedId, setSelectedId] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [polling, setPolling] = useState(false);
+ const [error, setError] = useState("");
+ const [now, setNow] = useState(Date.now());
+ const [drafts, setDrafts] = useState>({});
+ const [deletedIds, setDeletedIds] = useState>(new Set());
+ const [renamingId, setRenamingId] = useState("");
+ const [renamingTitle, setRenamingTitle] = useState("");
+ const [deleteConfirm, setDeleteConfirm] = useState<{ type: "one"; id: string } | { type: "all"; ids: string[] } | null>(null);
+ const visibleTasks = useMemo(() => tasks.filter((task) => task.kind === kind && !deletedIds.has(taskIdOf(task))).slice(0, MAX_HISTORY), [deletedIds, kind, tasks]);
+ const selectedTask = selectedId === DRAFT_ID ? null : visibleTasks.find((task) => taskIdOf(task) === selectedId) || visibleTasks[0] || null;
+ const running = visibleTasks.some(isRunning);
+ const runningIds = visibleTasks.filter(isRunning).map(taskIdOf).join(",");
+
+ const elapsedOf = (task: EditableFileTask | null | undefined) => {
+ if (!task) return 0;
+ const base = Math.max(0, Number(task.elapsed_seconds || 0));
+ return Math.max(0, isRunning(task) && task.polled_at ? base + Math.floor((now - task.polled_at) / 1000) : base);
+ };
+
+ const fetchTasks = useCallback(async (ids: string[] = []) => {
+ const taskIds = Array.from(new Set(ids.filter(Boolean))).slice(0, MAX_HISTORY);
+ setPolling(true);
+ try {
+ const path = taskIds.length ? `/v1/editable-file-tasks?ids=${taskIds.map(encodeURIComponent).join(",")}` : "/v1/editable-file-tasks";
+ const result = await httpRequest<{ items: EditableFileTask[]; missing_ids?: string[] }>(path);
+ const missingIds = result.missing_ids || [];
+ const hidden = await listDeletedEditableFileIds(kind);
+ setTasks((current) => (taskIds.length ? mergeTasks(removeTasks(current, missingIds), result.items || []) : (result.items || [])).filter((task) => !hidden.has(taskIdOf(task))));
+ setSelectedId((current) => missingIds.includes(current) ? "" : current);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setPolling(false);
+ }
+ }, [kind]);
+
+ useEffect(() => {
+ void listEditableFileDrafts(kind).then(setDrafts);
+ void listDeletedEditableFileIds(kind).then(setDeletedIds);
+ void fetchTasks();
+ }, [fetchTasks, kind]);
+
+ useEffect(() => {
+ if (selectedId === DRAFT_ID) return;
+ const draft = drafts[selectedId];
+ const task = visibleTasks.find((item) => taskIdOf(item) === selectedId);
+ setPrompt(draft?.prompt || task?.prompt_preview || defaultPrompt);
+ setImages(Array.isArray(draft?.images) ? draft.images : []);
+ }, [defaultPrompt, drafts, selectedId, visibleTasks]);
+
+ useEffect(() => {
+ if (selectedId === DRAFT_ID) return;
+ if (visibleTasks.length && (!selectedId || !visibleTasks.some((task) => taskIdOf(task) === selectedId))) setSelectedId(taskIdOf(visibleTasks[0]));
+ if (!visibleTasks.length && selectedId) setSelectedId("");
+ }, [selectedId, visibleTasks]);
+
+ useEffect(() => {
+ if (!running) return;
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, [running]);
+
+ useEffect(() => {
+ const ids = runningIds.split(",").filter(Boolean);
+ if (!ids.length) return;
+ const timer = window.setInterval(() => void fetchTasks(ids), 5000);
+ return () => window.clearInterval(timer);
+ }, [fetchTasks, runningIds]);
+
+ const appendFiles = async (files: FileList | null) => {
+ if (!files?.length) return;
+ const values = await Promise.all(Array.from(files).map(readFile));
+ setImages((current) => [...current, ...values]);
+ };
+
+ const persistDrafts = (updater: (current: Record) => Record) => {
+ setDrafts((current) => {
+ const next = updater(current);
+ void saveEditableFileDrafts(kind, next);
+ return next;
+ });
+ };
+
+ const createDraft = () => {
+ setError("");
+ setSelectedId(DRAFT_ID);
+ setPrompt(defaultPrompt);
+ setImages([]);
+ };
+
+ const renameTask = (id: string, title: string) => {
+ const trimmed = title.trim();
+ if (!id || !trimmed) return;
+ persistDrafts((current) => ({ ...current, [id]: { ...(current[id] || {}), title: trimmed } }));
+ };
+
+ const deleteTask = (id: string) => {
+ if (!id) return;
+ const nextDeleted = new Set(deletedIds);
+ nextDeleted.add(id);
+ void saveDeletedEditableFileIds(kind, nextDeleted);
+ setDeletedIds(nextDeleted);
+ setTasks((current) => current.filter((task) => taskIdOf(task) !== id));
+ persistDrafts((current) => {
+ const next = { ...current };
+ delete next[id];
+ return next;
+ });
+ if (selectedId === id) setSelectedId("");
+ };
+
+ const clearHistory = () => {
+ const ids = deleteConfirm?.type === "all" ? deleteConfirm.ids : tasks.filter((task) => task.kind === kind).map(taskIdOf).filter(Boolean);
+ if (!ids.length) return;
+ const nextDeleted = new Set([...deletedIds, ...ids]);
+ void saveDeletedEditableFileIds(kind, nextDeleted);
+ setDeletedIds(nextDeleted);
+ setTasks((current) => current.filter((task) => task.kind !== kind || !ids.includes(taskIdOf(task))));
+ persistDrafts((current) => {
+ const next = { ...current };
+ ids.forEach((id) => delete next[id]);
+ return next;
+ });
+ setSelectedId("");
+ };
+
+ const confirmDelete = () => {
+ if (!deleteConfirm) return;
+ if (deleteConfirm.type === "one") deleteTask(deleteConfirm.id);
+ else clearHistory();
+ setDeleteConfirm(null);
+ };
+
+ const submit = async () => {
+ setError("");
+ setSubmitting(true);
+ try {
+ const base64_images = images;
+ if (imageRequired && !base64_images.length) throw new Error("base64_images is empty");
+ const task = await httpRequest(endpoint, { method: "POST", body: { client_task_id: crypto.randomUUID(), prompt, base64_images } });
+ const polledAt = Date.now();
+ const id = taskIdOf(task);
+ const draft = { prompt: prompt.trim(), images: base64_images, title: titleOfPrompt(prompt, title) };
+ const nextTask = { ...task, prompt_preview: draft.prompt, polled_at: polledAt };
+ setNow(polledAt);
+ persistDrafts((current) => ({ ...current, [id]: draft }));
+ setTasks((current) => mergeTasks(current.filter((item) => taskIdOf(item) !== taskIdOf(nextTask)), [nextTask]));
+ setSelectedId(id);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const refreshAll = () => void fetchTasks();
+ const selectTask = (id: string) => {
+ setRenamingId("");
+ setSelectedId(id);
+ const draft = drafts[id];
+ const task = visibleTasks.find((item) => taskIdOf(item) === id);
+ setPrompt(draft?.prompt || task?.prompt_preview || defaultPrompt);
+ setImages(Array.isArray(draft?.images) ? draft.images : []);
+ };
+ const startRename = (id: string) => {
+ setRenamingId(id);
+ setRenamingTitle(drafts[id]?.title || titleOfPrompt(drafts[id]?.prompt || "", `${kind.toUpperCase()} 任务`));
+ };
+ const commitRename = () => {
+ renameTask(renamingId, renamingTitle);
+ setRenamingId("");
+ setRenamingTitle("");
+ };
+
+ return (
+ <>
+
+
+
+
+
+
{title}
+
+
+
+
+
+
+
+
+
+ {images.length}
+
+
+ {images.length ? (
+
+ {images.slice(0, 8).map((src, index) => (
+

+ ))}
+
+ ) : null}
+
+
+
+
+ {error ?
: null}
+
+
+
+
+
+
生成状态
+ {selectedTask ? {statusText(selectedTask.status)} : null}
+
+
+ {selectedTask ? (
+
+
+
+
状态
+
+ {selectedTask.status === "success" ? : selectedTask.status === "error" ? : }
+ {statusText(selectedTask.status)}
+
+
+
+
已执行
+
{formatElapsed(elapsedOf(selectedTask))}
+
+
+
Task ID
+
{taskIdOf(selectedTask)}
+
+
+
+ {selectedTask.result ? (
+
+
生成结果
+
} label={kind === "ppt" ? "PPT 文件" : "PSD 文件"} />
+
} label="素材包" />
+
+ ) : null}
+
+ {selectedTask.error ?
{selectedTask.error}
: null}
+
+ ) : (
+
暂无任务
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/debug/components/ppt-panel.tsx b/web/src/app/debug/components/ppt-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fb827383a6d05471e27390bdb4078056ec4713e0
--- /dev/null
+++ b/web/src/app/debug/components/ppt-panel.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import { EditableFilePanel } from "./editable-file-panel";
+
+const defaultPrompt = "需要制作一份《2026年Q2 电商运营工作汇报》PPT,用于公司管理层季度会议汇报,整体页数控制在 8 页以内,风格偏商务科技感。重点体现销售增长、用户增长、广告投放效果以及618活动成果,并通过折线图、柱状图、环形图、漏斗图呈现。";
+
+export function PptPanel() {
+ return ;
+}
diff --git a/web/src/app/debug/components/psd-panel.tsx b/web/src/app/debug/components/psd-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..576a06fb4a56d9e22ce9610a846dbf187adfce50
--- /dev/null
+++ b/web/src/app/debug/components/psd-panel.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import { EditableFilePanel } from "./editable-file-panel";
+
+const defaultPrompt = "按原图位置拆分海报元素并合成可编辑 PSD,保留背景和每个元素图层位置,同时输出每个图层素材 zip。";
+
+export function PsdPanel() {
+ return ;
+}
diff --git a/web/src/app/debug/components/search-panel.tsx b/web/src/app/debug/components/search-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cd0ff4c13bc56c9367f06f3fe802030ebe62d821
--- /dev/null
+++ b/web/src/app/debug/components/search-panel.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { ExternalLink, Globe2, LoaderCircle, Search } from "lucide-react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+import { httpRequest } from "@/lib/request";
+import { cn } from "@/lib/utils";
+
+import type { SearchResult } from "./types";
+
+const normalizeMarkdown = (text: string) =>
+ text
+ .replace(/\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201/g, "[$1]($2)")
+ .replace(/\ue200cite\ue202[^\ue201]*\ue201/g, "")
+ .replace(/\ue200[^\ue201]*\ue201/g, "")
+ .replace(/\ue200[^\ue201]*$/g, "")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+
+const cleanUrl = (url: string) => url.replace(/[\ue200-\ue202].*$/g, "").trim();
+
+const sourceKind = (url: string) => {
+ const host = (() => {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return "";
+ }
+ })();
+ return host.includes("github.com") ? "github" : "web";
+};
+
+function MarkdownResult({ content }: { content: string }) {
+ return (
+ ,
+ h1: ({ className, ...props }) => ,
+ h2: ({ className, ...props }) => ,
+ h3: ({ className, ...props }) => ,
+ p: ({ className, ...props }) => ,
+ ul: ({ className, ...props }) => ,
+ ol: ({ className, ...props }) =>
,
+ blockquote: ({ className, ...props }) => ,
+ code: ({ className, ...props }) => ,
+ pre: ({ className, ...props }) => ,
+ table: ({ className, ...props }) => ,
+ th: ({ className, ...props }) => | ,
+ td: ({ className, ...props }) => | ,
+ }}
+ >
+ {content}
+
+ );
+}
+
+export function SearchPanel() {
+ const [prompt, setPrompt] = useState("帮我搜索 chatgpt2api 相关项目");
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const [startedAt, setStartedAt] = useState(0);
+ const searched = loading || !!result || !!error;
+
+ useEffect(() => {
+ if (!loading || !startedAt) return;
+ const timer = window.setInterval(() => setElapsedMs(Date.now() - startedAt), 100);
+ return () => window.clearInterval(timer);
+ }, [loading, startedAt]);
+
+ const runSearch = async () => {
+ const value = prompt.trim();
+ if (!value || loading) return;
+ const start = Date.now();
+ setStartedAt(start);
+ setElapsedMs(0);
+ setLoading(true);
+ setError("");
+ setResult(null);
+ try {
+ setResult(await httpRequest("/v1/search", { method: "POST", body: { prompt: value } }));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setElapsedMs(Date.now() - start);
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {!searched ? (
+
利用ChatGPT先进的网页搜索功能进行搜索
+ ) : null}
+
+
+
+ {searched ?
+ {loading ? (
+
+
+ 搜索中... {(elapsedMs / 1000).toFixed(1)}s
+
+ ) : null}
+
+ {error ?
{error}
: null}
+
+ {result ? (
+
+
+
+ {result.status || "done"}
+ {(elapsedMs / 1000).toFixed(2)}s
+ {result.sources?.length || 0} sources
+
+
+
+
+
+ {result.sources?.length ? (
+
+ ) : null}
+
+ ) : null}
+
: null}
+
+ );
+}
diff --git a/web/src/app/debug/components/skill-panel.tsx b/web/src/app/debug/components/skill-panel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..18646044edfde0947e685b76dfdd344e44f9709c
--- /dev/null
+++ b/web/src/app/debug/components/skill-panel.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Copy, Download } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import webConfig from "@/constants/common-env";
+import { fetchSettingsConfig } from "@/lib/api";
+import { getStoredAuthSession } from "@/store/auth";
+
+export function SkillPanel() {
+ const [browserBaseUrl, setBrowserBaseUrl] = useState("");
+ const [configuredBaseUrl, setConfiguredBaseUrl] = useState("");
+ const [authKey, setAuthKey] = useState("");
+
+ useEffect(() => {
+ setBrowserBaseUrl(window.location.origin);
+ void fetchSettingsConfig().then((data) => setConfiguredBaseUrl(String(data.config.base_url || "").replace(/\/$/, ""))).catch(() => undefined);
+ void getStoredAuthSession().then((session) => setAuthKey(session?.key || ""));
+ }, []);
+
+ const apiBaseUrl = configuredBaseUrl || webConfig.apiUrl.replace(/\/$/, "") || browserBaseUrl;
+ const skillZh = useMemo(() => `---
+name: chatgpt2api-search
+description: 当用户需要联网搜索、查询最新信息、核实事实或需要来源链接时,调用本地 chatgpt2api 搜索接口。
+---
+
+# ChatGPT2API 搜索
+
+当用户要求联网搜索、查询最新信息、核实资料、查新闻、查价格、查文档更新或需要来源链接时,使用这个 skill。
+
+## 接口
+
+POST ${apiBaseUrl}/v1/search
+
+Headers:
+
+Authorization: Bearer ${authKey}
+Content-Type: application/json
+
+Body:
+
+{
+ "prompt": "<用户要搜索的问题>"
+}
+
+## 返回处理
+
+- 使用接口返回的 \`answer\` 作为主要回答。
+- 如果有 \`sources\`,在回答里附上来源链接。
+- 如果接口报错,简要说明错误并询问是否重试。`, [apiBaseUrl, authKey]);
+
+ const skillEn = useMemo(() => `---
+name: chatgpt2api-search
+description: Use when current web search is needed through this chatgpt2api server. Call the configured HTTP search endpoint with a prompt and return the answer with source URLs.
+---
+
+# ChatGPT2API Search
+
+Use this skill when the user asks for current web search, online lookup, recent information, or source-backed answers. It calls the local chatgpt2api search endpoint and returns an answer with source links.
+
+## When to use
+
+- The user asks to search the web, look something up, verify current information, or find the latest status.
+- The answer needs source URLs, recent details, prices, releases, docs, laws, schedules, or news.
+- Do not use it for purely local codebase questions unless the user explicitly asks for web search.
+
+## Request
+
+POST ${apiBaseUrl}/v1/search
+
+Headers:
+
+Authorization: Bearer ${authKey}
+Content-Type: application/json
+
+JSON body:
+
+{
+ "prompt": ""
+}
+
+## Response handling
+
+- Use \`answer\` as the main response.
+- Include source URLs from \`sources\` when available.
+- If the endpoint returns an error, summarize the error and ask the user whether to retry.
+- Keep the final answer concise unless the user asks for detail.`, [apiBaseUrl, authKey]);
+
+ const zhPrompt = useMemo(() => `请帮我在本机安装一个用于联网搜索的 skill。
+
+要求:
+1. 请按你当前环境的 skill 安装规范,把它安装成本地 skill。
+2. skill 名称为:chatgpt2api-search
+3. 文件名为:SKILL.md
+4. 如果你无法确定本地 skills 目录在哪里,先告诉我需要放到哪个目录,不要猜路径。
+5. 只创建或更新这个 skill 文件,不要修改其他无关文件。
+6. SKILL.md 请写入下面的完整内容。
+
+SKILL.md 内容:
+
+\`\`\`markdown
+${skillZh}
+\`\`\``, [skillZh]);
+
+ const enPrompt = useMemo(() => `Please install a local web-search skill on this machine.
+
+Requirements:
+1. Install this as a local skill according to the skill installation rules of your current environment.
+2. Skill name: chatgpt2api-search
+3. File name: SKILL.md
+4. If you cannot determine the local skills directory, tell me which directory is required before writing files.
+5. Only create or update this skill file. Do not modify unrelated files.
+6. Write the full content below into SKILL.md.
+
+SKILL.md content:
+
+\`\`\`markdown
+${skillEn}
+\`\`\``, [skillEn]);
+
+ const copyText = async (text: string) => {
+ await navigator.clipboard.writeText(text);
+ toast.success("已复制");
+ };
+
+ const downloadSkill = (text: string) => {
+ const url = URL.createObjectURL(new Blob([text], { type: "text/markdown;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "SKILL.md";
+ link.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const versions = [
+ { title: "中文安装指令", desc: "复制后直接发给 Codex 或 Claude,让它安装到本地。", prompt: zhPrompt, skill: skillZh },
+ { title: "English install prompt", desc: "Copy and send this to Codex or Claude to install locally.", prompt: enPrompt, skill: skillEn },
+ ];
+
+ return (
+
+ {versions.map((item) => (
+
+
+
+
{item.title}
+
{item.desc}
+
+
+
+
+
+
+
+ {item.prompt}
+
+
+ ))}
+
+ );
+}
diff --git a/web/src/app/debug/components/types.ts b/web/src/app/debug/components/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aae1ce76109da26972eb762807bf1db2605c8a8d
--- /dev/null
+++ b/web/src/app/debug/components/types.ts
@@ -0,0 +1,35 @@
+export type SearchResult = {
+ conversation_id: string;
+ status: string;
+ answer: string;
+ sources: Array<{ title?: string; url?: string; snippet?: string; source_type?: string }>;
+};
+
+export type ChatMessage = {
+ role: "system" | "user" | "assistant";
+ content: string;
+};
+
+export type ChatCompletionResponse = {
+ choices?: Array<{ message?: { role?: string; content?: string } }>;
+};
+
+export type EditableFileTask = {
+ id: string;
+ taskId?: string;
+ status: "queued" | "running" | "success" | "error" | string;
+ kind: "ppt" | "psd" | string;
+ created_at?: string;
+ updated_at?: string;
+ elapsed_seconds?: number;
+ polled_at?: number;
+ prompt_preview?: string;
+ error?: string;
+ result?: {
+ conversation_id?: string;
+ primary_url?: string;
+ zip_url?: string;
+ };
+};
+
+export const pretty = (value: unknown) => JSON.stringify(value, null, 2);
diff --git a/web/src/app/debug/page.tsx b/web/src/app/debug/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..45fa1f92fdf29fdf725fd2ec4d6a8e257e80dcda
--- /dev/null
+++ b/web/src/app/debug/page.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { LoaderCircle } from "lucide-react";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useAuthGuard } from "@/lib/use-auth-guard";
+
+import { ChatPanel } from "./components/chat-panel";
+import { PptPanel } from "./components/ppt-panel";
+import { PsdPanel } from "./components/psd-panel";
+import { SearchPanel } from "./components/search-panel";
+import { SkillPanel } from "./components/skill-panel";
+
+const tabs = [
+ { value: "skills", title: "搜索Skills" },
+ { value: "search", title: "搜索" },
+ { value: "ppt", title: "PPT生成" },
+ { value: "psd", title: "PSD生成" },
+ { value: "chat", title: "对话" },
+];
+
+export default function DebugPage() {
+ const { isCheckingAuth, session } = useAuthGuard(["admin"]);
+
+ if (isCheckingAuth || !session || session.role !== "admin") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {tabs.map(({ value, title }) => (
+
+ {title}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/favicon.ico b/web/src/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c
Binary files /dev/null and b/web/src/app/favicon.ico differ
diff --git a/web/src/app/globals.css b/web/src/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..807ddcb55e78b8c7365a0c52d8de3f16bc1a6efa
--- /dev/null
+++ b/web/src/app/globals.css
@@ -0,0 +1,231 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ html,
+ body {
+ background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), rgba(245, 239, 231, 0.96) 42%, rgba(240, 235, 227, 0.99) 100%);
+ overscroll-behavior: none;
+ }
+
+ .dark,
+ .dark body {
+ background: radial-gradient(circle at top left, rgba(55, 48, 43, 0.72), rgba(28, 25, 23, 0.98) 40%, rgba(12, 10, 9, 1) 100%);
+ }
+
+ body {
+ @apply text-foreground;
+ }
+}
+
+@layer utilities {
+ .hide-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .hide-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+.dark .bg-white,
+.dark .bg-white\/85,
+.dark .bg-white\/90,
+.dark .bg-white\/95,
+.dark .hover\:bg-white:hover {
+ background-color: rgb(28 25 23 / 0.94) !important;
+}
+
+.dark .bg-white\/80,
+.dark .bg-white\/70,
+.dark .bg-white\/60 {
+ background-color: rgb(41 37 36 / 0.78) !important;
+}
+
+.dark .bg-stone-50,
+.dark .bg-stone-50\/70,
+.dark .hover\:bg-stone-50\/70:hover,
+.dark .bg-stone-100,
+.dark .hover\:bg-stone-100:hover,
+.dark .hover\:bg-stone-200:hover {
+ background-color: rgb(41 37 36 / 0.86) !important;
+}
+
+.dark .bg-stone-200 {
+ background-color: rgb(68 64 60 / 0.9) !important;
+}
+
+.dark .border-white,
+.dark .border-white\/80,
+.dark .border-white\/70,
+.dark .border-stone-100,
+.dark .border-stone-100\/80,
+.dark .border-stone-100\/50,
+.dark .border-stone-200,
+.dark .border-stone-200\/70,
+.dark .border-stone-300,
+.dark .focus-visible\:border-stone-300:focus-visible {
+ border-color: rgb(255 255 255 / 0.11) !important;
+}
+
+.dark .text-stone-950,
+.dark .text-stone-900,
+.dark .text-stone-800 {
+ color: rgb(250 250 249) !important;
+}
+
+.dark .text-stone-700,
+.dark .text-stone-600 {
+ color: rgb(214 211 209) !important;
+}
+
+.dark .text-stone-500,
+.dark .text-stone-400 {
+ color: rgb(168 162 158) !important;
+}
+
+.dark .text-stone-300 {
+ color: rgb(214 211 209) !important;
+}
+
+.dark .placeholder\:text-stone-400::placeholder,
+.dark .placeholder\:text-muted-foreground::placeholder {
+ color: rgb(120 113 108) !important;
+}
+
+.dark .hover\:text-stone-700:hover,
+.dark .hover\:text-stone-800:hover,
+.dark .hover\:text-stone-900:hover {
+ color: rgb(250 250 249) !important;
+}
+
+.dark .shadow-sm,
+.dark .shadow-xs,
+.dark [class*="shadow-"] {
+ box-shadow: none !important;
+}
+
+@keyframes grow {
+ from { transform: scaleX(0); }
+ to { transform: scaleX(1); }
+}
+
+::view-transition-old(root), ::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
\ No newline at end of file
diff --git a/web/src/app/image-manager/page.tsx b/web/src/app/image-manager/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cc151d7724413c0f06583ec6a3e6f5e9354435c4
--- /dev/null
+++ b/web/src/app/image-manager/page.tsx
@@ -0,0 +1,732 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { CalendarDays, ChevronLeft, ChevronRight, Copy, Download, ImageIcon, LoaderCircle, Maximize2, Plus, RefreshCw, Search, Tag, Trash2, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { DateRangeFilter } from "@/components/date-range-filter";
+import { ImageLightbox } from "@/components/image-lightbox";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { compressAllImages, deleteImageTag, deleteManagedImages, deleteToTarget, downloadImages, downloadSingleImage, fetchImageStorage, fetchImageTags, fetchManagedImages, setImageTags, type ImageStorageStats, type ManagedImage } from "@/lib/api";
+import { useAuthGuard } from "@/lib/use-auth-guard";
+
+const LONG_PRESS_MS = 800;
+const IMAGE_MANAGER_CHECKBOX_CLASS = "border-stone-300 bg-white/80 dark:border-white/35 dark:bg-white/5 data-[state=checked]:border-stone-950 dark:data-[state=checked]:border-white";
+
+function formatSize(size: number) {
+ return size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(2)} MB` : `${Math.ceil(size / 1024)} KB`;
+}
+
+function imageKey(item: ManagedImage) {
+ return item.rel || item.url;
+}
+
+function useLongPress(onLongPress: () => void, ms = LONG_PRESS_MS) {
+ const timerRef = useRef | null>(null);
+ const activeRef = useRef(false);
+
+ const start = useCallback((e: React.MouseEvent | React.TouchEvent) => {
+ activeRef.current = true;
+ timerRef.current = setTimeout(() => {
+ if (activeRef.current) {
+ onLongPress();
+ }
+ }, ms);
+ }, [onLongPress, ms]);
+
+ const stop = useCallback(() => {
+ activeRef.current = false;
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ return {
+ onMouseDown: start,
+ onMouseUp: stop,
+ onMouseLeave: stop,
+ onTouchStart: start,
+ onTouchEnd: stop,
+ };
+}
+
+function ImageManagerContent() {
+ const [items, setItems] = useState([]);
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+ const [lightboxIndex, setLightboxIndex] = useState(0);
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [page, setPage] = useState(1);
+ const [isLoading, setIsLoading] = useState(true);
+ const [deleteStartDate, setDeleteStartDate] = useState("");
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [allTags, setAllTags] = useState([]);
+ const [storage, setStorage] = useState(null);
+ const [storageLoading, setStorageLoading] = useState(false);
+ const [compressResult, setCompressResult] = useState("");
+ const [targetFreeMb, setTargetFreeMb] = useState(500);
+
+ const loadStorage = useCallback(async () => {
+ try {
+ setStorageLoading(true);
+ const data = await fetchImageStorage();
+ setStorage(data);
+ } catch { /* ignore */ }
+ finally { setStorageLoading(false); }
+ }, []);
+
+ useEffect(() => { void loadStorage(); }, [loadStorage]);
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [tagEditTarget, setTagEditTarget] = useState(null);
+ const [tagInput, setTagInput] = useState("");
+ const [dialogVisible, setDialogVisible] = useState(false);
+ const deleteTargetRef = useRef(null);
+ const [selectedPaths, setSelectedPaths] = useState([]);
+ const [deleteMode, setDeleteMode] = useState<"selected" | "filtered" | "byDate" | null>(null);
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const filteredItems = selectedTags.length > 0
+ ? items.filter((item) => selectedTags.every((t) => (item.tags ?? []).includes(t)))
+ : items;
+
+ const lightboxImages = filteredItems.map((item) => ({
+ id: item.name,
+ src: item.url,
+ sizeLabel: formatSize(item.size),
+ dimensions: item.width && item.height ? `${item.width} x ${item.height}` : undefined,
+ }));
+ const pageSize = 12;
+ const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
+ const safePage = Math.min(page, pageCount);
+ const currentRows = filteredItems.slice((safePage - 1) * pageSize, safePage * pageSize);
+ const selectedSet = useMemo(() => new Set(selectedPaths), [selectedPaths]);
+ const selectedCount = deleteMode === "filtered" ? items.length : deleteMode === "byDate" ? 0 : selectedPaths.length;
+ const currentPageSelected = currentRows.length > 0 && currentRows.every((item) => selectedSet.has(imageKey(item)));
+ const allSelected = filteredItems.length > 0 && filteredItems.every((item) => selectedSet.has(imageKey(item)));
+
+ const loadImages = async () => {
+ setIsLoading(true);
+ try {
+ const [data, tagsData] = await Promise.all([
+ fetchManagedImages({ start_date: startDate, end_date: endDate }),
+ fetchImageTags(),
+ ]);
+ setItems(data.items);
+ setAllTags(tagsData.tags);
+ setSelectedPaths((current) => current.filter((path) => data.items.some((item) => imageKey(item) === path)));
+ setPage(1);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "加载图片失败");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const closeDialog = useCallback(() => {
+ setDialogVisible(false);
+ setTimeout(() => setDeleteTarget(null), 200);
+ }, []);
+
+ const openDeleteDialog = useCallback((item: ManagedImage) => {
+ deleteTargetRef.current = item;
+ setDeleteTarget(item);
+ setDialogVisible(true);
+ }, []);
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return;
+ setIsDeleting(true);
+ try {
+ await deleteManagedImages({ paths: [deleteTarget.rel] });
+ setItems((prev) => prev.filter((item) => item.rel !== deleteTarget.rel));
+ setSelectedPaths((prev) => prev.filter((p) => p !== imageKey(deleteTarget)));
+ toast.success("图片已删除");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "删除失败");
+ } finally {
+ setIsDeleting(false);
+ closeDialog();
+ }
+ };
+
+ const handleSetTags = async (item: ManagedImage, tags: string[]) => {
+ try {
+ const result = await setImageTags(item.rel, tags);
+ setItems((prev) => prev.map((i) => i.rel === item.rel ? { ...i, tags: result.tags } : i));
+ const tagsData = await fetchImageTags();
+ setAllTags(tagsData.tags);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "设置标签失败");
+ }
+ };
+
+ const handleAddTag = (item: ManagedImage) => {
+ const tag = tagInput.trim();
+ if (!tag) return;
+ const current = item.tags ?? [];
+ if (current.includes(tag)) {
+ toast.error("标签已存在");
+ return;
+ }
+ void handleSetTags(item, [...current, tag]);
+ setTagInput("");
+ };
+
+ const handleRemoveTag = (item: ManagedImage, tag: string) => {
+ void handleSetTags(item, (item.tags ?? []).filter((t) => t !== tag));
+ };
+
+ const toggleFilterTag = (tag: string) => {
+ setSelectedTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]);
+ setPage(1);
+ };
+
+ const [pressingTag, setPressingTag] = useState(null);
+ const pressTimerRef = useRef | null>(null);
+ const [tagDeleteTarget, setTagDeleteTarget] = useState(null);
+
+ const handleDeleteTag = async (tag: string) => {
+ try {
+ const result = await deleteImageTag(tag);
+ setAllTags((prev) => prev.filter((t) => t !== tag));
+ setSelectedTags((prev) => prev.filter((t) => t !== tag));
+ setItems((prev) => prev.map((item) => ({
+ ...item,
+ tags: (item.tags ?? []).filter((t) => t !== tag),
+ })));
+ toast.success(`标签"${tag}"已删除,影响 ${result.removed_from} 张图片`);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "删除标签失败");
+ }
+ };
+
+ const startTagPress = useCallback((tag: string) => {
+ setPressingTag(tag);
+ pressTimerRef.current = setTimeout(() => {
+ setPressingTag(null);
+ setTagDeleteTarget(tag);
+ }, LONG_PRESS_MS);
+ }, []);
+
+ const stopTagPress = useCallback(() => {
+ setPressingTag(null);
+ if (pressTimerRef.current) {
+ clearTimeout(pressTimerRef.current);
+ pressTimerRef.current = null;
+ }
+ }, []);
+
+ const clearFilters = () => {
+ setStartDate("");
+ setEndDate("");
+ setSelectedTags([]);
+ };
+
+ const togglePaths = (paths: string[], checked: boolean) => {
+ setSelectedPaths((current) => checked ? Array.from(new Set([...current, ...paths])) : current.filter((path) => !paths.includes(path)));
+ };
+
+ const confirmDelete = async () => {
+ if (!deleteMode || selectedCount === 0) return;
+ setIsDeleting(true);
+ try {
+ const data = await deleteManagedImages(deleteMode === "filtered" ? { start_date: startDate, end_date: endDate, all_matching: true } : { paths: selectedPaths });
+ toast.success(`已删除 ${data.removed} 张图片`);
+ setDeleteMode(null);
+ setSelectedPaths([]);
+ await loadImages();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "删除图片失败");
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleBatchDownload = async () => {
+ const paths = deleteMode === "filtered" ? items.map((item) => item.rel) : selectedPaths;
+ if (paths.length === 0) return;
+ setIsDownloading(true);
+ try {
+ await downloadImages(paths);
+ toast.success(`已下载 ${paths.length} 张图片`);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "下载失败");
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ const handleSingleDownload = async (item: ManagedImage) => {
+ await downloadSingleImage(item.rel);
+ };
+
+ useEffect(() => {
+ void loadImages();
+ }, [startDate, endDate]);
+
+ return (
+
+
+
+
+ { setStartDate(start); setEndDate(end); }} />
+
+
+
+
+
+
+ {allTags.length > 0 ? (
+
+
+
+ 标签筛选:
+
+ {allTags.map((tag) => {
+ const isPressing = pressingTag === tag;
+ return (
+ startTagPress(tag)}
+ onMouseUp={stopTagPress}
+ onMouseLeave={stopTagPress}
+ onTouchStart={() => startTagPress(tag)}
+ onTouchEnd={stopTagPress}
+ >
+
+ {isPressing ? (
+
+
+
+ ) : null}
+
+ );
+ })}
+ {selectedTags.length > 0 ? (
+
+ ) : null}
+
+ ) : null}
+
+ {/* Storage Stats Panel */}
+
+ {storage ? (
+ <>
+
+
磁盘总量
+
{storage.disk_total_mb >= 1024 ? `${(storage.disk_total_mb / 1024).toFixed(1)} GB` : `${storage.disk_total_mb} MB`}
+
+
+
剩余空间
+
{storage.disk_free_mb >= 1024 ? `${(storage.disk_free_mb / 1024).toFixed(1)} GB` : `${storage.disk_free_mb} MB`}
+
+
+
图片数量
+
{storage.image_count}
+
+
+
图片占用
+
{storage.image_size_mb >= 1024 ? `${(storage.image_size_mb / 1024).toFixed(1)} GB` : `${storage.image_size_mb} MB`}
+
+
+ 快捷操作
+
+
+
+
+ {compressResult ? {compressResult} : null}
+
+ >
+ ) : (
+
+ {storageLoading ? "加载存储信息..." : "存储信息加载失败"}
+
+ )}
+
+
+ {/* Delete by date dialog */}
+
+
+
+
+
+
+
+ 共 {filteredItems.length} 张
+ {selectedTags.length > 0 ? (筛选自 {items.length} 张) : null}
+
+
+ {selectedPaths.length > 0 ? 已选 {selectedPaths.length} 张 : null}
+
+
+
+
+
+
+
+
+
+ {currentRows.map((item) => {
+ const imageIndex = filteredItems.findIndex((row) => row.url === item.url);
+ return (
+
+
+
+
+
+
+
+
+
+ {item.created_at}
+
+
+
+
+ togglePaths([imageKey(item)], Boolean(checked))} />
+
+
+
+ {formatSize(item.size)}
+ {item.width && item.height ? `${item.width} x ${item.height}` : "-"}
+
+
+ {(item.tags ?? []).map((tag) => (
+
+ {tag}
+
+
+ ))}
+
{ setTagEditTarget(open ? item : null); setTagInput(""); }}>
+
+
+
+
+
+
添加标签
+
+
setTagInput(e.target.value)}
+ placeholder="输入标签名"
+ className="h-8 text-xs"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddTag(item);
+ }
+ }}
+ />
+
+
+ {allTags.filter((t) => !(item.tags ?? []).includes(t)).length > 0 ? (
+
+ {allTags.filter((t) => !(item.tags ?? []).includes(t)).map((tag) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+ )})}
+
+
+ 第 {safePage} / {pageCount} 页,共 {filteredItems.length} 张
+
+
+
+ {!isLoading && filteredItems.length === 0 ? 没有找到图片
: null}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function ImageManagerPage() {
+ const { isCheckingAuth, session } = useAuthGuard(["admin"]);
+ if (isCheckingAuth || !session || session.role !== "admin") {
+ return
;
+ }
+ return ;
+}
diff --git a/web/src/app/image/components/image-composer.tsx b/web/src/app/image/components/image-composer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..474426351f1a904c83255d31907df509c9a9307f
--- /dev/null
+++ b/web/src/app/image/components/image-composer.tsx
@@ -0,0 +1,542 @@
+"use client";
+import { ArrowUp, ChevronDown, ImagePlus, Info, LoaderCircle, RectangleHorizontal, RectangleVertical, Square, X } from "lucide-react";
+import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent, type RefObject } from "react";
+
+import { ImageLightbox } from "@/components/image-lightbox";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import type { ImageModel } from "@/lib/api";
+import { cn } from "@/lib/utils";
+
+type ImageComposerProps = {
+ prompt: string;
+ imageCount: string;
+ imageRatio: string;
+ imageTier: string;
+ imageWidth: string;
+ imageHeight: string;
+ imageQuality: string;
+ imageModel: ImageModel;
+ imageModels: ImageModel[];
+ availableQuota: string;
+ activeTaskCount: number;
+ referenceImages: Array<{ name: string; dataUrl: string }>;
+ textareaRef: RefObject;
+ fileInputRef: RefObject;
+ onPromptChange: (value: string) => void;
+ onImageCountChange: (value: string) => void;
+ onImageRatioChange: (value: string) => void;
+ onImageTierChange: (value: string) => void;
+ onImageWidthChange: (value: string) => void;
+ onImageHeightChange: (value: string) => void;
+ onImageQualityChange: (value: string) => void;
+ onImageModelChange: (value: ImageModel) => void;
+ onSubmit: () => void | Promise;
+ onPickReferenceImage: () => void;
+ onReferenceImageChange: (files: File[]) => void | Promise;
+ onRemoveReferenceImage: (index: number) => void;
+};
+
+const imageFileNamePattern = /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i;
+
+function isImageFile(file: File) {
+ return file.type.startsWith("image/") || (!file.type && imageFileNamePattern.test(file.name));
+}
+
+function hasDraggedImages(dataTransfer: DataTransfer) {
+ const items = Array.from(dataTransfer.items || []);
+ if (items.length > 0) {
+ return items.some((item) => item.kind === "file" && (item.type.startsWith("image/") || !item.type));
+ }
+ return Array.from(dataTransfer.files || []).some(isImageFile);
+}
+
+function getDraggedImageFiles(dataTransfer: DataTransfer) {
+ return Array.from(dataTransfer.files || []).filter(isImageFile);
+}
+
+const qualityOptions = [
+ { value: "auto", label: "自动" },
+ { value: "low", label: "低" },
+ { value: "medium", label: "中" },
+ { value: "high", label: "高" },
+];
+const aspectOptions = [
+ { ratio: "1:1", tier: "1k", width: "1024", height: "1024", label: "1:1", icon: Square },
+ { ratio: "2:3", tier: "1k", width: "1024", height: "1536", label: "2:3", icon: RectangleVertical },
+ { ratio: "3:2", tier: "1k", width: "1536", height: "1024", label: "3:2", icon: RectangleHorizontal },
+ { ratio: "3:4", tier: "1k", width: "1024", height: "1365", label: "3:4", icon: RectangleVertical },
+ { ratio: "4:3", tier: "1k", width: "1365", height: "1024", label: "4:3", icon: RectangleHorizontal },
+ { ratio: "9:16", tier: "1k", width: "1088", height: "1920", label: "9:16", icon: RectangleVertical },
+ { ratio: "16:9", tier: "1k", width: "1920", height: "1088", label: "16:9", icon: RectangleHorizontal },
+ { ratio: "1:1", tier: "2k", width: "2048", height: "2048", label: "1:1(2k)", icon: Square },
+ { ratio: "16:9", tier: "2k", width: "2560", height: "1440", label: "16:9(2k)", icon: RectangleHorizontal },
+ { ratio: "9:16", tier: "2k", width: "1440", height: "2560", label: "9:16(2k)", icon: RectangleVertical },
+ { ratio: "16:9", tier: "4k", width: "3840", height: "2160", label: "16:9(4k)", icon: RectangleHorizontal },
+ { ratio: "9:16", tier: "4k", width: "2160", height: "3840", label: "9:16(4k)", icon: RectangleVertical },
+ { ratio: "auto", tier: "auto", width: "1024", height: "1024", label: "auto", icon: null },
+];
+const countOptions = Array.from({ length: 10 }, (_, index) => String(index + 1));
+
+export function ImageComposer({
+ prompt,
+ imageCount,
+ imageRatio,
+ imageTier,
+ imageWidth,
+ imageHeight,
+ imageQuality,
+ imageModel,
+ imageModels,
+ availableQuota,
+ activeTaskCount,
+ referenceImages,
+ textareaRef,
+ fileInputRef,
+ onPromptChange,
+ onImageCountChange,
+ onImageRatioChange,
+ onImageTierChange,
+ onImageWidthChange,
+ onImageHeightChange,
+ onImageQualityChange,
+ onImageModelChange,
+ onSubmit,
+ onPickReferenceImage,
+ onReferenceImageChange,
+ onRemoveReferenceImage,
+}: ImageComposerProps) {
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [lightboxIndex, setLightboxIndex] = useState(0);
+ const [isSizeMenuOpen, setIsSizeMenuOpen] = useState(false);
+ const [isDraggingImage, setIsDraggingImage] = useState(false);
+ const [sizeMenuPos, setSizeMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
+ const sizeMenuRef = useRef(null);
+ const sizeMenuBtnRef = useRef(null);
+ const lightboxImages = useMemo(
+ () => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })),
+ [referenceImages],
+ );
+ const modelOptions = useMemo(
+ () => imageModels.map((model) => ({ value: model, label: model })),
+ [imageModels],
+ );
+ const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动";
+ const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`;
+ const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`;
+ const selectedModelLabel = modelOptions.find((option) => option.value === imageModel)?.label || imageModel;
+ const isCodexModel = imageModel.toLowerCase().includes("codex");
+
+ useEffect(() => {
+ if (!isSizeMenuOpen) {
+ return;
+ }
+ const handlePointerDown = (event: MouseEvent) => {
+ const target = event.target;
+ if (
+ target instanceof Element &&
+ target.closest('[data-slot="select-content"], [data-slot="select-trigger"]')
+ ) {
+ return;
+ }
+ if (!sizeMenuRef.current?.contains(target as Node)) {
+ setIsSizeMenuOpen(false);
+ }
+ };
+ window.addEventListener("mousedown", handlePointerDown);
+ return () => {
+ window.removeEventListener("mousedown", handlePointerDown);
+ };
+ }, [isSizeMenuOpen]);
+
+ const handleTextareaPaste = (event: ClipboardEvent) => {
+ const imageFiles = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
+ if (imageFiles.length === 0) {
+ return;
+ }
+
+ event.preventDefault();
+ void onReferenceImageChange(imageFiles);
+ };
+
+ const handleComposerDragEnter = (event: DragEvent) => {
+ if (!hasDraggedImages(event.dataTransfer)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ setIsSizeMenuOpen(false);
+ setIsDraggingImage(true);
+ };
+
+ const handleComposerDragOver = (event: DragEvent) => {
+ if (!hasDraggedImages(event.dataTransfer)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ setIsDraggingImage(true);
+ };
+
+ const handleComposerDragLeave = (event: DragEvent) => {
+ const nextTarget = event.relatedTarget;
+ if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
+ return;
+ }
+ setIsDraggingImage(false);
+ };
+
+ const handleComposerDrop = (event: DragEvent) => {
+ const imageFiles = getDraggedImageFiles(event.dataTransfer);
+ if (event.dataTransfer.files.length > 0 || imageFiles.length > 0) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ setIsDraggingImage(false);
+ if (imageFiles.length === 0) {
+ return;
+ }
+
+ void onReferenceImageChange(imageFiles);
+ };
+
+ return (
+
+
+
{
+ void onReferenceImageChange(Array.from(event.target.files || []));
+ }}
+ />
+
+ {referenceImages.length > 0 ? (
+
+ {referenceImages.map((image, index) => (
+
+
+
+
+ ))}
+
+ ) : null}
+
+
+
{
+ textareaRef.current?.focus();
+ }}
+ >
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/image/components/image-results.tsx b/web/src/app/image/components/image-results.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5bd5437a9f521f2eadae12a6a6673e996f262e50
--- /dev/null
+++ b/web/src/app/image/components/image-results.tsx
@@ -0,0 +1,554 @@
+"use client";
+
+import { memo, useEffect, useRef, useState } from "react";
+import { Clock3, Download, EyeOff, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
+
+export type ImageLightboxItem = {
+ id: string;
+ src: string;
+ sizeLabel?: string;
+ dimensions?: string;
+};
+
+type ImageResultsProps = {
+ selectedConversation: ImageConversation | null;
+ onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
+ onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
+ onDeletePrompt: (conversationId: string, turnId: string) => void;
+ onDeleteResults: (conversationId: string, turnId: string) => void;
+ onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise;
+ onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise;
+ onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise;
+ onTimeoutRetryContinue: (taskId: string) => void | Promise;
+ onDismissErrors: (conversationId: string, turnId: string) => void | Promise;
+ formatConversationTime: (value: string) => string;
+};
+
+// Blob URL 缓存:避免 base64 超长字符串在 DOM 中,改用短小的 blob: URL
+const b64BlobUrlCache = new Map();
+
+function getStoredImageSrc(image: StoredImage) {
+ if (image.b64_json) {
+ let url = b64BlobUrlCache.get(image.b64_json);
+ if (!url) {
+ const binary = atob(image.b64_json);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ const blob = new Blob([bytes], { type: "image/png" });
+ url = URL.createObjectURL(blob);
+ b64BlobUrlCache.set(image.b64_json, url);
+ }
+ return url;
+ }
+ return image.url || "";
+}
+
+async function downloadStoredImage(image: StoredImage, index: number) {
+ let blob: Blob | null = null;
+ try {
+ if (image.b64_json) {
+ const binary = atob(image.b64_json);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ blob = new Blob([bytes], { type: "image/png" });
+ } else if (image.url) {
+ // 确保 URL 是绝对路径
+ const url = image.url.startsWith("http") ? image.url : `${window.location.origin}${image.url}`;
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
+ }
+ blob = await res.blob();
+ } else {
+ return;
+ }
+ } catch (err) {
+ console.error("Failed to download image:", err);
+ // 如果 fetch 失败,尝试直接在新窗口打开
+ if (image.url) {
+ window.open(image.url, "_blank");
+ }
+ return;
+ }
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `image-${index + 1}.png`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+export function ImageResults({
+ selectedConversation,
+ onOpenLightbox,
+ onContinueEdit,
+ onDeletePrompt,
+ onDeleteResults,
+ onReuseTurnConfig,
+ onRegenerateTurn,
+ onRetryImage,
+ onTimeoutRetryContinue,
+ onDismissErrors,
+ formatConversationTime,
+}: ImageResultsProps) {
+ const imageDimensionsRef = useRef>({});
+ const [currentTime, setCurrentTime] = useState(Date.now());
+
+ // 仅在存在 loading 图片时启动定时器,避免空闲时无谓重渲染
+ const hasLoadingImages = selectedConversation?.turns.some(
+ (turn) => !turn.resultsDeleted && turn.images.some((image) => image.status === "loading"),
+ );
+ useEffect(() => {
+ if (!hasLoadingImages) return;
+ const timer = setInterval(() => {
+ setCurrentTime(Date.now());
+ }, 500);
+ return () => clearInterval(timer);
+ }, [hasLoadingImages]);
+
+ const updateImageDimensions = (id: string, width: number, height: number) => {
+ const dimensions = formatImageDimensions(width, height);
+ // 使用 ref 存储,不触发 React 重渲染,消除级联重渲染
+ if (imageDimensionsRef.current[id] !== dimensions) {
+ imageDimensionsRef.current[id] = dimensions;
+ }
+ };
+
+ if (!selectedConversation) {
+ return (
+
+
+
+ Turn ideas into images
+
+
+ 在同一窗口里保留本地历史与任务状态,并从已有结果图继续发起新的无状态编辑。
+
+
+
+ );
+ }
+
+ return (
+
+ {selectedConversation.turns.map((turn, turnIndex) => {
+ const referenceLightboxImages = turn.referenceImages.map((image, index) => ({
+ id: `${turn.id}-reference-${index}`,
+ src: image.dataUrl,
+ }));
+ const successfulTurnImages = turn.images.flatMap((image) => {
+ const src = image.status === "success" ? getStoredImageSrc(image) : "";
+ return src
+ ? [
+ {
+ id: image.id,
+ src,
+ sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
+ dimensions: imageDimensionsRef.current[image.id],
+ },
+ ]
+ : [];
+ });
+
+ return (
+
+ {!turn.promptDeleted ? (
+
+
+
+ 第 {turnIndex + 1} 轮
+
+ {turn.mode === "edit" ? "编辑图" : "文生图"}
+
+ {getTurnStatusLabel(turn.status)}
+ {formatConversationTime(turn.createdAt)}
+
+
{turn.prompt}
+
+
+
+
+
+
+ ) : null}
+
+ {!turn.resultsDeleted ? (
+
+
+ {turn.referenceImages.length > 0 ? (
+
+
本轮参考图
+
+ {turn.referenceImages.map((image, index) => (
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+ {turn.count} 张
+ {getTurnStatusLabel(turn.status)}
+ {turn.status === "queued" ? (
+ 等待当前对话中的前序任务完成
+ ) : null}
+
+
+
+ {turn.images.map((image, index) => {
+ const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
+ if (image.status === "success" && imageSrc) {
+ const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
+ const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
+ const dimensions = imageDimensionsRef.current[image.id];
+ const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
+
+ return (
+
+
{
+ updateImageDimensions(
+ image.id,
+ event.currentTarget.naturalWidth,
+ event.currentTarget.naturalHeight,
+ );
+ }}
+ onOpen={() => onOpenLightbox(successfulTurnImages, currentIndex)}
+ />
+
+
+ 结果 {index + 1}
+ {image.durationMs != null ? {formatDuration(image.durationMs)} : null}
+ {imageMeta ? {imageMeta} : null}
+
+
+
+
+
+
+
+ );
+ }
+
+ if (image.status === "error") {
+ const isTimeoutError = image.error?.includes("超时") && image.taskId;
+ return (
+
+
+
+
图片 {index + 1}/{turn.images.length}
+
{image.error || "生成失败"}
+
+ {isTimeoutError && (
+
+ )}
+
+
+
+
+
+
+ 结果 {index + 1}
+ {image.durationMs != null ? {formatDuration(image.durationMs)} : null}
+ -
+
+
+
+ );
+ }
+
+ const imageTaskStatus = image.taskStatus || (turn.status === "queued" ? "queued" : "running");
+ const imageStatusLabel = imageTaskStatus === "queued" ? "排队中" : getProgressLabel(image.progress);
+ const showElapsed = imageTaskStatus === "running" && image.elapsedSecs != null;
+ const elapsedDisplay = showElapsed
+ ? formatElapsed(
+ image.elapsedUpdatedAt != null
+ ? image.elapsedSecs! + (currentTime - image.elapsedUpdatedAt!) / 1000
+ : image.elapsedSecs!,
+ )
+ : null;
+ return (
+
+
+
+
+ {imageTaskStatus === "queued" ? (
+
+ ) : (
+
+ )}
+
+
+ 图片 {index + 1}/{turn.images.length}
+
+
+ {imageStatusLabel}
+
+
+
+ {elapsedDisplay != null && (
+
{elapsedDisplay}
+ )}
+
+ );
+ })}
+
+
+ {turn.status === "error" && turn.error ? (
+
+ {turn.error}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
+
+function getTurnStatusLabel(status: ImageTurnStatus) {
+ if (status === "queued") {
+ return "排队中";
+ }
+ if (status === "generating") {
+ return "处理中";
+ }
+ if (status === "success") {
+ return "已完成";
+ }
+ return "失败";
+}
+
+const PROGRESS_LABELS: Record = {
+ getting_account: "确认可用账号",
+ uploading: "上传图片",
+ bootstrapping: "预热首页",
+ getting_token: "获取 token",
+ preparing_conversation: "准备会话",
+ starting_generation: "启动生成",
+ generating: "生成中",
+ receiving_image: "接收图片中",
+};
+
+function getProgressLabel(progress?: string) {
+ if (!progress) {
+ return "生成中";
+ }
+ return PROGRESS_LABELS[progress] || "生成中";
+}
+
+function formatElapsed(seconds: number): string {
+ return `${seconds.toFixed(1)}s`;
+}
+
+function formatDuration(ms: number): string {
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+const base64SizeCache = new Map();
+function formatBase64ImageSize(base64: string) {
+ let cached = base64SizeCache.get(base64);
+ if (cached !== undefined) return cached;
+ const normalized = base64.replace(/\s/g, "");
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
+ const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
+
+ if (bytes >= 1024 * 1024) {
+ cached = `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+ } else if (bytes >= 1024) {
+ cached = `${(bytes / 1024).toFixed(1)} KB`;
+ } else {
+ cached = `${bytes} B`;
+ }
+ base64SizeCache.set(base64, cached);
+ return cached;
+}
+
+function formatImageDimensions(width: number, height: number) {
+ return `${width} x ${height}`;
+}
+
+const LazyImage = memo(function LazyImage({ src, alt, className, onLoad, onOpen }: {
+ src: string;
+ alt: string;
+ className: string;
+ onLoad?: (event: React.SyntheticEvent) => void;
+ onOpen?: () => void;
+}) {
+ const [isVisible, setIsVisible] = useState(false);
+ const imgRef = useRef(null);
+
+ useEffect(() => {
+ const element = imgRef.current;
+ if (!element) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "400px" },
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {isVisible ? (
+
+ ) : (
+
+ )}
+
+ );
+});
diff --git a/web/src/app/image/components/image-sidebar.tsx b/web/src/app/image/components/image-sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dbbab2c260aaccada337dd1fcc5ec94afd35d83b
--- /dev/null
+++ b/web/src/app/image/components/image-sidebar.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { LoaderCircle, MessageSquarePlus, Pencil, Trash2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { getImageConversationStats, type ImageConversation } from "@/store/image-conversations";
+
+type ImageSidebarProps = {
+ conversations: ImageConversation[];
+ isLoadingHistory: boolean;
+ selectedConversationId: string | null;
+ onCreateDraft: () => void;
+ onClearHistory: () => void | Promise;
+ onSelectConversation: (id: string) => void;
+ onDeleteConversation: (id: string) => void | Promise;
+ onRenameConversation: (id: string, title: string) => void | Promise;
+ formatConversationTime: (value: string) => string;
+ hideActionButtons?: boolean;
+};
+
+export function ImageSidebar({
+ conversations,
+ isLoadingHistory,
+ selectedConversationId,
+ onCreateDraft,
+ onClearHistory,
+ onSelectConversation,
+ onDeleteConversation,
+ onRenameConversation,
+ formatConversationTime,
+ hideActionButtons = false,
+}: ImageSidebarProps) {
+ const [editingId, setEditingId] = useState(null);
+ const [editingTitle, setEditingTitle] = useState("");
+ const editInputRef = useRef(null);
+
+ useEffect(() => {
+ if (editingId && editInputRef.current) {
+ editInputRef.current.focus();
+ editInputRef.current.select();
+ }
+ }, [editingId]);
+
+ const startRename = useCallback((conversation: ImageConversation, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setEditingId(conversation.id);
+ setEditingTitle(conversation.title);
+ }, []);
+
+ const commitRename = useCallback(() => {
+ const trimmed = editingTitle.trim();
+ if (editingId && trimmed) {
+ void onRenameConversation(editingId, trimmed);
+ }
+ setEditingId(null);
+ setEditingTitle("");
+ }, [editingId, editingTitle, onRenameConversation]);
+
+ const cancelRename = useCallback(() => {
+ setEditingId(null);
+ setEditingTitle("");
+ }, []);
+ return (
+
+ );
+}
diff --git a/web/src/app/image/page.tsx b/web/src/app/image/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc2ff28e4d6e7d4296d40d858536154bc15972b6
--- /dev/null
+++ b/web/src/app/image/page.tsx
@@ -0,0 +1,1775 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { ArrowDown, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { ImageComposer } from "@/app/image/components/image-composer";
+import { ImageResults, type ImageLightboxItem } from "@/app/image/components/image-results";
+import { ImageSidebar } from "@/app/image/components/image-sidebar";
+import { ImageLightbox } from "@/components/image-lightbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ createImageEditTask,
+ createImageGenerationTask,
+ fetchAccounts,
+ fetchModels,
+ fetchImageTasks,
+ resumeImagePoll,
+ type Account,
+ type ImageModel,
+ type Model,
+ type ImageTask,
+} from "@/lib/api";
+import { useAuthGuard } from "@/lib/use-auth-guard";
+import { useSettingsStore } from "@/app/settings/store";
+import {
+ clearImageConversations,
+ deleteImageConversation,
+ getImageConversationStats,
+ listImageConversations,
+ renameImageConversation,
+ saveImageConversation,
+ saveImageConversations,
+ type ImageConversation,
+ type ImageConversationMode,
+ type ImageTurn,
+ type ImageTurnStatus,
+ type StoredImage,
+ type StoredReferenceImage,
+} from "@/store/image-conversations";
+
+const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
+const IMAGE_RATIO_STORAGE_KEY = "chatgpt2api:image_last_ratio";
+const IMAGE_TIER_STORAGE_KEY = "chatgpt2api:image_last_tier";
+const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
+const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
+const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
+const SCROLL_POSITIONS_STORAGE_KEY = "chatgpt2api:image_scroll_positions";
+const SCROLL_TO_LATEST_THRESHOLD = 160;
+
+function loadScrollPositions(): Map {
+ if (typeof window === "undefined") return new Map();
+ try {
+ const raw = window.sessionStorage.getItem(SCROLL_POSITIONS_STORAGE_KEY);
+ if (!raw) return new Map();
+ const parsed = JSON.parse(raw) as Record;
+ return new Map(Object.entries(parsed));
+ } catch {
+ return new Map();
+ }
+}
+
+function saveScrollPositions(positions: Map) {
+ if (typeof window === "undefined") return;
+ try {
+ const obj: Record = {};
+ positions.forEach((value, key) => { obj[key] = value; });
+ window.sessionStorage.setItem(SCROLL_POSITIONS_STORAGE_KEY, JSON.stringify(obj));
+ } catch {
+ // sessionStorage may be full or unavailable
+ }
+}
+
+function clampImageCount(value: string) {
+ return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
+}
+function parseImageSize(size: string) {
+ const match = size.match(/^(\d+)x(\d+)$/);
+ return match ? { width: match[1], height: match[2] } : { width: "1024", height: "1024" };
+}
+
+const activeConversationQueueIds = new Set();
+let pollAbortController: AbortController | null = null;
+
+function getResultsDistanceFromBottom(element: HTMLElement) {
+ return element.scrollHeight - element.scrollTop - element.clientHeight;
+}
+
+function buildConversationTitle(prompt: string) {
+ const trimmed = prompt.trim();
+ if (trimmed.length <= 12) {
+ return trimmed;
+ }
+ return `${trimmed.slice(0, 12)}...`;
+}
+
+function formatConversationTime(value: string) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "";
+ }
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(date);
+}
+
+function formatAvailableQuota(accounts: Account[]) {
+ const availableAccounts = accounts.filter((account) => account.status !== "禁用");
+ return String(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
+}
+
+function createId() {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ return crypto.randomUUID();
+ }
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+function readFileAsDataUrl(file: File) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ""));
+ reader.onerror = () => reject(new Error("读取参考图失败"));
+ reader.readAsDataURL(file);
+ });
+}
+
+function dataUrlToFile(dataUrl: string, fileName: string, mimeType?: string) {
+ const [header, content] = dataUrl.split(",", 2);
+ const matchedMimeType = header.match(/data:(.*?);base64/)?.[1];
+ const binary = atob(content || "");
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
+ }
+ return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
+}
+
+function filterImageModels(items: Model[]): ImageModel[] {
+ return items
+ .map((item) => String(item.id || "").trim())
+ .filter((id, index, list) => id.toLowerCase().includes("image") && list.indexOf(id) === index);
+}
+
+function normalizeStoredImageModel(value: string | null, availableModels: ImageModel[]): ImageModel {
+ const normalized = String(value || "").trim();
+ if (normalized && availableModels.includes(normalized)) {
+ return normalized;
+ }
+ return availableModels[0] || "gpt-image-2";
+}
+
+function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
+ if (!image.b64_json) {
+ return null;
+ }
+
+ return {
+ name: fileName,
+ type: "image/png",
+ dataUrl: `data:image/png;base64,${image.b64_json}`,
+ };
+}
+
+async function fetchImageAsFile(url: string, fileName: string) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error("读取结果图失败");
+ }
+ const blob = await response.blob();
+ return new File([blob], fileName, { type: blob.type || "image/png" });
+}
+
+async function buildReferenceImageFromStoredImage(image: StoredImage, fileName: string) {
+ const direct = buildReferenceImageFromResult(image, fileName);
+ if (direct) {
+ return {
+ referenceImage: direct,
+ file: dataUrlToFile(direct.dataUrl, direct.name, direct.type),
+ };
+ }
+
+ if (!image.url) {
+ return null;
+ }
+ const file = await fetchImageAsFile(image.url, fileName);
+ return {
+ referenceImage: {
+ name: file.name,
+ type: file.type || "image/png",
+ dataUrl: await readFileAsDataUrl(file),
+ },
+ file,
+ };
+}
+
+function taskDataToStoredImage(image: StoredImage, task: ImageTask): StoredImage {
+ if (task.status === "success") {
+ const first = task.data?.[0];
+ if (!first?.b64_json && !first?.url) {
+ return {
+ ...image,
+ taskId: task.id,
+ status: "error",
+ taskStatus: undefined,
+ progress: undefined,
+ error: "未返回图片数据",
+ };
+ }
+ return {
+ ...image,
+ taskId: task.id,
+ status: "success",
+ taskStatus: undefined,
+ progress: undefined,
+ b64_json: first.b64_json,
+ url: first.url,
+ revised_prompt: first.revised_prompt,
+ error: undefined,
+ durationMs: task.duration_ms,
+ };
+ }
+
+ if (task.status === "error") {
+ return {
+ ...image,
+ taskId: task.id,
+ status: "error",
+ taskStatus: undefined,
+ progress: undefined,
+ error: task.error || "生成失败",
+ durationMs: task.duration_ms,
+ };
+ }
+
+ const newTaskStatus = task.status === "queued" ? "queued" : task.status === "running" ? "running" : image.taskStatus;
+ const shouldSetStartTime = newTaskStatus === "running" && !image.startTime;
+ const startTime = shouldSetStartTime ? Date.now() : image.startTime;
+ // elapsedSecs 仅使用后端返回的值,确保计时从 image_stream_resolve_start 开始
+ const elapsedSecs =
+ newTaskStatus === "running" && typeof task.elapsed_secs === "number"
+ ? task.elapsed_secs
+ : undefined;
+
+ return {
+ ...image,
+ taskId: task.id,
+ status: "loading",
+ taskStatus: newTaskStatus,
+ progress: task.progress || image.progress,
+ error: undefined,
+ startTime,
+ elapsedSecs,
+ elapsedUpdatedAt: elapsedSecs != null ? Date.now() : undefined,
+ };
+}
+
+function sleep(ms: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+function pickFallbackConversationId(conversations: ImageConversation[]) {
+ const activeConversation = conversations.find((conversation) =>
+ conversation.turns.some((turn) => turn.status === "queued" || turn.status === "generating"),
+ );
+ return activeConversation?.id ?? conversations[0]?.id ?? null;
+}
+
+function sortImageConversations(conversations: ImageConversation[]) {
+ return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+}
+
+function deriveTurnStatus(turn: ImageTurn): Pick {
+ const loadingCount = turn.images.filter((image) => image.status === "loading").length;
+ const failedCount = turn.images.filter((image) => image.status === "error").length;
+ const successCount = turn.images.filter((image) => image.status === "success").length;
+ if (loadingCount > 0) {
+ // 如果任何图片的 taskStatus 为 running,则状态为 generating
+ const hasRunning = turn.images.some((image) => image.taskStatus === "running");
+ if (hasRunning) {
+ return { status: "generating", error: undefined };
+ }
+ return { status: turn.status === "queued" ? "queued" : "generating", error: undefined };
+ }
+ if (failedCount > 0) {
+ return { status: "error", error: `其中 ${failedCount} 张未成功生成` };
+ }
+ if (successCount > 0) {
+ return { status: "success", error: undefined };
+ }
+ // 所有图片都被忽略(images 为空),视为完成
+ return { status: "success", error: undefined };
+}
+
+async function syncConversationImageTasks(items: ImageConversation[]) {
+ const taskIds = Array.from(
+ new Set(
+ items.flatMap((conversation) =>
+ conversation.turns.flatMap((turn) =>
+ turn.resultsDeleted
+ ? []
+ : turn.images.flatMap((image) =>
+ (image.status === "loading" || (image.status === "error" && image.taskId))
+ ? [image.taskId!]
+ : [],
+ ),
+ ),
+ ),
+ ),
+ );
+ if (taskIds.length === 0) {
+ return items;
+ }
+
+ let taskList: Awaited>;
+ try {
+ taskList = await fetchImageTasks(taskIds);
+ } catch {
+ return items;
+ }
+ const taskMap = new Map(taskList.items.map((task) => [task.id, task]));
+ let changed = false;
+ const normalized = items.map((conversation) => {
+ const turns = conversation.turns.map((turn) => {
+ let turnChanged = false;
+ const images = turn.images.map((image) => {
+ if (!image.taskId) {
+ return image;
+ }
+ if (image.status !== "loading" && image.status !== "error") {
+ return image;
+ }
+ const task = taskMap.get(image.taskId);
+ if (!task) {
+ return image;
+ }
+ const nextImage = taskDataToStoredImage(image, task);
+ if (nextImage !== image) {
+ turnChanged = true;
+ }
+ return nextImage;
+ });
+ if (!turnChanged) {
+ return turn;
+ }
+ changed = true;
+ const derived = deriveTurnStatus({ ...turn, images });
+ return {
+ ...turn,
+ ...derived,
+ images,
+ };
+ });
+ if (turns === conversation.turns || !turns.some((turn, index) => turn !== conversation.turns[index])) {
+ return conversation;
+ }
+ return {
+ ...conversation,
+ turns,
+ updatedAt: new Date().toISOString(),
+ };
+ });
+
+ if (changed) {
+ await saveImageConversations(normalized);
+ }
+ return normalized;
+}
+
+async function recoverConversationHistory(items: ImageConversation[]) {
+ let changed = false;
+ const normalized = items.map((conversation) => {
+ const turns = conversation.turns.map((turn) => {
+ if (turn.status !== "queued" && turn.status !== "generating" && turn.status !== "error") {
+ return turn;
+ }
+
+ let turnChanged = false;
+ const images = turn.images.map((image) => {
+ if (image.status !== "loading" || image.taskId) {
+ return image;
+ }
+ turnChanged = true;
+ return {
+ ...image,
+ status: "error" as const,
+ error: "页面刷新或任务中断,未找到可恢复的任务 ID",
+ };
+ });
+ const derived = deriveTurnStatus({ ...turn, images });
+ if (!turnChanged && derived.status === turn.status && derived.error === turn.error) {
+ return turn;
+ }
+ changed = true;
+ return {
+ ...turn,
+ ...derived,
+ images,
+ };
+ });
+
+ if (!turns.some((turn, index) => turn !== conversation.turns[index])) {
+ return conversation;
+ }
+
+ return {
+ ...conversation,
+ turns,
+ updatedAt: new Date().toISOString(),
+ };
+ });
+
+ if (changed) {
+ await saveImageConversations(normalized);
+ }
+
+ return syncConversationImageTasks(normalized);
+}
+
+
+function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
+ const didLoadQuotaRef = useRef(false);
+ const conversationsRef = useRef([]);
+ const loadCancelledRef = useRef(false);
+ const resultsViewportRef = useRef(null);
+ const lastConversationIdRef = useRef(null);
+ const shouldStickToBottomRef = useRef(true);
+ const scrollRafRef = useRef(null);
+ const scrollSaveTimerRef = useRef | null>(null);
+ const textareaRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const scrollPositionsRef = useRef