Compare commits

...

26 Commits

Author SHA1 Message Date
shiyu
1c216a7516 chore: update version to v2.2.0 in service configuration 2026-05-01 22:04:20 +08:00
shiyu
d8425f1cdd feat: add client authorization feature in TopHeader and update localization files 2026-05-01 17:59:30 +08:00
shiyu
e235845737 feat: add PikPak adapter implementation 2026-05-01 14:09:52 +08:00
shiyu
6981bb8444 feat: add /client directory to .gitignore 2026-05-01 10:53:22 +08:00
shiyu
1101273077 feat: add default language configuration 2026-04-19 16:42:19 +08:00
shiyu
398dbcf8ae feat: enhance vector database configuration handling and improve provider initialization 2026-04-10 19:40:41 +08:00
shiyu
0609cf6971 feat: add metadata options for file status checks and optimize permission filtering logic 2026-04-10 17:56:01 +08:00
dependabot[bot]
93c4d7a748 chore(deps): bump cryptography in the uv group across 1 directory (#116)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.6 to 46.0.7
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.6...46.0.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.7
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 17:35:45 +08:00
dependabot[bot]
a0fe35b6e9 chore(deps): bump aiohttp in the uv group across 1 directory (#115)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.4
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 11:22:59 +08:00
dependabot[bot]
3efc286ef8 chore(deps): bump cryptography in the uv group across 1 directory (#114)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 10:26:46 +08:00
dependabot[bot]
013c14b767 chore(deps): bump pyasn1 in the uv group across 1 directory (#113)
Bumps the uv group with 1 update in the / directory: [pyasn1](https://github.com/pyasn1/pyasn1).


Updates `pyasn1` from 0.6.2 to 0.6.3
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.2...v0.6.3)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.3
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-22 11:22:58 +08:00
shiyu
da7db4ff2a chore(service): update version to v2.1.1 2026-03-15 16:09:54 +08:00
shiyu
4038a7292a fix(ContextMenu): prevent event propagation on menu click to improve usability 2026-03-15 16:07:23 +08:00
shiyu
f96a4dce11 fix(ContextMenu): improve handler invocation logic to prevent errors 2026-03-13 23:01:41 +08:00
shiyu
ef1fe1cce8 feat(agent): unify tool calling around MCP and add /api/mcp endpoint 2026-03-11 11:49:21 +08:00
shiyu
a5d606387f feat(Header): enhance mobile layout with dedicated buttons and improved responsiveness 2026-03-09 17:30:06 +08:00
shiyu
e6402661d6 fix(LayoutShell): adjust layout styles for better responsiveness and overflow handling 2026-03-09 17:26:24 +08:00
shiyu
f4b18fdf35 chore(service): update version to v2.1.0 2026-03-09 16:48:05 +08:00
shiyu
338c72cd5c feat(SideNav): add hideOnMobile property to NavItem and filter nav items based on mobile view 2026-03-09 16:47:21 +08:00
shiyu
072ccea5be feat(Header): add upload folder option to the upload menu 2026-03-09 16:44:03 +08:00
shiyu
066bd67273 feat(web): add first-pass mobile responsive support 2026-03-09 11:44:44 +08:00
dependabot[bot]
1cac4f6f98 chore(deps): bump pillow in the uv group across 1 directory (#112)
Bumps the uv group with 1 update in the / directory: [pillow](https://github.com/python-pillow/Pillow).


Updates `pillow` from 12.1.0 to 12.1.1
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.1.0...12.1.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.1.1
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-15 20:50:31 +08:00
dependabot[bot]
f042eecc2f chore(deps): bump cryptography in the uv group across 1 directory (#111)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.4 to 46.0.5
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.4...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 15:19:37 +08:00
shiyu
a7fb7b44c6 chore: update version to v2.0.1 2026-02-10 10:22:08 +08:00
shiyu
82f351260b fix: add setuptools dependency with version constraint 2026-02-10 10:19:06 +08:00
shiyu
ad6b335169 feat: enhance README with detailed core features and usage instructions 2026-02-09 17:56:50 +08:00
85 changed files with 3679 additions and 1372 deletions

0
.codex Normal file
View File

4
.gitignore vendored
View File

@@ -31,4 +31,6 @@ lerna-debug.log*
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
/client

View File

@@ -56,7 +56,7 @@ Follow the development setup below before opening a pull request. Keep changes f
Install the following tooling first:
- **Git** for version control
- **Python** 3.13 or newer
- **Python** 3.14 or newer
- **Bun** for frontend package management and scripts
### Backend (FastAPI)

View File

@@ -58,7 +58,7 @@
### 依赖准备
- **Git**: 用于版本控制。
- **Python**: >= 3.13
- **Python**: >= 3.14
- **Bun**: 用于前端包管理和脚本运行。
### 后端 (FastAPI)

113
README.md
View File

@@ -29,48 +29,105 @@
## ✨ Core Features
- **Unified File Management**: Centralize management of files distributed across different storage backends.
- **Pluggable Storage Backends**: Utilizes an extensible adapter pattern to easily integrate various storage types.
- **Semantic Search**: Supports natural language search for content within unstructured data like images and documents.
- **Built-in File Preview**: Preview images, videos, PDFs, Office documents, text, and code files directly without downloading.
- **Permissions and Sharing**: Supports public or private sharing links for easy file distribution.
- **Task Processing Center**: Supports asynchronous task processing, such as file indexing and data backups, without impacting the main application.
### 📁 Unified File Management
Centralize management of files distributed across different storage backends. Browse, upload, download, move, copy, and delete — all through a single, unified interface.
### 🔌 Pluggable Storage Backends
Utilizes an extensible adapter pattern to easily integrate various storage types:
| Category | Adapters |
|---|---|
| **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP |
| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark |
| **Special** | Telegram, AList, Foxel-to-Foxel |
### 🔍 AI-Powered Semantic Search
Go beyond filename matching — search by natural language descriptions to find content within images, documents, and other unstructured data. Powered by configurable embedding providers and vector databases (Milvus, Qdrant).
### 👁️ Built-in File Preview
Preview images, videos, PDFs, Office documents, text, and code files directly in the browser — no downloads required.
### 🔐 Permissions & Access Control
A full-featured **Role-Based Access Control (RBAC)** system to secure your data:
- **Built-in Roles**: Three system roles — **Admin** (full access), **User** (configurable access), and **Viewer** (read-only).
- **Custom Roles**: Create tailored roles with fine-grained system and adapter permissions.
- **Path-based Rules**: Define read / write / delete / share permissions per path, with support for **wildcards**, **regex patterns**, and **priority-based rule ordering**.
- **Audit Logging**: Every user action is recorded with full traceability (user, IP, method, status, duration).
### 🔗 Sharing
Generate public or password-protected share links with configurable expiration dates. Recipients can browse shared files and folders without logging in.
### 🧩 Plugin System
Extend Foxel's capabilities through a manifest-based plugin architecture. Load React frontend components and custom backend routes at runtime, without modifying the core codebase.
### ⚙️ Task Processing Center
Run asynchronous background tasks — file indexing, data backups, scheduled jobs — without impacting the main application.
### 🤖 AI Agent
An integrated AI agent with built-in tools for VFS operations, web fetching, and file processing — bringing intelligent automation directly into your cloud storage.
### 🌐 Protocol Mappings
Access your files through familiar protocols:
- **S3 API** — S3-compatible endpoint for programmatic access
- **WebDAV** — Mount as a network drive in your OS file manager
- **Direct Links** — Temporary signed URLs for direct file access
## 🛠️ Tech Stack
| Layer | Technologies |
|---|---|
| **Backend** | Python 3.14+, FastAPI, Tortoise ORM, SQLite |
| **Frontend** | React 19, TypeScript, Vite, Ant Design |
| **Auth** | JWT (OAuth2), bcrypt |
| **Vector DB** | Milvus Lite / Server, Qdrant |
| **Deployment** | Docker, Gunicorn + Uvicorn |
| **Package Managers** | uv (Python), Bun (JS) |
## 🚀 Quick Start
Using Docker Compose is the most recommended way to start Foxel.
1. **Create Data Directories**
### 1. Create Data Directories
Create a `data` folder for persistent data:
Create a `data` folder for persistent data:
```bash
mkdir -p data/db
mkdir -p data/mount
chmod 777 data/db data/mount
```
```bash
mkdir -p data/db data/mount
chmod 777 data/db data/mount
```
2. **Download Docker Compose File**
### 2. Download Docker Compose File
```bash
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
```
```bash
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
```
After downloading, it is **strongly recommended** to modify the environment variables in the `compose.yaml` file to ensure security:
After downloading, it is **strongly recommended** to modify the environment variables in the `compose.yaml` file to ensure security:
- Modify `SECRET_KEY` and `TEMP_LINK_SECRET_KEY`: Replace the default keys with randomly generated strong keys.
- Modify `SECRET_KEY` and `TEMP_LINK_SECRET_KEY`: Replace the default keys with randomly generated strong keys.
3. **Start the Services**
### 3. Start the Services
```bash
docker-compose up -d
```
```bash
docker-compose up -d
```
4. **Access the Application**
### 4. Access the Application
Once the services are running, open the page in your browser.
Once the services are running, open the page in your browser.
> On the first launch, please follow the setup guide to initialize the administrator account.
> On the first launch, please follow the setup guide to initialize the administrator account.
## 🤝 How to Contribute
@@ -87,3 +144,7 @@ You can also join our WeChat group for more real-time communication and support.
<img src="https://foxel.cc/image/wechat.png" alt="WeChat Group QR Code" width="180">
> If the QR code is invalid, please add WeChat ID **drizzle2001**, and we will invite you to the group.
## 📄 License
Foxel is open-sourced under the [MIT License](LICENSE).

View File

@@ -29,48 +29,105 @@
## ✨ 核心功能
- **统一文件管理**:集中管理分布于不同存储后端的文件。
- **插件化存储后端**:采用可扩展的适配器模式,方便集成多种存储类型。
- **语义搜索**:支持自然语言描述搜索图片、文档等非结构化数据内容
- **内置文件预览**可直接预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
- **权限与分享**:支持公开或私密分享链接,便于文件共享。
- **任务处理中心**:支持异步任务处理,如文件索引和数据备份,不影响主应用运行。
### 📁 统一文件管理
集中管理分布于不同存储后端的文件。浏览、上传、下载、移动、复制和删除——全部通过统一的界面完成
### 🔌 插件化存储后端
采用可扩展的适配器模式,方便集成多种存储类型:
| 分类 | 适配器 |
|---|---|
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP |
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘 |
| **特殊类型** | Telegram、AList、Foxel 互联 |
### 🔍 AI 语义搜索
突破文件名匹配的局限——通过自然语言描述搜索图片、文档等非结构化数据的内容。由可配置的 Embedding 服务和向量数据库Milvus、Qdrant驱动。
### 👁️ 内置文件预览
可直接在浏览器中预览图片、视频、PDF、Office 文档及文本、代码文件,无需下载。
### 🔐 权限与访问控制
完善的 **基于角色的访问控制RBAC** 系统,全方位保障数据安全:
- **内置角色**:三个系统角色 — **管理员**(完全访问)、**用户**(可配置访问权限)、**观察者**(只读访问)。
- **自定义角色**:可创建自定义角色,灵活分配系统权限和适配器权限。
- **路径级权限规则**:为每个路径定义 读取 / 写入 / 删除 / 分享 权限,支持 **通配符**、**正则表达式** 匹配和 **优先级排序**
- **审计日志**记录所有用户操作包含完整的追溯信息用户、IP、请求方法、状态码、耗时
### 🔗 文件分享
生成公开或加密的分享链接,支持设置过期时间。接收方无需登录即可浏览分享的文件和文件夹。
### 🧩 插件系统
通过基于清单Manifest的插件架构扩展 Foxel 的功能。支持在运行时加载 React 前端组件和自定义后端路由,无需修改核心代码。
### ⚙️ 任务处理中心
支持异步后台任务——文件索引、数据备份、定时作业——不影响主应用运行。
### 🤖 AI 智能助手
内置 AI Agent提供 VFS 操作、网页抓取、文件处理等工具,将智能自动化能力直接融入你的云盘。
### 🌐 协议映射
通过熟悉的协议访问你的文件:
- **S3 API** — S3 兼容接口,支持编程方式访问
- **WebDAV** — 可在操作系统文件管理器中挂载为网络硬盘
- **直链** — 临时签名 URL支持直接文件访问
## 🛠️ 技术栈
| 层级 | 技术 |
|---|---|
| **后端** | Python 3.14+、FastAPI、Tortoise ORM、SQLite |
| **前端** | React 19、TypeScript、Vite、Ant Design |
| **认证** | JWTOAuth2、bcrypt |
| **向量数据库** | Milvus Lite / Server、Qdrant |
| **部署** | Docker、Gunicorn + Uvicorn |
| **包管理** | uvPython、BunJS |
## 🚀 快速开始
使用 Docker Compose 是启动 Foxel 最推荐的方式。
1. **创建数据目录**
### 1. 创建数据目录
新建 `data` 文件夹用于持久化数据:
新建 `data` 文件夹用于持久化数据:
```bash
mkdir -p data/db
mkdir -p data/mount
chmod 777 data/db data/mount
```
```bash
mkdir -p data/db data/mount
chmod 777 data/db data/mount
```
2. **下载 Docker Compose 文件**
### 2. 下载 Docker Compose 文件
```bash
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
```
```bash
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
```
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
- 修改 `SECRET_KEY` 和 `TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
- 修改 `SECRET_KEY``TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
3. **启动服务**
### 3. 启动服务
```bash
docker-compose up -d
```
```bash
docker-compose up -d
```
4. **访问应用**
### 4. 访问应用
服务启动后,在浏览器中打开页面。
服务启动后,在浏览器中打开页面。
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
## 🤝 如何贡献
@@ -87,3 +144,7 @@
<img src="https://foxel.cc/image/wechat.png" alt="微信群二维码" width="180">
> 如果二维码失效,请添加微信号 **drizzle2001**,我们会邀请你加入群聊。
## 📄 许可证
Foxel 基于 [MIT 许可证](LICENSE) 开源。

View File

@@ -299,23 +299,23 @@ class LocalAdapter:
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
async def stat_file(self, root: str, rel: str):
async def stat_file(self, root: str, rel: str, include_metadata: bool = False):
fp = _safe_join(root, rel)
if not fp.exists():
raise FileNotFoundError(rel)
st = await asyncio.to_thread(fp.stat)
is_dir = fp.is_dir()
info = {
"name": fp.name,
"is_dir": fp.is_dir(),
"is_dir": is_dir,
"size": st.st_size,
"mtime": int(st.st_mtime),
"mode": stat.S_IMODE(st.st_mode),
"type": "dir" if fp.is_dir() else "file",
"type": "dir" if is_dir else "file",
"path": str(fp),
}
# exif信息
exif = None
if not fp.is_dir():
if include_metadata and not is_dir:
exif = None
mime, _ = mimetypes.guess_type(fp.name)
if mime and mime.startswith("image/"):
try:
@@ -326,7 +326,7 @@ class LocalAdapter:
exif = {str(k): str(v) for k, v in exif_data.items()}
except Exception:
exif = None
info["exif"] = exif
info["exif"] = exif
return info

View File

@@ -0,0 +1,875 @@
import asyncio
import hashlib
import mimetypes
import re
import time
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
import httpx
from fastapi import HTTPException
from fastapi.responses import Response, StreamingResponse
from models import StorageAdapter
from .base import BaseAdapter
API_BASE = "https://api-drive.mypikpak.net/drive/v1"
USER_BASE = "https://user.mypikpak.net/v1"
ANDROID_ALGORITHMS = [
"SOP04dGzk0TNO7t7t9ekDbAmx+eq0OI1ovEx",
"nVBjhYiND4hZ2NCGyV5beamIr7k6ifAsAbl",
"Ddjpt5B/Cit6EDq2a6cXgxY9lkEIOw4yC1GDF28KrA",
"VVCogcmSNIVvgV6U+AochorydiSymi68YVNGiz",
"u5ujk5sM62gpJOsB/1Gu/zsfgfZO",
"dXYIiBOAHZgzSruaQ2Nhrqc2im",
"z5jUTBSIpBN9g4qSJGlidNAutX6",
"KJE2oveZ34du/g1tiimm",
]
WEB_ALGORITHMS = [
"C9qPpZLN8ucRTaTiUMWYS9cQvWOE",
"+r6CQVxjzJV6LCV",
"F",
"pFJRC",
"9WXYIDGrwTCz2OiVlgZa90qpECPD6olt",
"/750aCr4lm/Sly/c",
"RB+DT/gZCrbV",
"",
"CyLsf7hdkIRxRm215hl",
"7xHvLi2tOYP0Y92b",
"ZGTXXxu8E/MIWaEDB+Sm/",
"1UI3",
"E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO",
"ihtqpG6FMt65+Xk+tWUH2",
"NhXXU9rg4XXdzo7u5o",
]
PC_ALGORITHMS = [
"KHBJ07an7ROXDoK7Db",
"G6n399rSWkl7WcQmw5rpQInurc1DkLmLJqE",
"JZD1A3M4x+jBFN62hkr7VDhkkZxb9g3rWqRZqFAAb",
"fQnw/AmSlbbI91Ik15gpddGgyU7U",
"/Dv9JdPYSj3sHiWjouR95NTQff",
"yGx2zuTjbWENZqecNI+edrQgqmZKP",
"ljrbSzdHLwbqcRn",
"lSHAsqCkGDGxQqqwrVu",
"TsWXI81fD1",
"vk7hBjawK/rOSrSWajtbMk95nfgf3",
]
PLATFORM_CONFIG = {
"android": {
"client_id": "YNxT9w7GMdWvEOKa",
"client_secret": "dbw2OtmVEeuUvIptb1Coyg",
"client_version": "1.53.2",
"package_name": "com.pikcloud.pikpak",
"sdk_version": "2.0.6.206003",
"algorithms": ANDROID_ALGORITHMS,
"ua": None,
},
"web": {
"client_id": "YUMx5nI8ZU8Ap8pm",
"client_secret": "dbw2OtmVEeuUvIptb1Coyg",
"client_version": "2.0.0",
"package_name": "mypikpak.com",
"sdk_version": "8.0.3",
"algorithms": WEB_ALGORITHMS,
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
},
"pc": {
"client_id": "YvtoWO6GNHiuCl7x",
"client_secret": "1NIH5R1IEe2pAxZE3hv3uA",
"client_version": "undefined",
"package_name": "mypikpak.com",
"sdk_version": "8.0.3",
"algorithms": PC_ALGORITHMS,
"ua": "MainWindow Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 "
"(KHTML, like Gecko) PikPak/2.6.11.4955 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36",
},
}
def _md5_text(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest()
def _sha1_text(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()
def _as_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _root_payload(root: str | None) -> Tuple[str, str]:
raw = (root or "").strip()
if not raw:
return "", ""
if "|" not in raw:
return raw, ""
root_id, sub_path = raw.split("|", 1)
return root_id.strip(), sub_path.strip("/")
def _split_parent_name(rel: str) -> Tuple[str, str]:
rel = (rel or "").strip("/")
if not rel:
return "", ""
if "/" not in rel:
return "", rel
parent, _, name = rel.rpartition("/")
return parent, name
def _parse_time(value: str | None) -> int:
if not value:
return 0
text = str(value).strip()
if not text:
return 0
try:
from datetime import datetime, timezone
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
except Exception:
return 0
class PikPakAdapter:
def __init__(self, record: StorageAdapter):
self.record = record
cfg = record.config or {}
self.username = str(cfg.get("username") or "").strip()
self.password = str(cfg.get("password") or "")
if not self.username or not self.password:
raise ValueError("PikPak adapter requires username and password")
self.platform = str(cfg.get("platform") or "web").strip().lower()
if self.platform not in PLATFORM_CONFIG:
self.platform = "web"
platform_cfg = PLATFORM_CONFIG[self.platform]
self.client_id = str(platform_cfg["client_id"])
self.client_secret = str(platform_cfg["client_secret"])
self.client_version = str(platform_cfg["client_version"])
self.package_name = str(platform_cfg["package_name"])
self.sdk_version = str(platform_cfg["sdk_version"])
self.algorithms = list(platform_cfg["algorithms"])
self.device_id = str(cfg.get("device_id") or "").strip() or _md5_text(self.username + self.password)
self.user_id = str(cfg.get("user_id") or "").strip()
self.refresh_token = str(cfg.get("refresh_token") or "").strip()
self.access_token = str(cfg.get("access_token") or "").strip()
self.captcha_token = str(cfg.get("captcha_token") or "").strip()
self.root_id = str(cfg.get("root_id") or "").strip()
self.disable_media_link = _as_bool(cfg.get("disable_media_link"), True)
self.enable_direct_download_307 = _as_bool(cfg.get("enable_direct_download_307"), False)
self.timeout = float(cfg.get("timeout") or 30)
ua = platform_cfg.get("ua")
self.user_agent = str(ua) if ua else self._build_android_user_agent()
self._auth_lock = asyncio.Lock()
self._config_save_lock = asyncio.Lock()
self._dir_id_cache: Dict[str, str] = {}
self._children_cache: Dict[str, List[Dict[str, Any]]] = {}
def get_effective_root(self, sub_path: str | None) -> str:
return f"{self.root_id}|{(sub_path or '').strip('/')}"
def _build_android_user_agent(self) -> str:
device_sign = self._generate_device_sign(self.device_id, self.package_name)
user_id = self.user_id
return (
f"ANDROID-{self.package_name}/{self.client_version} "
"protocolVersion/200 accesstype/ "
f"clientid/{self.client_id} "
f"clientversion/{self.client_version} "
"action_type/ networktype/WIFI sessionid/ "
f"deviceid/{self.device_id} "
"providername/NONE "
f"devicesign/{device_sign} "
"refresh_token/ "
f"sdkversion/{self.sdk_version} "
f"datetime/{int(time.time() * 1000)} "
f"usrno/{user_id} "
f"appname/android-{self.package_name} "
"session_origin/ grant_type/ appid/ clientip/ "
"devicename/Xiaomi_M2004j7ac osversion/13 platformversion/10 "
"accessmode/ devicemodel/M2004J7AC "
)
@staticmethod
def _generate_device_sign(device_id: str, package_name: str) -> str:
sha1_str = _sha1_text(f"{device_id}{package_name}1appkey")
md5_str = _md5_text(sha1_str)
return f"div101.{device_id}{md5_str}"
def _captcha_sign(self) -> Tuple[str, str]:
timestamp = str(int(time.time() * 1000))
value = f"{self.client_id}{self.client_version}{self.package_name}{self.device_id}{timestamp}"
for algorithm in self.algorithms:
value = _md5_text(value + algorithm)
return timestamp, "1." + value
@staticmethod
def _action(method: str, url: str) -> str:
m = re.search(r"://[^/]+((/[^/\s?#]+)*)", url)
path = m.group(1) if m else "/"
return f"{method.upper()}:{path}"
def _download_headers(self) -> Dict[str, str]:
headers = {
"User-Agent": self.user_agent,
"X-Device-ID": self.device_id,
"X-Captcha-Token": self.captcha_token,
}
if self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
return headers
async def _save_runtime_config(self):
cfg = dict(self.record.config or {})
changed = False
for key, value in (
("refresh_token", self.refresh_token),
("captcha_token", self.captcha_token),
("device_id", self.device_id),
):
if value and cfg.get(key) != value:
cfg[key] = value
changed = True
if not changed:
return
async with self._config_save_lock:
self.record.config = cfg
await self.record.save(update_fields=["config"])
async def _ensure_auth(self):
if self.access_token:
return
async with self._auth_lock:
if self.access_token:
return
if self.refresh_token:
try:
await self._refresh_access_token()
return
except Exception:
self.access_token = ""
if not self.username or not self.password:
raise
await self._login()
async def _login(self):
url = f"{USER_BASE}/auth/signin"
if not self.captcha_token:
await self._refresh_captcha_token(self._action("POST", url), self._login_captcha_meta())
body = {
"captcha_token": self.captcha_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
"username": self.username,
"password": self.password,
}
data = await self._raw_json("POST", url, json=body, params={"client_id": self.client_id}, auth=False)
self.refresh_token = str(data.get("refresh_token") or "").strip()
self.access_token = str(data.get("access_token") or "").strip()
self.user_id = str(data.get("sub") or self.user_id).strip()
if not self.refresh_token or not self.access_token:
raise HTTPException(502, detail="PikPak login failed: missing token")
if self.platform == "android":
self.user_agent = self._build_android_user_agent()
await self._save_runtime_config()
async def _refresh_access_token(self):
url = f"{USER_BASE}/auth/token"
body = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
}
data = await self._raw_json("POST", url, json=body, params={"client_id": self.client_id}, auth=False)
self.refresh_token = str(data.get("refresh_token") or "").strip()
self.access_token = str(data.get("access_token") or "").strip()
self.user_id = str(data.get("sub") or self.user_id).strip()
if not self.refresh_token or not self.access_token:
raise HTTPException(502, detail="PikPak refresh token failed: missing token")
if self.platform == "android":
self.user_agent = self._build_android_user_agent()
await self._save_runtime_config()
def _login_captcha_meta(self) -> Dict[str, str]:
if re.match(r"\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*", self.username):
return {"email": self.username}
if 11 <= len(self.username) <= 18:
return {"phone_number": self.username}
return {"username": self.username}
async def _refresh_captcha_token(self, action: str, meta: Dict[str, str]):
url = f"{USER_BASE}/shield/captcha/init"
body = {
"action": action,
"captcha_token": self.captcha_token,
"client_id": self.client_id,
"device_id": self.device_id,
"meta": meta,
"redirect_uri": "xlaccsdk01://xbase.cloud/callback?state=harbor",
}
data = await self._raw_json("POST", url, json=body, params={"client_id": self.client_id}, auth=False)
verify_url = str(data.get("url") or "").strip()
token = str(data.get("captcha_token") or "").strip()
if token and not verify_url:
self.captcha_token = token
await self._save_runtime_config()
if verify_url:
raise HTTPException(
400,
detail=(
"PikPak requires captcha verification. Open the URL, finish verification, "
"then capture the fresh captcha_token from the successful verification request and paste it into the adapter config. URL: "
f"{verify_url}"
),
)
if not token:
raise HTTPException(502, detail="PikPak captcha refresh failed: missing captcha_token")
self.captcha_token = token
await self._save_runtime_config()
async def _refresh_captcha_token_after_login(self, method: str, url: str):
timestamp, sign = self._captcha_sign()
meta = {
"client_version": self.client_version,
"package_name": self.package_name,
"user_id": self.user_id,
"timestamp": timestamp,
"captcha_sign": sign,
}
await self._refresh_captcha_token(self._action(method, url), meta)
async def _raw_json(
self,
method: str,
url: str,
*,
json: Any | None = None,
params: Dict[str, Any] | None = None,
auth: bool = True,
retry_auth: bool = True,
retry_captcha: bool = True,
) -> Dict[str, Any]:
if auth:
await self._ensure_auth()
headers = {
"User-Agent": self.user_agent,
"X-Device-ID": self.device_id,
"X-Captcha-Token": self.captcha_token,
}
if auth and self.access_token:
headers["Authorization"] = f"Bearer {self.access_token}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
resp = await client.request(method, url, headers=headers, params=params, json=json)
payload: Dict[str, Any] = {}
try:
parsed = resp.json()
if isinstance(parsed, dict):
payload = parsed
except Exception:
resp.raise_for_status()
return {}
if auth and retry_auth and resp.status_code in {401, 403}:
async with self._auth_lock:
await self._refresh_access_token()
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=False,
retry_captcha=retry_captcha,
)
error_code = payload.get("error_code")
error_msg = payload.get("error") or payload.get("error_description") or payload.get("message")
try:
code_int = int(error_code or 0)
except Exception:
code_int = 0
has_error = code_int != 0 or bool(error_msg and resp.status_code >= 400)
if has_error:
if auth and retry_auth and code_int in {4122, 4121, 16}:
async with self._auth_lock:
await self._refresh_access_token()
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=False,
retry_captcha=retry_captcha,
)
if code_int == 4002 or error_msg == "captcha_invalid":
if retry_captcha:
if auth:
if self.user_id:
await self._refresh_captcha_token_after_login(method, url)
else:
await self._refresh_captcha_token(self._action(method, url), self._login_captcha_meta())
else:
await self._refresh_captcha_token(self._action(method, url), self._login_captcha_meta())
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=retry_auth,
retry_captcha=False,
)
raise HTTPException(
400,
detail=(
"PikPak captcha_invalid. Refresh the captcha token, then retry after solving the verification page."
),
)
if auth and retry_captcha and code_int == 9:
await self._refresh_captcha_token_after_login(method, url)
return await self._raw_json(
method,
url,
json=json,
params=params,
auth=auth,
retry_auth=retry_auth,
retry_captcha=False,
)
raise HTTPException(502, detail=f"PikPak error code={error_code} msg={error_msg}")
if resp.status_code >= 400:
raise HTTPException(resp.status_code, detail=f"PikPak HTTP error: {payload or resp.text}")
return payload
async def _request(
self,
method: str,
path_or_url: str,
*,
json: Any | None = None,
params: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
url = path_or_url if path_or_url.startswith("http") else API_BASE + path_or_url
return await self._raw_json(method, url, json=json, params=params, auth=True)
def _map_file_item(self, it: Dict[str, Any]) -> Dict[str, Any]:
is_dir = it.get("kind") == "drive#folder"
size = 0
if not is_dir:
try:
size = int(it.get("size") or 0)
except Exception:
size = 0
return {
"fid": it.get("id"),
"id": it.get("id"),
"name": it.get("name") or "",
"is_dir": is_dir,
"size": size,
"ctime": _parse_time(it.get("created_time")),
"mtime": _parse_time(it.get("modified_time")),
"type": "dir" if is_dir else "file",
"hash": it.get("hash") or "",
"thumbnail_link": it.get("thumbnail_link") or "",
"web_content_link": it.get("web_content_link") or "",
"medias": it.get("medias") or [],
}
async def _list_children(self, parent_id: str) -> List[Dict[str, Any]]:
if parent_id in self._children_cache:
return self._children_cache[parent_id]
items: List[Dict[str, Any]] = []
page_token = ""
while True:
params = {
"parent_id": parent_id,
"thumbnail_size": "SIZE_LARGE",
"with_audit": "true",
"limit": "100",
"filters": '{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}',
"page_token": page_token,
}
data = await self._request("GET", "/files", params=params)
files = data.get("files") or []
if isinstance(files, list):
items.extend(self._map_file_item(x) for x in files if isinstance(x, dict))
page_token = str(data.get("next_page_token") or "")
if not page_token:
break
self._children_cache[parent_id] = items
return items
async def _resolve_root_id(self, root: str | None) -> str:
root_id, sub_path = _root_payload(root)
base_id = root_id or ""
if not sub_path:
return base_id
return await self._resolve_dir_id_from(base_id, sub_path)
async def _resolve_dir_id_from(self, base_id: str, rel: str) -> str:
rel = (rel or "").strip("/")
cache_key = f"{base_id}:{rel}"
if cache_key in self._dir_id_cache:
return self._dir_id_cache[cache_key]
if not rel:
self._dir_id_cache[cache_key] = base_id
return base_id
parent_id = base_id
path_so_far: List[str] = []
for seg in rel.split("/"):
if not seg:
continue
path_so_far.append(seg)
current_key = f"{base_id}:{'/'.join(path_so_far)}"
cached = self._dir_id_cache.get(current_key)
if cached is not None:
parent_id = cached
continue
children = await self._list_children(parent_id)
found = next((item for item in children if item["is_dir"] and item["name"] == seg), None)
if not found:
raise FileNotFoundError(rel)
parent_id = str(found["fid"])
self._dir_id_cache[current_key] = parent_id
return parent_id
async def _find_child(self, parent_id: str, name: str) -> Optional[Dict[str, Any]]:
children = await self._list_children(parent_id)
return next((item for item in children if item.get("name") == name), None)
async def _resolve_obj(self, root: str, rel: str) -> Dict[str, Any]:
rel = (rel or "").strip("/")
base_id = await self._resolve_root_id(root)
if not rel:
return {"fid": base_id, "id": base_id, "name": "", "is_dir": True, "size": 0, "mtime": 0, "type": "dir"}
if rel.endswith("/"):
fid = await self._resolve_dir_id_from(base_id, rel.rstrip("/"))
return {"fid": fid, "id": fid, "name": rel.rstrip("/").split("/")[-1], "is_dir": True, "size": 0, "mtime": 0, "type": "dir"}
parent_rel, name = _split_parent_name(rel)
parent_id = await self._resolve_dir_id_from(base_id, parent_rel)
item = await self._find_child(parent_id, name)
if not item:
raise FileNotFoundError(rel)
return item
async def _resolve_parent_and_obj(self, root: str, rel: str) -> Tuple[str, Dict[str, Any]]:
base_id = await self._resolve_root_id(root)
parent_rel, name = _split_parent_name(rel)
parent_id = await self._resolve_dir_id_from(base_id, parent_rel)
item = await self._find_child(parent_id, name)
if not item:
raise FileNotFoundError(rel)
return parent_id, item
def _invalidate_children_cache(self, parent_id: str):
self._children_cache.pop(parent_id, None)
def _clear_path_cache(self):
self._dir_id_cache.clear()
async def list_dir(
self,
root: str,
rel: str,
page_num: int = 1,
page_size: int = 50,
sort_by: str = "name",
sort_order: str = "asc",
) -> Tuple[List[Dict], int]:
base_id = await self._resolve_root_id(root)
target_id = await self._resolve_dir_id_from(base_id, rel)
items = list(await self._list_children(target_id))
reverse = sort_order.lower() == "desc"
def sort_key(item: Dict[str, Any]) -> Tuple:
key = (not item.get("is_dir"),)
field = sort_by.lower()
if field == "size":
key += (int(item.get("size") or 0),)
elif field == "mtime":
key += (int(item.get("mtime") or 0),)
else:
key += (str(item.get("name") or "").lower(),)
return key
items.sort(key=sort_key, reverse=reverse)
total = len(items)
start = max(page_num - 1, 0) * page_size
return items[start : start + page_size], total
async def stat_file(self, root: str, rel: str):
return await self._resolve_obj(root, rel)
async def stat_path(self, root: str, rel: str):
try:
item = await self._resolve_obj(root, rel)
return {"exists": True, "is_dir": bool(item.get("is_dir")), "path": rel, "fid": item.get("fid")}
except FileNotFoundError:
return {"exists": False, "is_dir": None, "path": rel}
async def exists(self, root: str, rel: str) -> bool:
try:
await self._resolve_obj(root, rel)
return True
except FileNotFoundError:
return False
async def _get_remote_file(self, file_id: str) -> Dict[str, Any]:
params = {"_magic": "2021", "usage": "FETCH", "thumbnail_size": "SIZE_LARGE"}
if not self.disable_media_link:
params["usage"] = "CACHE"
return await self._request("GET", f"/files/{file_id}", params=params)
async def _get_download_url(self, item: Dict[str, Any]) -> str:
file_id = str(item.get("fid") or item.get("id") or "")
if not file_id:
raise FileNotFoundError(item.get("name") or "")
data = await self._get_remote_file(file_id)
url = str(data.get("web_content_link") or "").strip()
medias = data.get("medias") or []
if not self.disable_media_link and isinstance(medias, list) and medias:
first = medias[0]
if isinstance(first, dict):
media_url = str(((first.get("link") or {}).get("url") if isinstance(first.get("link"), dict) else "") or "")
if media_url:
url = media_url
if not url:
raise HTTPException(502, detail="PikPak did not return download url")
return url
async def read_file(self, root: str, rel: str) -> bytes:
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
raise IsADirectoryError(rel)
url = await self._get_download_url(item)
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
resp = await client.get(url, headers=self._download_headers())
if resp.status_code == 404:
raise FileNotFoundError(rel)
resp.raise_for_status()
return resp.content
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
raise IsADirectoryError(rel)
url = await self._get_download_url(item)
headers = self._download_headers()
headers["Range"] = f"bytes={start}-" if end is None else f"bytes={start}-{end}"
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
if resp.status_code == 404:
raise FileNotFoundError(rel)
if resp.status_code == 416:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp.raise_for_status()
return resp.content
async def stream_file(self, root: str, rel: str, range_header: str | None):
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
raise IsADirectoryError(rel)
url = await self._get_download_url(item)
file_size = int(item.get("size") or 0)
mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream"
start = 0
end = file_size - 1 if file_size > 0 else None
status_code = 200
if range_header and range_header.startswith("bytes="):
status_code = 206
part = range_header.split("=", 1)[1]
s, _, e = part.partition("-")
if s.strip():
start = int(s)
if e.strip():
end = int(e)
elif file_size > 0:
end = file_size - 1
if file_size > 0:
if start >= file_size:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
if end is None or end >= file_size:
end = file_size - 1
if start > end:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp_headers = {"Accept-Ranges": "bytes", "Content-Type": content_type}
if file_size > 0:
if status_code == 206 and end is not None:
resp_headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
resp_headers["Content-Length"] = str(end - start + 1)
else:
resp_headers["Content-Length"] = str(file_size)
async def iterator():
headers = self._download_headers()
if status_code == 206 and end is not None:
headers["Range"] = f"bytes={start}-{end}"
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code == 404:
raise FileNotFoundError(rel)
if resp.status_code == 416:
raise HTTPException(416, detail="Requested Range Not Satisfiable")
resp.raise_for_status()
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type)
async def get_direct_download_response(self, root: str, rel: str):
if not self.enable_direct_download_307:
return None
item = await self._resolve_obj(root, rel)
if item.get("is_dir"):
return None
url = await self._get_download_url(item)
return Response(status_code=307, headers={"Location": url})
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
item = await self._resolve_obj(root, rel)
url = str(item.get("thumbnail_link") or "").strip()
if not url:
return None
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
resp = await client.get(url, headers=self._download_headers())
if resp.status_code >= 400:
return None
return resp.content
async def mkdir(self, root: str, rel: str):
rel = (rel or "").strip("/")
if not rel:
raise HTTPException(400, detail="Cannot create root")
parent_rel, name = _split_parent_name(rel)
if not name:
raise HTTPException(400, detail="Invalid directory name")
base_id = await self._resolve_root_id(root)
parent_id = await self._resolve_dir_id_from(base_id, parent_rel)
await self._request("POST", "/files", json={"kind": "drive#folder", "parent_id": parent_id, "name": name})
self._invalidate_children_cache(parent_id)
async def delete(self, root: str, rel: str):
parent_id, item = await self._resolve_parent_and_obj(root, rel)
await self._request("POST", "/files:batchTrash", json={"ids": [item["fid"]]})
self._invalidate_children_cache(parent_id)
if item.get("is_dir"):
self._clear_path_cache()
async def move(self, root: str, src_rel: str, dst_rel: str):
src_parent_id, item = await self._resolve_parent_and_obj(root, src_rel)
base_id = await self._resolve_root_id(root)
dst_parent_rel, dst_name = _split_parent_name(dst_rel)
dst_parent_id = await self._resolve_dir_id_from(base_id, dst_parent_rel)
if src_parent_id != dst_parent_id:
await self._request("POST", "/files:batchMove", json={"ids": [item["fid"]], "to": {"parent_id": dst_parent_id}})
self._invalidate_children_cache(src_parent_id)
self._invalidate_children_cache(dst_parent_id)
if item.get("name") != dst_name:
await self._request("PATCH", f"/files/{item['fid']}", json={"name": dst_name})
self._invalidate_children_cache(dst_parent_id)
if item.get("is_dir"):
self._clear_path_cache()
async def rename(self, root: str, src_rel: str, dst_rel: str):
await self.move(root, src_rel, dst_rel)
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
src_parent_id, item = await self._resolve_parent_and_obj(root, src_rel)
base_id = await self._resolve_root_id(root)
dst_parent_rel, dst_name = _split_parent_name(dst_rel)
dst_parent_id = await self._resolve_dir_id_from(base_id, dst_parent_rel)
await self._request("POST", "/files:batchCopy", json={"ids": [item["fid"]], "to": {"parent_id": dst_parent_id}})
self._invalidate_children_cache(dst_parent_id)
if item.get("name") != dst_name:
children = await self._list_children(dst_parent_id)
copied_candidates = [x for x in children if x.get("name") == item.get("name") and x.get("fid") != item.get("fid")]
copied = None
if copied_candidates:
copied_candidates.sort(key=lambda x: (int(x.get("ctime") or 0), int(x.get("mtime") or 0)), reverse=True)
copied = copied_candidates[0]
if copied:
await self._request("PATCH", f"/files/{copied['fid']}", json={"name": dst_name})
self._invalidate_children_cache(dst_parent_id)
if item.get("is_dir"):
self._clear_path_cache()
_ = src_parent_id
async def write_file(self, root: str, rel: str, data: bytes):
raise HTTPException(501, detail="PikPak upload not implemented")
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
raise HTTPException(501, detail="PikPak upload not implemented")
async def write_upload_file(
self,
root: str,
rel: str,
file_obj,
filename: str | None,
file_size: int | None = None,
content_type: str | None = None,
):
raise HTTPException(501, detail="PikPak upload not implemented")
ADAPTER_TYPE = "pikpak"
CONFIG_SCHEMA = [
{"key": "username", "label": "PikPak 账号", "type": "string", "required": True},
{"key": "password", "label": "PikPak 密码", "type": "password", "required": True},
{"key": "platform", "label": "平台", "type": "select", "required": False, "default": "web", "options": ["web", "android", "pc"]},
{"key": "refresh_token", "label": "Refresh Token", "type": "password", "required": False},
{"key": "captcha_token", "label": "Captcha Token", "type": "password", "required": False},
{"key": "device_id", "label": "Device ID", "type": "string", "required": False},
{"key": "root_id", "label": "根目录 ID", "type": "string", "required": False, "default": ""},
{"key": "disable_media_link", "label": "禁用媒体转码链接", "type": "boolean", "required": False, "default": True},
{"key": "enable_direct_download_307", "label": "直链 307 跳转", "type": "boolean", "required": False, "default": False},
]
def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter:
return PikPakAdapter(rec)

View File

@@ -376,7 +376,7 @@ class WebDAVAdapter:
return StreamingResponse(segmented_body(), status_code=status_code, headers=resp_headers, media_type=content_type)
async def stat_file(self, root: str, rel: str):
async def stat_file(self, root: str, rel: str, include_metadata: bool = False):
url = self._build_url(rel)
async with self._client() as client:
# PROPFIND 获取属性
@@ -426,9 +426,8 @@ class WebDAVAdapter:
info["mtime"] = 0
elif info["mtime"] is None:
info["mtime"] = 0
# exif信息
exif = None
if not info["is_dir"]:
if include_metadata and not info["is_dir"]:
exif = None
mime, _ = mimetypes.guess_type(info["name"])
if mime and mime.startswith("image/"):
try:
@@ -442,7 +441,7 @@ class WebDAVAdapter:
exif = {str(k): str(v) for k, v in exif_data.items()}
except Exception:
exif = None
info["exif"] = exif
info["exif"] = exif
return info
async def exists(self, root: str, rel: str) -> bool:

View File

@@ -1,9 +1,10 @@
from .service import AgentService
from .types import AgentChatContext, AgentChatRequest, PendingToolCall
from .types import AgentChatContext, AgentChatRequest, McpCall, PendingMcpCall
__all__ = [
"AgentService",
"AgentChatContext",
"AgentChatRequest",
"PendingToolCall",
"McpCall",
"PendingMcpCall",
]

View File

@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
@router.post("/chat")
@audit(action=AuditAction.CREATE, description="Agent 对话", body_fields=["auto_execute"])
@audit(action=AuditAction.CREATE, description="Agent 对话", body_fields=["auto_execute", "approved_mcp_call_ids", "rejected_mcp_call_ids"])
async def chat(
request: Request,
payload: AgentChatRequest,
@@ -25,7 +25,7 @@ async def chat(
@router.post("/chat/stream")
@audit(action=AuditAction.CREATE, description="Agent 对话SSE", body_fields=["auto_execute"])
@audit(action=AuditAction.CREATE, description="Agent 对话SSE", body_fields=["auto_execute", "approved_mcp_call_ids", "rejected_mcp_call_ids"])
async def chat_stream(
request: Request,
payload: AgentChatRequest,

334
domain/agent/mcp.py Normal file
View File

@@ -0,0 +1,334 @@
import inspect
import json
from contextlib import asynccontextmanager
from datetime import timedelta
from typing import Annotated, Any, Literal
from urllib.parse import quote, unquote
import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.fastmcp.server import AuthSettings
from mcp.types import ToolAnnotations
from pydantic import Field
from domain.auth import AuthService, User
from domain.processors import ProcessorService
from .tools import get_tool, mcp_tool_descriptors
from .tools.base import McpToolDescriptor, normalize_tool_result, tool_result_to_content
INTERNAL_MCP_BASE_URL = "http://127.0.0.1:8000/"
CURRENT_PATH_HEADER = "x-foxel-current-path"
def _normalize_path(path: str | None) -> str | None:
if not path:
return None
value = str(path).strip().replace("\\", "/")
if not value:
return None
if not value.startswith("/"):
value = "/" + value
return value.rstrip("/") or "/"
def _header_current_path(ctx: Context | None) -> str | None:
request = ctx.request_context.request if ctx and ctx.request_context else None
if request is None:
return None
return _normalize_path(request.headers.get(CURRENT_PATH_HEADER))
def _field_annotation(schema: dict[str, Any], required: bool) -> tuple[Any, Any]:
raw_type = schema.get("type")
enum_values = schema.get("enum")
description = str(schema.get("description") or "").strip() or None
default = schema.get("default", inspect.Parameter.empty if required else None)
annotation: Any
if isinstance(enum_values, list) and enum_values:
annotation = Literal.__getitem__(tuple(enum_values))
elif raw_type == "string":
annotation = str
elif raw_type == "integer":
annotation = int
elif raw_type == "number":
annotation = float
elif raw_type == "boolean":
annotation = bool
elif raw_type == "array":
annotation = list[Any]
elif raw_type == "object":
annotation = dict[str, Any]
else:
annotation = Any
if not required and default is None:
annotation = annotation | None
if description:
annotation = Annotated[annotation, Field(description=description)]
return annotation, default
def _build_tool_signature(descriptor: McpToolDescriptor) -> inspect.Signature:
schema = descriptor.input_schema if isinstance(descriptor.input_schema, dict) else {}
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
required = set(schema.get("required") or [])
parameters: list[inspect.Parameter] = []
for key, value in properties.items():
prop_schema = value if isinstance(value, dict) else {}
annotation, default = _field_annotation(prop_schema, key in required)
parameters.append(
inspect.Parameter(
str(key),
inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=default,
annotation=annotation,
)
)
return inspect.Signature(parameters=parameters, return_annotation=dict[str, Any])
def _build_tool_wrapper(descriptor: McpToolDescriptor):
async def wrapper(**kwargs: Any) -> dict[str, Any]:
spec = get_tool(descriptor.name)
if not spec:
return normalize_tool_result({"error": f"unknown_tool: {descriptor.name}"})
try:
result = await spec.handler(kwargs)
return normalize_tool_result(result)
except Exception as exc: # noqa: BLE001
return normalize_tool_result({"error": str(exc)})
wrapper.__name__ = descriptor.name
wrapper.__doc__ = descriptor.description
wrapper.__signature__ = _build_tool_signature(descriptor)
return wrapper
class FoxelMcpTokenVerifier:
async def verify_token(self, token: str) -> AccessToken | None:
try:
user = await AuthService.get_current_active_user(await AuthService.get_current_user(token))
except Exception: # noqa: BLE001
return None
return AccessToken(token=token, client_id=user.username, scopes=[])
MCP_SERVER = FastMCP(
name="Foxel MCP",
instructions="Foxel 内置 MCP 服务,提供文件系统、网页抓取、时间与处理器相关能力。",
streamable_http_path="/",
token_verifier=FoxelMcpTokenVerifier(),
auth=AuthSettings(
issuer_url="http://127.0.0.1:8000",
resource_server_url=None,
required_scopes=[],
),
)
for descriptor in mcp_tool_descriptors():
MCP_SERVER.add_tool(
_build_tool_wrapper(descriptor),
name=descriptor.name,
description=descriptor.description,
annotations=ToolAnnotations.model_validate(descriptor.annotations),
meta=descriptor.meta,
structured_output=False,
)
@MCP_SERVER.resource(
"foxel://context/current-path",
name="current_path",
title="Current Path",
description="返回当前请求上下文里的文件管理目录。",
mime_type="application/json",
)
def current_path_resource() -> dict[str, Any]:
return {"current_path": None}
@MCP_SERVER.resource(
"foxel://policy/tool-confirmation",
name="tool_confirmation_policy",
title="Tool Confirmation Policy",
description="返回 Foxel agent 对工具审批的策略。",
mime_type="application/json",
)
def tool_confirmation_policy_resource() -> dict[str, Any]:
return {
"read_tools": [tool.name for tool in mcp_tool_descriptors() if not tool.requires_confirmation],
"write_tools": [tool.name for tool in mcp_tool_descriptors() if tool.requires_confirmation],
"rule": "直接调用 MCP tool 时不额外审批;通过 agent 代表用户执行写操作时需要审批。",
}
@MCP_SERVER.resource(
"foxel://processors/index",
name="processors_index",
title="Processors Index",
description="返回当前可用处理器列表。",
mime_type="application/json",
)
def processors_index_resource() -> dict[str, Any]:
return {"processors": ProcessorService.list_processors()}
async def _tool_resource(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
spec = get_tool(tool_name)
if not spec:
return normalize_tool_result({"error": f"unknown_tool: {tool_name}"})
try:
result = await spec.handler(arguments)
return normalize_tool_result(result)
except Exception as exc: # noqa: BLE001
return normalize_tool_result({"error": str(exc)})
@MCP_SERVER.resource(
"foxel://vfs/stat/{path}",
name="vfs_stat_resource",
title="VFS Stat",
description="读取指定路径的文件或目录元信息path 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_stat_resource(path: str) -> dict[str, Any]:
return await _tool_resource("vfs_stat", {"path": "/" + unquote(path).lstrip("/")})
@MCP_SERVER.resource(
"foxel://vfs/text/{path}",
name="vfs_text_resource",
title="VFS Text",
description="读取文本文件内容path 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_text_resource(path: str) -> dict[str, Any]:
return await _tool_resource("vfs_read_text", {"path": "/" + unquote(path).lstrip("/")})
@MCP_SERVER.resource(
"foxel://vfs/dir/{path}",
name="vfs_dir_resource",
title="VFS Directory",
description="列出目录内容path 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_dir_resource(path: str) -> dict[str, Any]:
return await _tool_resource("vfs_list_dir", {"path": "/" + unquote(path).lstrip("/")})
@MCP_SERVER.resource(
"foxel://vfs/search/{query}",
name="vfs_search_resource",
title="VFS Search",
description="搜索文件query 需要 URL 编码。",
mime_type="application/json",
)
async def vfs_search_resource(query: str) -> dict[str, Any]:
return await _tool_resource("vfs_search", {"q": unquote(query)})
@MCP_SERVER.prompt(name="browse_path", title="Browse Path", description="生成浏览目录的推荐提示词。")
def browse_path_prompt(path: Annotated[str, Field(description="目标目录路径")]) -> list[dict[str, Any]]:
return [{"role": "user", "content": f"请先浏览目录 `{path}`,总结结构与关键文件。必要时调用 vfs_list_dir 与 vfs_stat。"}]
@MCP_SERVER.prompt(name="inspect_file", title="Inspect File", description="生成查看文件的推荐提示词。")
def inspect_file_prompt(path: Annotated[str, Field(description="目标文件路径")]) -> list[dict[str, Any]]:
return [{"role": "user", "content": f"请检查文件 `{path}` 的内容与用途。必要时调用 vfs_read_text。"}]
@MCP_SERVER.prompt(name="search_files", title="Search Files", description="生成搜索文件的推荐提示词。")
def search_files_prompt(query: Annotated[str, Field(description="搜索关键词")]) -> list[dict[str, Any]]:
return [{"role": "user", "content": f"请搜索与 `{query}` 相关的文件,并按相关性总结。必要时调用 vfs_search。"}]
@MCP_SERVER.prompt(name="edit_file_safely", title="Edit File Safely", description="生成安全修改文件的推荐提示词。")
def edit_file_safely_prompt(path: Annotated[str, Field(description="目标文件路径")]) -> list[dict[str, Any]]:
return [{"role": "user", "content": f"请先读取 `{path}`,解释拟修改点,再等待我确认后执行写入。"}]
@MCP_SERVER.prompt(name="run_processor", title="Run Processor", description="生成运行处理器的推荐提示词。")
def run_processor_prompt(
path: Annotated[str, Field(description="目标文件或目录路径")],
processor_type: Annotated[str, Field(description="处理器类型")],
) -> list[dict[str, Any]]:
return [{"role": "user", "content": f"请检查 `{path}` 是否适合运行处理器 `{processor_type}`,确认参数后再执行 processors_run。"}]
@MCP_SERVER.prompt(name="fetch_web_page", title="Fetch Web Page", description="生成抓取网页的推荐提示词。")
def fetch_web_page_prompt(url: Annotated[str, Field(description="目标网址")]) -> list[dict[str, Any]]:
return [{"role": "user", "content": f"请抓取网页 `{url}`,并总结标题、正文与关键链接。必要时调用 web_fetch。"}]
MCP_HTTP_APP = MCP_SERVER.streamable_http_app()
def loopback_httpx_client_factory(app):
def factory(headers: dict[str, str] | None = None, timeout=None, auth=None) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url=INTERNAL_MCP_BASE_URL.rstrip("/"),
headers=headers,
timeout=timeout,
auth=auth,
follow_redirects=True,
)
return factory
async def create_loopback_mcp_headers(user: User | None, current_path: str | None = None) -> dict[str, str]:
headers: dict[str, str] = {}
if user is not None:
token = await AuthService.create_access_token(
{"sub": user.username},
expires_delta=timedelta(minutes=5),
)
headers["Authorization"] = f"Bearer {token}"
if current_path:
headers[CURRENT_PATH_HEADER] = current_path
return headers
@asynccontextmanager
async def mcp_client_session(user: User | None, current_path: str | None = None):
headers = await create_loopback_mcp_headers(user, current_path)
async with streamablehttp_client(
INTERNAL_MCP_BASE_URL,
headers=headers,
httpx_client_factory=loopback_httpx_client_factory(MCP_HTTP_APP),
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
def mcp_content_to_text(content: list[Any], structured_content: dict[str, Any] | None = None) -> str:
if structured_content is not None:
try:
return json.dumps(structured_content, ensure_ascii=False)
except TypeError:
pass
text_parts: list[str] = []
for item in content:
item_type = getattr(item, "type", None)
if item_type == "text":
text = getattr(item, "text", None)
if isinstance(text, str) and text:
text_parts.append(text)
if text_parts:
return "\n".join(text_parts)
return tool_result_to_content({"error": "empty_mcp_content"})
def encode_resource_path(path: str) -> str:
return quote(path.lstrip("/"), safe="")

View File

@@ -8,27 +8,27 @@ from fastapi import HTTPException
from domain.ai import AIProviderService, MissingModelError, chat_completion, chat_completion_stream
from domain.auth import User
from .tools import get_tool, openai_tools, tool_result_to_content
from .types import AgentChatRequest, PendingToolCall
from .mcp import mcp_client_session, mcp_content_to_text
from .tools import tool_result_to_content
from .types import AgentChatRequest, PendingMcpCall
def _normalize_path(p: Optional[str]) -> Optional[str]:
if not p:
def _normalize_path(path: Optional[str]) -> Optional[str]:
if not path:
return None
s = str(p).strip()
if not s:
value = str(path).strip().replace("\\", "/")
if not value:
return None
s = s.replace("\\", "/")
if not s.startswith("/"):
s = "/" + s
s = s.rstrip("/") or "/"
return s
if not value.startswith("/"):
value = "/" + value
return value.rstrip("/") or "/"
def _build_system_prompt(current_path: Optional[str]) -> str:
lines = [
"你是 Foxel 的 AI 助手。",
"你可以通过工具对文件/目录进行查询、读写、移动、复制、删除以及运行处理器processor",
"你可以通过 MCP 工具对文件/目录进行查询、读写、移动、复制、删除以及运行处理器processor",
"",
"可用工具:",
"- time获取服务器当前时间精确到秒英文星期支持 year/month/day/hour/minute/second 偏移。",
@@ -60,13 +60,13 @@ def _build_system_prompt(current_path: Optional[str]) -> str:
return "\n".join(lines)
def _ensure_tool_call_ids(message: Dict[str, Any]) -> Dict[str, Any]:
tool_calls = message.get("tool_calls")
if not isinstance(tool_calls, list):
def _ensure_mcp_call_ids(message: Dict[str, Any]) -> Dict[str, Any]:
mcp_calls = message.get("mcp_calls")
if not isinstance(mcp_calls, list):
return message
changed = False
for idx, call in enumerate(tool_calls):
for idx, call in enumerate(mcp_calls):
if not isinstance(call, dict):
continue
call_id = call.get("id")
@@ -76,57 +76,54 @@ def _ensure_tool_call_ids(message: Dict[str, Any]) -> Dict[str, Any]:
changed = True
if changed:
message["tool_calls"] = tool_calls
message["mcp_calls"] = mcp_calls
return message
def _extract_pending(tool_call: Dict[str, Any], requires_confirmation: bool) -> PendingToolCall:
call_id = str(tool_call.get("id") or "")
fn = tool_call.get("function") or {}
name = str((fn.get("name") if isinstance(fn, dict) else None) or "")
raw_args = fn.get("arguments") if isinstance(fn, dict) else None
arguments: Dict[str, Any] = {}
if isinstance(raw_args, str) and raw_args.strip():
try:
parsed = json.loads(raw_args)
if isinstance(parsed, dict):
arguments = parsed
except json.JSONDecodeError:
arguments = {}
return PendingToolCall(
id=call_id,
name=name,
def _extract_pending(mcp_call: Dict[str, Any], requires_confirmation: bool) -> PendingMcpCall:
arguments = mcp_call.get("arguments") if isinstance(mcp_call.get("arguments"), dict) else {}
return PendingMcpCall(
id=str(mcp_call.get("id") or ""),
name=str(mcp_call.get("name") or ""),
arguments=arguments,
requires_confirmation=requires_confirmation,
)
def _find_last_assistant_tool_calls(messages: List[Dict[str, Any]]) -> Tuple[int, Dict[str, Any]]:
def _find_last_assistant_mcp_calls(messages: List[Dict[str, Any]]) -> Tuple[int, Dict[str, Any]]:
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if not isinstance(msg, dict):
continue
if msg.get("role") != "assistant":
continue
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
mcp_calls = msg.get("mcp_calls")
if isinstance(mcp_calls, list) and mcp_calls:
return idx, msg
raise HTTPException(status_code=400, detail="没有可确认的待执行操作")
def _existing_tool_result_ids(messages: List[Dict[str, Any]]) -> set[str]:
def _existing_mcp_result_ids(messages: List[Dict[str, Any]]) -> set[str]:
ids: set[str] = set()
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("role") != "tool":
continue
tool_call_id = msg.get("tool_call_id")
if isinstance(tool_call_id, str) and tool_call_id.strip():
ids.add(tool_call_id)
call_id = msg.get("mcp_call_id")
if isinstance(call_id, str) and call_id.strip():
ids.add(call_id)
return ids
def _tool_requires_confirmation(tool_descriptor: Dict[str, Any]) -> bool:
meta = tool_descriptor.get("meta") if isinstance(tool_descriptor.get("meta"), dict) else {}
if "requires_confirmation" in meta:
return bool(meta.get("requires_confirmation"))
annotations = tool_descriptor.get("annotations") if isinstance(tool_descriptor.get("annotations"), dict) else {}
return not bool(annotations.get("readOnlyHint"))
async def _choose_chat_ability() -> str:
tools_model = await AIProviderService.get_default_model("tools")
return "tools" if tools_model else "chat"
@@ -142,245 +139,91 @@ def _format_exc(exc: BaseException) -> str:
return text if text else exc.__class__.__name__
async def _list_mcp_tools(session) -> List[Dict[str, Any]]:
result = await session.list_tools()
tools: List[Dict[str, Any]] = []
for item in result.tools:
annotations = getattr(item, "annotations", None)
meta = getattr(item, "meta", None)
tools.append(
{
"name": str(getattr(item, "name", "") or ""),
"description": str(getattr(item, "description", "") or ""),
"input_schema": getattr(item, "inputSchema", None) or {},
"annotations": annotations.model_dump(exclude_none=True) if annotations is not None else {},
"meta": meta if isinstance(meta, dict) else {},
}
)
return tools
async def _execute_mcp_call(session, name: str, arguments: Dict[str, Any]) -> str:
result = await session.call_tool(name, arguments)
return mcp_content_to_text(result.content, result.structuredContent)
class AgentService:
@classmethod
async def chat(cls, req: AgentChatRequest, user: Optional[User]) -> Dict[str, Any]:
history: List[Dict[str, Any]] = list(req.messages or [])
current_path = _normalize_path(req.context.current_path if req.context else None)
system_prompt = _build_system_prompt(current_path)
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
new_messages: List[Dict[str, Any]] = []
pending: List[PendingToolCall] = []
pending: List[PendingMcpCall] = []
approved_ids = {i for i in (req.approved_tool_call_ids or []) if isinstance(i, str) and i.strip()}
rejected_ids = {i for i in (req.rejected_tool_call_ids or []) if isinstance(i, str) and i.strip()}
approved_ids = {i for i in (req.approved_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
rejected_ids = {i for i in (req.rejected_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
if approved_ids or rejected_ids:
_, last_call_msg = _find_last_assistant_tool_calls(internal_messages)
last_call_msg = _ensure_tool_call_ids(last_call_msg)
tool_calls = last_call_msg.get("tool_calls") or []
call_map: Dict[str, Dict[str, Any]] = {
str(c.get("id")): c
for c in tool_calls
if isinstance(c, dict) and isinstance(c.get("id"), str)
}
async with mcp_client_session(user, current_path) as mcp_session:
tools_schema = await _list_mcp_tools(mcp_session)
tool_index = {tool["name"]: tool for tool in tools_schema if tool.get("name")}
existing_ids = _existing_tool_result_ids(internal_messages)
for call_id in approved_ids | rejected_ids:
if call_id in existing_ids:
continue
tool_call = call_map.get(call_id)
if not tool_call:
continue
fn = tool_call.get("function") or {}
name = fn.get("name") if isinstance(fn, dict) else None
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
args: Dict[str, Any] = {}
if isinstance(args_raw, str) and args_raw.strip():
try:
parsed = json.loads(args_raw)
if isinstance(parsed, dict):
args = parsed
except json.JSONDecodeError:
args = {}
spec = get_tool(str(name or ""))
if call_id in rejected_ids:
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
continue
if not spec:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
continue
try:
result = await spec.handler(args)
content = tool_result_to_content(result)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
tools_schema = openai_tools()
ability = await _choose_chat_ability()
max_loops = 4
for _ in range(max_loops):
try:
assistant = await chat_completion(
internal_messages,
ability=ability,
tools=tools_schema,
tool_choice="auto",
timeout=60.0,
)
except MissingModelError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f"对话请求失败: {exc}") from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"对话请求异常: {exc}") from exc
assistant = _ensure_tool_call_ids(assistant)
internal_messages.append(assistant)
new_messages.append(assistant)
tool_calls = assistant.get("tool_calls")
if not isinstance(tool_calls, list) or not tool_calls:
break
pending = []
for call in tool_calls:
if not isinstance(call, dict):
continue
call_id = str(call.get("id") or "")
fn = call.get("function") or {}
name = fn.get("name") if isinstance(fn, dict) else None
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
args: Dict[str, Any] = {}
if isinstance(args_raw, str) and args_raw.strip():
try:
parsed = json.loads(args_raw)
if isinstance(parsed, dict):
args = parsed
except json.JSONDecodeError:
args = {}
spec = get_tool(str(name or ""))
if not spec:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
continue
if spec.requires_confirmation and not req.auto_execute:
pending.append(_extract_pending(call, True))
continue
try:
result = await spec.handler(args)
content = tool_result_to_content(result)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
if pending:
break
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
return payload
@classmethod
async def chat_stream(cls, req: AgentChatRequest, user: Optional[User]):
history: List[Dict[str, Any]] = list(req.messages or [])
current_path = _normalize_path(req.context.current_path if req.context else None)
system_prompt = _build_system_prompt(current_path)
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
new_messages: List[Dict[str, Any]] = []
pending: List[PendingToolCall] = []
approved_ids = {i for i in (req.approved_tool_call_ids or []) if isinstance(i, str) and i.strip()}
rejected_ids = {i for i in (req.rejected_tool_call_ids or []) if isinstance(i, str) and i.strip()}
try:
if approved_ids or rejected_ids:
_, last_call_msg = _find_last_assistant_tool_calls(internal_messages)
last_call_msg = _ensure_tool_call_ids(last_call_msg)
tool_calls = last_call_msg.get("tool_calls") or []
_, last_call_msg = _find_last_assistant_mcp_calls(internal_messages)
last_call_msg = _ensure_mcp_call_ids(last_call_msg)
mcp_calls = last_call_msg.get("mcp_calls") or []
call_map: Dict[str, Dict[str, Any]] = {
str(c.get("id")): c
for c in tool_calls
if isinstance(c, dict) and isinstance(c.get("id"), str)
str(call.get("id")): call
for call in mcp_calls
if isinstance(call, dict) and isinstance(call.get("id"), str)
}
existing_ids = _existing_tool_result_ids(internal_messages)
existing_ids = _existing_mcp_result_ids(internal_messages)
for call_id in approved_ids | rejected_ids:
if call_id in existing_ids:
continue
tool_call = call_map.get(call_id)
if not tool_call:
mcp_call = call_map.get(call_id)
if not mcp_call:
continue
fn = tool_call.get("function") or {}
name = fn.get("name") if isinstance(fn, dict) else None
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
args: Dict[str, Any] = {}
if isinstance(args_raw, str) and args_raw.strip():
try:
parsed = json.loads(args_raw)
if isinstance(parsed, dict):
args = parsed
except json.JSONDecodeError:
args = {}
name = str(mcp_call.get("name") or "")
arguments = mcp_call.get("arguments") if isinstance(mcp_call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
spec = get_tool(str(name or ""))
if call_id in rejected_ids:
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("tool_end", {"tool_call_id": call_id, "name": str(name or ""), "message": tool_msg})
continue
if not spec:
elif not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("tool_end", {"tool_call_id": call_id, "name": str(name or ""), "message": tool_msg})
continue
yield _sse("tool_start", {"tool_call_id": call_id, "name": spec.name})
try:
result = await spec.handler(args)
content = tool_result_to_content(result)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
else:
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("tool_end", {"tool_call_id": call_id, "name": spec.name, "message": tool_msg})
tools_schema = openai_tools()
ability = await _choose_chat_ability()
max_loops = 4
for _ in range(max_loops):
assistant_event_id = uuid.uuid4().hex
yield _sse("assistant_start", {"id": assistant_event_id})
assistant_message: Dict[str, Any] | None = None
for _ in range(8):
try:
async for event in chat_completion_stream(
assistant = await chat_completion(
internal_messages,
ability=ability,
tools=tools_schema,
tool_choice="auto",
timeout=60.0,
):
if event.get("type") == "delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
yield _sse("assistant_delta", {"id": assistant_event_id, "delta": delta})
elif event.get("type") == "message":
msg = event.get("message")
if isinstance(msg, dict):
assistant_message = msg
)
except MissingModelError as exc:
raise HTTPException(status_code=400, detail=_format_exc(exc)) from exc
except httpx.HTTPStatusError as exc:
@@ -388,66 +231,196 @@ class AgentService:
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"对话请求异常: {_format_exc(exc)}") from exc
if not assistant_message:
assistant_message = {"role": "assistant", "content": ""}
assistant = _ensure_mcp_call_ids(assistant if isinstance(assistant, dict) else {"role": "assistant", "content": ""})
internal_messages.append(assistant)
new_messages.append(assistant)
assistant_message = _ensure_tool_call_ids(assistant_message)
internal_messages.append(assistant_message)
new_messages.append(assistant_message)
yield _sse("assistant_end", {"id": assistant_event_id, "message": assistant_message})
tool_calls = assistant_message.get("tool_calls")
if not isinstance(tool_calls, list) or not tool_calls:
mcp_calls = assistant.get("mcp_calls")
if not isinstance(mcp_calls, list) or not mcp_calls:
break
pending = []
for call in tool_calls:
for call in mcp_calls:
if not isinstance(call, dict):
continue
call_id = str(call.get("id") or "")
fn = call.get("function") or {}
name = fn.get("name") if isinstance(fn, dict) else None
args_raw = fn.get("arguments") if isinstance(fn, dict) else None
args: Dict[str, Any] = {}
if isinstance(args_raw, str) and args_raw.strip():
try:
parsed = json.loads(args_raw)
if isinstance(parsed, dict):
args = parsed
except json.JSONDecodeError:
args = {}
name = str(call.get("name") or "")
arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
spec = get_tool(str(name or ""))
if not spec:
if not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("tool_end", {"tool_call_id": call_id, "name": str(name or ""), "message": tool_msg})
continue
if spec.requires_confirmation and not req.auto_execute:
if _tool_requires_confirmation(tool_desc) and not req.auto_execute:
pending.append(_extract_pending(call, True))
continue
yield _sse("tool_start", {"tool_call_id": call_id, "name": spec.name})
try:
result = await spec.handler(args)
content = tool_result_to_content(result)
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "tool_call_id": call_id, "content": content}
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("tool_end", {"tool_call_id": call_id, "name": spec.name, "message": tool_msg})
if pending:
yield _sse("pending", {"pending_tool_calls": [p.model_dump() for p in pending]})
break
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
return payload
@classmethod
async def chat_stream(cls, req: AgentChatRequest, user: Optional[User]):
history: List[Dict[str, Any]] = list(req.messages or [])
current_path = _normalize_path(req.context.current_path if req.context else None)
system_prompt = _build_system_prompt(current_path)
internal_messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + history
new_messages: List[Dict[str, Any]] = []
pending: List[PendingMcpCall] = []
approved_ids = {i for i in (req.approved_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
rejected_ids = {i for i in (req.rejected_mcp_call_ids or []) if isinstance(i, str) and i.strip()}
try:
async with mcp_client_session(user, current_path) as mcp_session:
tools_schema = await _list_mcp_tools(mcp_session)
tool_index = {tool["name"]: tool for tool in tools_schema if tool.get("name")}
if approved_ids or rejected_ids:
_, last_call_msg = _find_last_assistant_mcp_calls(internal_messages)
last_call_msg = _ensure_mcp_call_ids(last_call_msg)
mcp_calls = last_call_msg.get("mcp_calls") or []
call_map: Dict[str, Dict[str, Any]] = {
str(call.get("id")): call
for call in mcp_calls
if isinstance(call, dict) and isinstance(call.get("id"), str)
}
existing_ids = _existing_mcp_result_ids(internal_messages)
for call_id in approved_ids | rejected_ids:
if call_id in existing_ids:
continue
mcp_call = call_map.get(call_id)
if not mcp_call:
continue
name = str(mcp_call.get("name") or "")
arguments = mcp_call.get("arguments") if isinstance(mcp_call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
if call_id in rejected_ids:
content = tool_result_to_content({"canceled": True, "reason": "user_rejected"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
continue
if not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
continue
yield _sse("mcp_call_start", {"mcp_call_id": call_id, "name": name})
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
ability = await _choose_chat_ability()
for _ in range(8):
assistant_event_id = str(uuid.uuid4())
yield _sse("assistant_start", {"id": assistant_event_id})
assistant_message: Dict[str, Any] | None = None
try:
async for event in chat_completion_stream(
internal_messages,
ability=ability,
tools=tools_schema,
tool_choice="auto",
timeout=60.0,
):
event_type = event.get("type")
if event_type == "delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
yield _sse("assistant_delta", {"id": assistant_event_id, "delta": delta})
elif event_type == "message":
msg = event.get("message")
if isinstance(msg, dict):
assistant_message = msg
except MissingModelError as exc:
raise HTTPException(status_code=400, detail=_format_exc(exc)) from exc
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f"对话请求失败: {_format_exc(exc)}") from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"对话请求异常: {_format_exc(exc)}") from exc
if not assistant_message:
assistant_message = {"role": "assistant", "content": ""}
assistant_message = _ensure_mcp_call_ids(assistant_message)
internal_messages.append(assistant_message)
new_messages.append(assistant_message)
yield _sse("assistant_end", {"id": assistant_event_id, "message": assistant_message})
mcp_calls = assistant_message.get("mcp_calls")
if not isinstance(mcp_calls, list) or not mcp_calls:
break
pending = []
for call in mcp_calls:
if not isinstance(call, dict):
continue
call_id = str(call.get("id") or "")
name = str(call.get("name") or "")
arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
tool_desc = tool_index.get(name)
if not tool_desc:
content = tool_result_to_content({"error": f"unknown_tool: {name}"})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
continue
if _tool_requires_confirmation(tool_desc) and not req.auto_execute:
pending.append(_extract_pending(call, True))
continue
yield _sse("mcp_call_start", {"mcp_call_id": call_id, "name": name})
try:
content = await _execute_mcp_call(mcp_session, name, arguments)
except Exception as exc: # noqa: BLE001
content = tool_result_to_content({"error": str(exc)})
tool_msg = {"role": "tool", "mcp_call_id": call_id, "content": content}
internal_messages.append(tool_msg)
new_messages.append(tool_msg)
yield _sse("mcp_call_end", {"mcp_call_id": call_id, "name": name, "message": tool_msg})
if pending:
yield _sse("pending", {"pending_mcp_calls": [item.model_dump() for item in pending]})
break
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
yield _sse("done", payload)
except asyncio.CancelledError:
@@ -460,13 +433,11 @@ class AgentService:
new_messages.append({"role": "assistant", "content": content})
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
yield _sse("done", payload)
return
except Exception as exc: # noqa: BLE001
new_messages.append({"role": "assistant", "content": f"服务端异常: {_format_exc(exc)}"})
payload: Dict[str, Any] = {"messages": new_messages}
if pending:
payload["pending_tool_calls"] = [p.model_dump() for p in pending]
payload["pending_mcp_calls"] = [item.model_dump() for item in pending]
yield _sse("done", payload)
return

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict, List, Optional
from .base import ToolSpec, tool_result_to_content
from .base import McpToolDescriptor, ToolSpec, tool_result_to_content, tool_spec_to_mcp_descriptor
from .processors import TOOLS as PROCESSOR_TOOLS
from .time import TOOLS as TIME_TOOLS
from .vfs import TOOLS as VFS_TOOLS
@@ -15,23 +15,19 @@ def get_tool(name: str) -> Optional[ToolSpec]:
return TOOLS.get(name)
def openai_tools() -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for spec in TOOLS.values():
out.append({
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
},
})
return out
def list_tool_specs() -> List[ToolSpec]:
return list(TOOLS.values())
def mcp_tool_descriptors() -> List[McpToolDescriptor]:
return [tool_spec_to_mcp_descriptor(spec) for spec in TOOLS.values()]
__all__ = [
"McpToolDescriptor",
"ToolSpec",
"get_tool",
"openai_tools",
"list_tool_specs",
"mcp_tool_descriptors",
"tool_result_to_content",
]

View File

@@ -3,6 +3,16 @@ from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Optional
@dataclass(frozen=True)
class McpToolDescriptor:
name: str
description: str
input_schema: Dict[str, Any]
annotations: Dict[str, Any]
meta: Dict[str, Any]
requires_confirmation: bool
@dataclass(frozen=True)
class ToolSpec:
name: str
@@ -141,9 +151,31 @@ def _normalize_tool_result(result: Any) -> Dict[str, Any]:
return {"ok": True, "summary": summary, "view": view, "data": result}
def normalize_tool_result(result: Any) -> Dict[str, Any]:
return _normalize_tool_result(result)
def tool_result_to_content(result: Any) -> str:
payload = _normalize_tool_result(result)
payload = normalize_tool_result(result)
try:
return json.dumps(payload, ensure_ascii=False, default=str)
except TypeError:
return json.dumps({"ok": False, "summary": "error", "view": {"type": "error", "message": "error"}}, ensure_ascii=False)
def tool_spec_to_mcp_descriptor(spec: ToolSpec) -> McpToolDescriptor:
read_only = not spec.requires_confirmation
annotations: Dict[str, Any] = {
"readOnlyHint": read_only,
"destructiveHint": bool(spec.requires_confirmation),
}
if spec.name == "web_fetch":
annotations["openWorldHint"] = True
return McpToolDescriptor(
name=spec.name,
description=spec.description,
input_schema=spec.parameters,
annotations=annotations,
meta={"requires_confirmation": spec.requires_confirmation},
requires_confirmation=spec.requires_confirmation,
)

View File

@@ -10,14 +10,19 @@ class AgentChatContext(BaseModel):
class AgentChatRequest(BaseModel):
messages: List[Dict[str, Any]] = Field(default_factory=list)
auto_execute: bool = False
approved_tool_call_ids: List[str] = Field(default_factory=list)
rejected_tool_call_ids: List[str] = Field(default_factory=list)
approved_mcp_call_ids: List[str] = Field(default_factory=list)
rejected_mcp_call_ids: List[str] = Field(default_factory=list)
context: Optional[AgentChatContext] = None
class PendingToolCall(BaseModel):
class McpCall(BaseModel):
id: str
name: str
arguments: Dict[str, Any] = Field(default_factory=dict)
class PendingMcpCall(BaseModel):
id: str
name: str
arguments: Dict[str, Any] = Field(default_factory=dict)
requires_confirmation: bool = True

View File

@@ -267,19 +267,24 @@ async def get_vector_db_config(request: Request, user: User = Depends(get_curren
async def update_vector_db_config(
request: Request, payload: VectorDBConfigPayload, user: User = Depends(get_current_active_user)
):
entry = get_provider_entry(payload.type)
provider_type = str(payload.type or "").strip()
if not provider_type:
raise HTTPException(status_code=400, detail="向量数据库类型不能为空")
normalized_config = VectorDBConfigManager.normalize_config(payload.config)
entry = get_provider_entry(provider_type)
if not entry:
raise HTTPException(
status_code=400, detail=f"未知的向量数据库类型: {payload.type}")
status_code=400, detail=f"未知的向量数据库类型: {provider_type}")
if not entry.get("enabled", True):
raise HTTPException(status_code=400, detail="该向量数据库类型暂不可用")
provider_cls = get_provider_class(payload.type)
provider_cls = get_provider_class(provider_type)
if not provider_cls:
raise HTTPException(
status_code=400, detail=f"未找到类型 {payload.type} 对应的实现")
status_code=400, detail=f"未找到类型 {provider_type} 对应的实现")
test_provider = provider_cls(payload.config)
test_provider = provider_cls(normalized_config)
try:
await test_provider.initialize()
except Exception as exc:
@@ -293,7 +298,7 @@ async def update_vector_db_config(
except Exception:
pass
await VectorDBConfigManager.save_config(payload.type, payload.config)
await VectorDBConfigManager.save_config(provider_type, normalized_config)
service = VectorDBService()
await service.reload()
config_data = await service.current_provider()

View File

@@ -15,6 +15,102 @@ class MissingModelError(RuntimeError):
pass
def _mcp_tools_to_openai_wire(tools: List[Dict[str, Any]] | None) -> List[Dict[str, Any]] | None:
if not tools:
return None
out: List[Dict[str, Any]] = []
for tool in tools:
if not isinstance(tool, dict):
continue
name = tool.get("name")
if not isinstance(name, str) or not name.strip():
continue
out.append(
{
"type": "function",
"function": {
"name": name,
"description": str(tool.get("description") or ""),
"parameters": tool.get("input_schema") if isinstance(tool.get("input_schema"), dict) else {},
},
}
)
return out
def _mcp_messages_to_openai_wire(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for message in messages:
if not isinstance(message, dict):
continue
item = dict(message)
mcp_call_id = item.pop("mcp_call_id", None)
if isinstance(mcp_call_id, str) and mcp_call_id.strip():
item["tool_call_id"] = mcp_call_id
mcp_calls = item.pop("mcp_calls", None)
if isinstance(mcp_calls, list):
tool_calls: List[Dict[str, Any]] = []
for idx, call in enumerate(mcp_calls):
if not isinstance(call, dict):
continue
name = call.get("name")
if not isinstance(name, str) or not name.strip():
continue
arguments = call.get("arguments") if isinstance(call.get("arguments"), dict) else {}
tool_calls.append(
{
"id": str(call.get("id") or f"call_{idx}"),
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(arguments, ensure_ascii=False),
},
}
)
if tool_calls:
item["tool_calls"] = tool_calls
out.append(item)
return out
def _openai_wire_message_to_mcp(message: Dict[str, Any]) -> Dict[str, Any]:
out = dict(message)
tool_call_id = out.pop("tool_call_id", None)
if isinstance(tool_call_id, str) and tool_call_id.strip():
out["mcp_call_id"] = tool_call_id
tool_calls = out.pop("tool_calls", None)
if isinstance(tool_calls, list):
mcp_calls: List[Dict[str, Any]] = []
for idx, call in enumerate(tool_calls):
if not isinstance(call, dict):
continue
fn = call.get("function") if isinstance(call.get("function"), dict) else {}
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
continue
arguments: Dict[str, Any] = {}
raw_args = fn.get("arguments")
if isinstance(raw_args, str) and raw_args.strip():
try:
parsed = json.loads(raw_args)
if isinstance(parsed, dict):
arguments = parsed
except json.JSONDecodeError:
arguments = {}
mcp_calls.append(
{
"id": str(call.get("id") or f"call_{idx}"),
"name": name,
"arguments": arguments,
}
)
if mcp_calls:
out["mcp_calls"] = mcp_calls
return out
async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
"""
传入 base64 图片并返回描述文本。缺省时返回错误提示。
@@ -939,34 +1035,39 @@ async def chat_completion(
) -> Dict[str, Any]:
model, provider = await _require_model(ability)
fmt = str(provider.api_format or "").lower()
wire_messages = _mcp_messages_to_openai_wire(messages)
wire_tools = _mcp_tools_to_openai_wire(tools)
if fmt == "openai":
return await _chat_with_openai(
result = await _chat_with_openai(
provider,
model,
messages,
tools=tools,
wire_messages,
tools=wire_tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
)
return _openai_wire_message_to_mcp(result)
if fmt == "anthropic":
return await _chat_with_anthropic(
result = await _chat_with_anthropic(
provider,
model,
messages,
tools=tools,
wire_messages,
tools=wire_tools,
temperature=temperature,
timeout=timeout,
)
return _openai_wire_message_to_mcp(result)
if fmt == "ollama":
return await _chat_with_ollama(
result = await _chat_with_ollama(
provider,
model,
messages,
tools=tools,
wire_messages,
tools=wire_tools,
temperature=temperature,
timeout=timeout,
)
return _openai_wire_message_to_mcp(result)
raise MissingModelError(f"当前不支持该对话模型接口类型: {provider.api_format}")
@@ -1016,38 +1117,49 @@ async def chat_completion_stream(
) -> AsyncIterator[Dict[str, Any]]:
model, provider = await _require_model(ability)
fmt = str(provider.api_format or "").lower()
wire_messages = _mcp_messages_to_openai_wire(messages)
wire_tools = _mcp_tools_to_openai_wire(tools)
if fmt == "openai":
async for event in _chat_stream_with_openai(
provider,
model,
messages,
tools=tools,
wire_messages,
tools=wire_tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
):
if event.get("type") == "message" and isinstance(event.get("message"), dict):
yield {**event, "message": _openai_wire_message_to_mcp(event["message"])}
continue
yield event
return
if fmt == "anthropic":
async for event in _chat_stream_with_anthropic(
provider,
model,
messages,
tools=tools,
wire_messages,
tools=wire_tools,
temperature=temperature,
timeout=timeout,
):
if event.get("type") == "message" and isinstance(event.get("message"), dict):
yield {**event, "message": _openai_wire_message_to_mcp(event["message"])}
continue
yield event
return
if fmt == "ollama":
async for event in _chat_stream_with_ollama(
provider,
model,
messages,
tools=tools,
wire_messages,
tools=wire_tools,
temperature=temperature,
timeout=timeout,
):
if event.get("type") == "message" and isinstance(event.get("message"), dict):
yield {**event, "message": _openai_wire_message_to_mcp(event["message"])}
continue
yield event
return
raise MissingModelError(f"当前不支持该对话模型接口类型: {provider.api_format}")

View File

@@ -1,7 +1,7 @@
import asyncio
import json
from collections.abc import Iterable
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, TypeVar
import httpx
from tortoise.exceptions import DoesNotExist
@@ -28,16 +28,37 @@ OPENAI_EMBEDDING_DIMS = {
"text-embedding-ada-002": 1536,
}
T = TypeVar("T")
class VectorDBConfigManager:
TYPE_KEY = "VECTOR_DB_TYPE"
CONFIG_KEY = "VECTOR_DB_CONFIG"
DEFAULT_TYPE = "milvus_lite"
@classmethod
def normalize_type(cls, provider_type: Any) -> str:
normalized = str(provider_type or cls.DEFAULT_TYPE).strip()
return normalized or cls.DEFAULT_TYPE
@classmethod
def normalize_config(cls, config: Dict[str, Any] | None) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
for key, value in (config or {}).items():
normalized_key = str(key).strip()
if not normalized_key:
continue
if isinstance(value, str):
value = value.strip()
if not value:
continue
normalized[normalized_key] = value
return normalized
@classmethod
async def load_config(cls) -> Tuple[str, Dict[str, Any]]:
raw_type = await ConfigService.get(cls.TYPE_KEY, cls.DEFAULT_TYPE)
provider_type = str(raw_type or cls.DEFAULT_TYPE)
provider_type = cls.normalize_type(raw_type)
raw_config = await ConfigService.get(cls.CONFIG_KEY)
config_dict: Dict[str, Any] = {}
@@ -48,12 +69,14 @@ class VectorDBConfigManager:
config_dict = {}
elif isinstance(raw_config, dict):
config_dict = raw_config
return provider_type, config_dict
return provider_type, cls.normalize_config(config_dict)
@classmethod
async def save_config(cls, provider_type: str, config: Dict[str, Any]) -> None:
await ConfigService.set(cls.TYPE_KEY, provider_type)
await ConfigService.set(cls.CONFIG_KEY, json.dumps(config or {}))
normalized_type = cls.normalize_type(provider_type)
normalized_config = cls.normalize_config(config)
await ConfigService.set(cls.TYPE_KEY, normalized_type)
await ConfigService.set(cls.CONFIG_KEY, json.dumps(normalized_config))
@classmethod
async def get_type(cls) -> str:
@@ -413,6 +436,7 @@ class VectorDBService:
self._provider_type: Optional[str] = None
self._provider_config: Dict[str, Any] | None = None
self._lock = asyncio.Lock()
self._operation_lock = asyncio.Lock()
async def _ensure_provider(self) -> BaseVectorProvider:
if self._provider is None:
@@ -449,33 +473,38 @@ class VectorDBService:
self._provider_config = normalized_config
return provider
async def _run_provider_call(self, provider: BaseVectorProvider, method_name: str, *args, **kwargs) -> T:
method = getattr(provider, method_name)
async with self._operation_lock:
return await asyncio.to_thread(method, *args, **kwargs)
async def ensure_collection(self, collection_name: str, vector: bool = True, dim: int = DEFAULT_VECTOR_DIMENSION) -> None:
provider = await self._ensure_provider()
provider.ensure_collection(collection_name, vector, dim)
await self._run_provider_call(provider, "ensure_collection", collection_name, vector, dim)
async def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
provider = await self._ensure_provider()
provider.upsert_vector(collection_name, data)
await self._run_provider_call(provider, "upsert_vector", collection_name, data)
async def delete_vector(self, collection_name: str, path: str) -> None:
provider = await self._ensure_provider()
provider.delete_vector(collection_name, path)
await self._run_provider_call(provider, "delete_vector", collection_name, path)
async def search_vectors(self, collection_name: str, query_embedding, top_k: int = 5):
provider = await self._ensure_provider()
return provider.search_vectors(collection_name, query_embedding, top_k)
return await self._run_provider_call(provider, "search_vectors", collection_name, query_embedding, top_k)
async def search_by_path(self, collection_name: str, query_path: str, top_k: int = 20):
provider = await self._ensure_provider()
return provider.search_by_path(collection_name, query_path, top_k)
return await self._run_provider_call(provider, "search_by_path", collection_name, query_path, top_k)
async def get_all_stats(self) -> Dict[str, Any]:
provider = await self._ensure_provider()
return provider.get_all_stats()
return await self._run_provider_call(provider, "get_all_stats")
async def clear_all_data(self) -> None:
provider = await self._ensure_provider()
provider.clear_all_data()
await self._run_provider_call(provider, "clear_all_data")
async def current_provider(self) -> Dict[str, Any]:
provider_type, provider_config = await VectorDBConfigManager.load_config()

View File

@@ -1,3 +1,4 @@
import asyncio
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -23,12 +24,14 @@ class MilvusLiteProvider(BaseVectorProvider):
def __init__(self, config: Dict[str, Any] | None = None):
super().__init__(config)
self.db_path = Path(self.config.get("db_path") or "data/db/milvus.db")
raw_db_path = self.config.get("db_path")
db_path = str(raw_db_path).strip() if raw_db_path is not None else ""
self.db_path = Path(db_path or "data/db/milvus.db")
self.client: MilvusClient | None = None
async def initialize(self) -> None:
try:
self.client = MilvusClient(str(self.db_path))
self.client = await asyncio.to_thread(MilvusClient, str(self.db_path))
except Exception as exc: # pragma: no cover - depends on local environment
raise RuntimeError(f"Failed to open Milvus Lite at {self.db_path}: {exc}") from exc

View File

@@ -1,3 +1,4 @@
import asyncio
from typing import Any, Dict, List, Optional
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
@@ -32,11 +33,14 @@ class MilvusServerProvider(BaseVectorProvider):
self.client: MilvusClient | None = None
async def initialize(self) -> None:
uri = self.config.get("uri")
uri = str(self.config.get("uri") or "").strip()
if not uri:
raise RuntimeError("Milvus Server URI is required")
token = self.config.get("token")
if isinstance(token, str):
token = token.strip() or None
try:
self.client = MilvusClient(uri=uri, token=self.config.get("token"))
self.client = await asyncio.to_thread(MilvusClient, uri=uri, token=token)
except Exception as exc: # pragma: no cover - depends on remote availability
raise RuntimeError(f"Failed to connect to Milvus Server {uri}: {exc}") from exc

View File

@@ -1,3 +1,4 @@
import asyncio
from typing import Any, Dict, List, Optional, Sequence
from uuid import NAMESPACE_URL, uuid5
@@ -40,7 +41,7 @@ class QdrantProvider(BaseVectorProvider):
api_key = (self.config.get("api_key") or None) or None
try:
client = QdrantClient(url=url, api_key=api_key)
client.get_collections()
await asyncio.to_thread(client.get_collections)
self.client = client
except Exception as exc: # pragma: no cover - 依赖外部服务
raise RuntimeError(f"Failed to connect to Qdrant at {url}: {exc}") from exc

View File

@@ -13,6 +13,7 @@ from .types import ConfigItem
router = APIRouter(prefix="/api/config", tags=["config"])
PUBLIC_CONFIG_KEYS = [
"APP_DEFAULT_LANGUAGE",
"THEME_MODE",
"THEME_PRIMARY_COLOR",
"THEME_BORDER_RADIUS",
@@ -56,6 +57,7 @@ async def get_all_config(
configs = await ConfigService.get_all()
return success(configs)
@router.get("/public")
@audit(action=AuditAction.READ, description="获取公开配置")
async def get_public_config(

View File

@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
load_dotenv(dotenv_path=".env")
VERSION = "v2.0.0"
VERSION = "v2.2.0"
class ConfigService:
@@ -80,6 +80,7 @@ class ConfigService:
logo=logo,
favicon=favicon,
is_initialized=user_count > 0,
default_language=await cls.get("APP_DEFAULT_LANGUAGE", "zh"),
app_domain=await cls.get("APP_DOMAIN"),
file_domain=await cls.get("FILE_DOMAIN"),
)

View File

@@ -14,6 +14,7 @@ class SystemStatus(BaseModel):
logo: str
favicon: str
is_initialized: bool
default_language: str = "zh"
app_domain: Optional[str] = None
file_domain: Optional[str] = None

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import List, Optional
from fastapi import HTTPException
@@ -17,74 +18,169 @@ from .types import (
PERMISSION_DEFINITIONS,
)
@dataclass(slots=True)
class PermissionContext:
exists: bool
is_admin: bool
path_rules: List[PathRule]
class PermissionService:
"""权限检查服务"""
# 权限检查结果缓存(简单的内存缓存)
_cache: dict[str, tuple[bool, float]] = {}
_context_cache: dict[int, tuple[PermissionContext, float]] = {}
_cache_ttl = 300 # 5分钟缓存
@classmethod
def _now(cls) -> float:
import time
return time.time()
@classmethod
def _is_cache_valid(cls, timestamp: float) -> bool:
return cls._now() - timestamp < cls._cache_ttl
@classmethod
def _get_cached_result(cls, cache_key: str) -> Optional[bool]:
cached = cls._cache.get(cache_key)
if not cached:
return None
result, timestamp = cached
if cls._is_cache_valid(timestamp):
return result
cls._cache.pop(cache_key, None)
return None
@classmethod
def _sort_path_rules(cls, rules: List[PathRule]) -> List[PathRule]:
return sorted(
rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
)
@classmethod
def _match_sorted_path_rules(
cls, path: str, action: str, sorted_rules: List[PathRule]
) -> Optional[bool]:
for rule in sorted_rules:
if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex):
if action == PathAction.READ:
return rule.can_read
if action == PathAction.WRITE:
return rule.can_write
if action == PathAction.DELETE:
return rule.can_delete
if action == PathAction.SHARE:
return rule.can_share
return False
return None
@classmethod
async def _get_permission_context(cls, user_id: int) -> PermissionContext:
cached = cls._context_cache.get(user_id)
if cached:
context, timestamp = cached
if cls._is_cache_valid(timestamp):
return context
cls._context_cache.pop(user_id, None)
user = await UserAccount.get_or_none(id=user_id)
if not user:
context = PermissionContext(exists=False, is_admin=False, path_rules=[])
cls._context_cache[user_id] = (context, cls._now())
return context
if user.is_admin:
context = PermissionContext(exists=True, is_admin=True, path_rules=[])
cls._context_cache[user_id] = (context, cls._now())
return context
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
context = PermissionContext(exists=True, is_admin=False, path_rules=[])
cls._context_cache[user_id] = (context, cls._now())
return context
path_rules = await PathRule.filter(role_id__in=role_ids)
context = PermissionContext(
exists=True,
is_admin=False,
path_rules=cls._sort_path_rules(list(path_rules)),
)
cls._context_cache[user_id] = (context, cls._now())
return context
@classmethod
def _check_path_permission_with_context(
cls,
user_id: int,
normalized_path: str,
action: str,
context: PermissionContext,
) -> bool:
if not context.exists:
return False
if context.is_admin:
return True
checked_cache_keys: List[str] = []
current_path = normalized_path
while True:
cache_key = f"{user_id}:{current_path}:{action}"
cached_result = cls._get_cached_result(cache_key)
if cached_result is not None:
result = cached_result
break
checked_cache_keys.append(cache_key)
result = cls._match_sorted_path_rules(current_path, action, context.path_rules)
if result is not None:
break
parent_path = PathMatcher.get_parent_path(current_path)
if not parent_path:
result = False
break
current_path = parent_path
timestamp = cls._now()
for cache_key in checked_cache_keys:
cls._cache[cache_key] = (result, timestamp)
return result
@classmethod
async def check_path_permission(
cls, user_id: int, path: str, action: str
) -> bool:
"""
检查用户对路径的操作权限
Args:
user_id: 用户ID
path: 要检查的路径
action: 操作类型 (read/write/delete/share)
Returns:
是否有权限
"""
import time
# 检查缓存
cache_key = f"{user_id}:{path}:{action}"
if cache_key in cls._cache:
result, timestamp = cls._cache[cache_key]
if time.time() - timestamp < cls._cache_ttl:
return result
# 获取用户
user = await UserAccount.get_or_none(id=user_id)
if not user:
return False
# 超级管理员直接放行
if user.is_admin:
cls._cache[cache_key] = (True, time.time())
return True
# 获取用户所有角色
user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role")
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
cls._cache[cache_key] = (False, time.time())
return False
# 获取所有角色的路径规则
path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority")
# 规范化路径
normalized_path = PathMatcher.normalize_path(path)
cache_key = f"{user_id}:{normalized_path}:{action}"
cached_result = cls._get_cached_result(cache_key)
if cached_result is not None:
return cached_result
# 按优先级和具体程度匹配
result = cls._match_path_rules(normalized_path, action, list(path_rules))
# 如果没有匹配到规则,检查父目录(继承)
if result is None:
parent_path = PathMatcher.get_parent_path(normalized_path)
if parent_path:
result = await cls.check_path_permission(user_id, parent_path, action)
else:
result = False # 默认拒绝
cls._cache[cache_key] = (result, time.time())
context = await cls._get_permission_context(user_id)
result = cls._check_path_permission_with_context(user_id, normalized_path, action, context)
cls._cache[cache_key] = (result, cls._now())
return result
@classmethod
@@ -97,31 +193,7 @@ class PermissionService:
Returns:
True/False 表示明确的权限结果None 表示没有匹配到规则
"""
# 按优先级和具体程度排序
sorted_rules = sorted(
rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
)
for rule in sorted_rules:
if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex):
# 匹配到规则,检查具体操作权限
if action == PathAction.READ:
return rule.can_read
elif action == PathAction.WRITE:
return rule.can_write
elif action == PathAction.DELETE:
return rule.can_delete
elif action == PathAction.SHARE:
return rule.can_share
else:
return False
return None
return cls._match_sorted_path_rules(path, action, cls._sort_path_rules(rules))
@classmethod
async def check_system_permission(cls, user_id: int, permission_code: str) -> bool:
@@ -251,35 +323,20 @@ class PermissionService:
cls, user_id: int, path: str, action: str
) -> PathPermissionResult:
"""检查路径权限并返回详细结果"""
user = await UserAccount.get_or_none(id=user_id)
if not user:
context = await cls._get_permission_context(user_id)
if not context.exists:
return PathPermissionResult(path=path, action=action, allowed=False)
# 超级管理员
if user.is_admin:
if context.is_admin:
return PathPermissionResult(path=path, action=action, allowed=True)
# 获取用户角色
user_roles = await UserRole.filter(user_id=user_id)
role_ids = [ur.role_id for ur in user_roles]
if not role_ids:
if not context.path_rules:
return PathPermissionResult(path=path, action=action, allowed=False)
# 获取路径规则
path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority")
normalized_path = PathMatcher.normalize_path(path)
# 查找匹配的规则
matched_rule = None
for rule in sorted(
path_rules,
key=lambda r: (
r.priority,
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
),
reverse=True,
):
for rule in context.path_rules:
if PathMatcher.match_pattern(
normalized_path, rule.path_pattern, rule.is_regex
):
@@ -322,19 +379,30 @@ class PermissionService:
"""清除权限缓存"""
if user_id is None:
cls._cache.clear()
cls._context_cache.clear()
else:
# 清除特定用户的缓存
keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")]
for k in keys_to_delete:
del cls._cache[k]
cls._context_cache.pop(user_id, None)
@classmethod
async def filter_paths_by_permission(
cls, user_id: int, paths: List[str], action: str
) -> List[str]:
"""过滤出用户有权限的路径列表"""
if not paths:
return []
context = await cls._get_permission_context(user_id)
if not context.exists:
return []
if context.is_admin:
return list(paths)
result = []
for path in paths:
if await cls.check_path_permission(user_id, path, action):
normalized_path = PathMatcher.normalize_path(path)
if cls._check_path_permission_with_context(user_id, normalized_path, action, context):
result.append(path)
return result

View File

@@ -84,8 +84,9 @@ async def get_file_stat(
full_path: str,
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
verbose: bool = Query(False, description="是否返回扩展元数据"),
):
stat = await VirtualFSService.stat(full_path)
stat = await VirtualFSService.stat(full_path, verbose=verbose)
return success(stat)

View File

@@ -1,3 +1,4 @@
import inspect
from typing import Any, Dict, List, Tuple
from fastapi import HTTPException
@@ -14,6 +15,23 @@ from .resolver import VirtualFSResolverMixin
class VirtualFSListingMixin(VirtualFSResolverMixin):
@staticmethod
async def _call_stat_file(
stat_func,
root: str,
rel: str,
*,
include_metadata: bool = False,
):
try:
parameters = inspect.signature(stat_func).parameters
except (TypeError, ValueError):
parameters = {}
if "include_metadata" in parameters:
return await stat_func(root, rel, include_metadata=include_metadata)
return await stat_func(root, rel)
@classmethod
async def path_is_directory(cls, path: str) -> bool:
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
@@ -24,7 +42,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file")
try:
info = await stat_func(root, rel)
info = await cls._call_stat_file(stat_func, root, rel, include_metadata=False)
except FileNotFoundError:
raise HTTPException(404, detail="Path not found")
if isinstance(info, dict):
@@ -110,7 +128,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
stat_file = getattr(adapter_instance, "stat_file", None)
if callable(stat_file):
try:
parent_info = await stat_file(effective_root, rel)
parent_info = await cls._call_stat_file(
stat_file,
effective_root,
rel,
include_metadata=False,
)
if isinstance(parent_info, dict):
parent_info.setdefault("name", rel.split("/")[-1])
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
@@ -121,7 +144,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
stat_file = getattr(adapter_instance, "stat_file", None)
if callable(stat_file):
try:
parent_info = await stat_file(effective_root, parent_rel)
parent_info = await cls._call_stat_file(
stat_file,
effective_root,
parent_rel,
include_metadata=False,
)
if isinstance(parent_info, dict):
parent_info.setdefault("name", parent_rel.split("/")[-1])
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
@@ -222,13 +250,18 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
}
@classmethod
async def stat_file(cls, path: str):
async def stat_file(cls, path: str, verbose: bool = False):
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
stat_func = getattr(adapter_instance, "stat_file", None)
if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file")
try:
info = await stat_func(root, rel)
info = await cls._call_stat_file(
stat_func,
root,
rel,
include_metadata=verbose,
)
except FileNotFoundError as exc:
raise HTTPException(404, detail=str(exc))
@@ -241,7 +274,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
rel_name = rel.rstrip("/").split("/")[-1] if rel else path.rstrip("/").split("/")[-1]
name_hint = str(info.get("name") or rel_name or "")
info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
if not is_dir:
if verbose and not is_dir:
vector_index = await cls._gather_vector_index(path)
if vector_index is not None:
info["vector_index"] = vector_index
@@ -263,38 +296,26 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
过滤掉用户没有读取权限的条目
"""
# 首先获取完整的目录列表
result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order)
# 检查用户是否是管理员(管理员可以看到所有内容)
from models.database import UserAccount
user = await UserAccount.get_or_none(id=user_id)
if user and user.is_admin:
return result
# 过滤无权限的条目
items = result.get("items", [])
if not items:
return result
norm = cls._normalize_path(path).rstrip("/") or "/"
filtered_items = []
path_pairs: List[Tuple[str, Dict]] = []
for item in items:
item_name = item.get("name", "")
if norm == "/":
item_path = f"/{item_name}"
else:
item_path = f"{norm}/{item_name}"
# 检查用户是否有读取权限
has_permission = await PermissionService.check_path_permission(
user_id, item_path, PathAction.READ
)
if has_permission:
filtered_items.append(item)
# 更新结果
result["items"] = filtered_items
path_pairs.append((item_path, item))
allowed_paths = await PermissionService.filter_paths_by_permission(
user_id,
[item_path for item_path, _ in path_pairs],
PathAction.READ,
)
allowed_set = set(allowed_paths)
result["items"] = [item for item_path, item in path_pairs if item_path in allowed_set]
return result

View File

@@ -144,9 +144,9 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
return response
@classmethod
async def stat(cls, full_path: str):
async def stat(cls, full_path: str, verbose: bool = False):
full_path = cls._normalize_path(full_path)
return await cls.stat_file(full_path)
return await cls.stat_file(full_path, verbose=verbose)
@classmethod
async def write_uploaded_file(cls, full_path: str, data: bytes):

View File

@@ -28,12 +28,12 @@ async def search_files(
data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
items = data.get("items") if isinstance(data, dict) else None
if isinstance(items, list) and items:
filtered = []
for item in items:
path = getattr(item, "path", None)
if not path:
continue
if await PermissionService.check_path_permission(user.id, str(path), PathAction.READ):
filtered.append(item)
data["items"] = filtered
path_pairs = [(str(item.path), item) for item in items if getattr(item, "path", None)]
allowed_paths = await PermissionService.filter_paths_by_permission(
user.id,
[path for path, _ in path_pairs],
PathAction.READ,
)
allowed_set = set(allowed_paths)
data["items"] = [item for path, item in path_pairs if path in allowed_set]
return success(data)

15
main.py
View File

@@ -3,6 +3,7 @@ from pathlib import Path
from contextlib import asynccontextmanager
from domain.adapters import runtime_registry
from domain.agent.mcp import MCP_HTTP_APP
from domain.config import ConfigService, VERSION
from db.session import close_db, init_db
from api.routers import include_routers
@@ -80,12 +81,13 @@ async def lifespan(app: FastAPI):
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
try:
yield
finally:
await task_scheduler.stop()
await task_queue_service.stop_worker()
await close_db()
async with MCP_HTTP_APP.router.lifespan_context(MCP_HTTP_APP):
try:
yield
finally:
await task_scheduler.stop()
await task_queue_service.stop_worker()
await close_db()
def create_app() -> FastAPI:
@@ -95,6 +97,7 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
include_routers(app)
app.mount("/api/mcp", MCP_HTTP_APP, name="mcp")
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(httpx.HTTPStatusError, httpx_exception_handler)

View File

@@ -9,6 +9,7 @@ dependencies = [
"bcrypt>=5.0.0",
"croniter>=6.0.0",
"fastapi>=0.127.0",
"mcp>=1.26.0",
"paramiko>=4.0.0",
"pillow>=12.0.0",
"pydantic[email]>=2.12.5",
@@ -18,6 +19,7 @@ dependencies = [
"python-dotenv>=1.2.1",
"python-multipart>=0.0.21",
"qdrant-client>=1.16.2",
"setuptools<82",
"telethon>=1.42.0",
"tortoise-orm>=1.0.0",
"uvicorn>=0.40.0",

373
uv.lock generated
View File

@@ -63,7 +63,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -74,42 +74,42 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
@@ -347,55 +347,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.4"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" },
{ url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" },
{ url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" },
{ url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" },
{ url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" },
{ url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" },
{ url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" },
{ url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" },
{ url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" },
{ url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" },
{ url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" },
{ url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" },
{ url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" },
{ url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" },
{ url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" },
{ url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" },
{ url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" },
{ url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" },
{ url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" },
{ url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" },
{ url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" },
{ url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" },
{ url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" },
{ url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" },
{ url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" },
{ url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" },
{ url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" },
{ url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" },
{ url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" },
{ url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" },
{ url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
@@ -445,6 +445,7 @@ dependencies = [
{ name = "bcrypt" },
{ name = "croniter" },
{ name = "fastapi" },
{ name = "mcp" },
{ name = "paramiko" },
{ name = "pillow" },
{ name = "pydantic", extra = ["email"] },
@@ -454,6 +455,7 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "qdrant-client" },
{ name = "setuptools" },
{ name = "telethon" },
{ name = "tortoise-orm" },
{ name = "uvicorn" },
@@ -465,6 +467,7 @@ requires-dist = [
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "fastapi", specifier = ">=0.127.0" },
{ name = "mcp", specifier = ">=1.26.0" },
{ name = "paramiko", specifier = ">=4.0.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
@@ -474,6 +477,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-multipart", specifier = ">=0.0.21" },
{ name = "qdrant-client", specifier = ">=1.16.2" },
{ name = "setuptools", specifier = "<82" },
{ name = "telethon", specifier = ">=1.42.0" },
{ name = "tortoise-orm", specifier = ">=1.0.0" },
{ name = "uvicorn", specifier = ">=0.40.0" },
@@ -605,6 +609,15 @@ http2 = [
{ name = "h2" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -650,6 +663,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "mcp"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
name = "milvus-lite"
version = "2.5.1"
@@ -807,35 +872,35 @@ wheels = [
[[package]]
name = "pillow"
version = "12.1.0"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
@@ -912,11 +977,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c9066137
[[package]]
name = "pyasn1"
version = "0.6.2"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
@@ -987,6 +1052,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pyjwt"
version = "2.11.0"
@@ -996,6 +1075,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pymilvus"
version = "2.6.8"
@@ -1139,6 +1223,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186, upload-time = "2025-12-12T10:58:29.282Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
@@ -1165,11 +1299,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "82.0.0"
version = "81.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
]
[[package]]
@@ -1181,6 +1315,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sse-starlette"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"

View File

@@ -10,38 +10,7 @@ import { Routes, Route, Navigate } from 'react-router';
import SetupPage from './pages/SetupPage.tsx';
import { I18nProvider } from './i18n';
function AppInner() {
const [status, setStatus] = useState<SystemStatus | null>(null);
useEffect(() => {
async function checkInitialization() {
try {
const status = await getStatus();
setStatus(status);
document.title = status.title;
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
if (!favicon) {
favicon = document.createElement('link');
favicon.rel = 'icon';
document.head.appendChild(favicon);
}
if (favicon) {
favicon.href = status.favicon || status.logo;
}
} catch (error) {
console.error("Failed to check initialization status:", error);
}
}
checkInitialization();
}, []);
if (status === null) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
function AppInner({ status }: { status: SystemStatus }) {
return (
<SystemContext.Provider value={status}>
<AuthProvider>
@@ -61,9 +30,41 @@ function AppInner() {
}
export default function App() {
const [status, setStatus] = useState<SystemStatus | null>(null);
useEffect(() => {
async function checkInitialization() {
try {
const nextStatus = await getStatus();
setStatus(nextStatus);
document.title = nextStatus.title;
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
if (!favicon) {
favicon = document.createElement('link');
favicon.rel = 'icon';
document.head.appendChild(favicon);
}
if (favicon) {
favicon.href = nextStatus.favicon || nextStatus.logo;
}
} catch (error) {
console.error("Failed to check initialization status:", error);
}
}
checkInitialization();
}, []);
if (status === null) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
return (
<I18nProvider>
<AppInner />
<I18nProvider defaultLanguage={status.default_language}>
<AppInner status={status} />
</I18nProvider>
);
}

View File

@@ -13,10 +13,11 @@ export interface AdapterItem {
export interface AdapterTypeField {
key: string;
label: string;
type: 'string' | 'password' | 'number' | 'boolean';
type: 'string' | 'password' | 'number' | 'boolean' | 'select';
required?: boolean;
placeholder?: string;
default?: any;
options?: string[];
}
export interface AdapterTypeMeta {

View File

@@ -9,12 +9,18 @@ export interface AgentChatContext {
export interface AgentChatRequest {
messages: AgentChatMessage[];
auto_execute?: boolean;
approved_tool_call_ids?: string[];
rejected_tool_call_ids?: string[];
approved_mcp_call_ids?: string[];
rejected_mcp_call_ids?: string[];
context?: AgentChatContext;
}
export interface PendingToolCall {
export interface McpCall {
id: string;
name: string;
arguments: Record<string, any>;
}
export interface PendingMcpCall {
id: string;
name: string;
arguments: Record<string, any>;
@@ -23,16 +29,16 @@ export interface PendingToolCall {
export interface AgentChatResponse {
messages: AgentChatMessage[];
pending_tool_calls?: PendingToolCall[];
pending_mcp_calls?: PendingMcpCall[];
}
export type AgentSseEvent =
| { event: 'assistant_start'; data: { id: string } }
| { event: 'assistant_delta'; data: { id: string; delta: string } }
| { event: 'assistant_end'; data: { id: string; message: AgentChatMessage } }
| { event: 'tool_start'; data: { tool_call_id: string; name: string } }
| { event: 'tool_end'; data: { tool_call_id: string; name: string; message: AgentChatMessage } }
| { event: 'pending'; data: { pending_tool_calls: PendingToolCall[] } }
| { event: 'mcp_call_start'; data: { mcp_call_id: string; name: string } }
| { event: 'mcp_call_end'; data: { mcp_call_id: string; name: string; message: AgentChatMessage } }
| { event: 'pending'; data: { pending_mcp_calls: PendingMcpCall[] } }
| { event: 'done'; data: AgentChatResponse };
export const agentApi = {

View File

@@ -1,4 +1,5 @@
import request from './client';
import type { Lang } from '../i18n/lang';
export async function getConfig(key: string) {
return request<{ key: string; value: string }>('/config/?key=' + encodeURIComponent(key));
@@ -25,6 +26,7 @@ export interface SystemStatus {
logo: string;
favicon: string;
is_initialized: boolean;
default_language?: Lang;
app_domain?: string;
file_domain?: string;
}

View File

@@ -86,7 +86,12 @@ export const vfsApi = {
thumb: (path: string, w=256, h=256, fit='cover') =>
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
stat: (path: string, options?: { verbose?: boolean }) => {
const params = new URLSearchParams();
if (options?.verbose) params.set('verbose', 'true');
const query = params.toString();
return request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}${query ? `?${query}` : ''}`);
},
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,

View File

@@ -3,6 +3,7 @@ import { Space, Button } from 'antd';
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types';
import type { VfsEntry } from '../api/client';
import useResponsive from '../hooks/useResponsive';
export interface AppWindowItem {
id: string;
@@ -29,6 +30,7 @@ interface AppWindowsLayerProps {
}
export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => {
const { isMobile } = useResponsive();
const dragRef = useRef<{
id: string;
startX: number;
@@ -124,6 +126,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
}, [onMouseMove, onMouseUp]);
const startDrag = (e: React.MouseEvent, w: AppWindowItem) => {
if (isMobile) return;
if (e.detail === 2) return;
if (w.maximized) return;
if ((e.target as HTMLElement).closest('button')) return;
@@ -141,6 +144,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
const startResize = (e: React.MouseEvent, w: AppWindowItem, dir: string) => {
e.stopPropagation();
if (isMobile) return;
if (w.maximized) return;
onBringToFront(w.id);
resizeRef.current = {
@@ -202,6 +206,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC<any> | undefined;
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
const effectiveMaximized = isMobile || w.maximized;
if (!ContentComp) {
return null;
@@ -215,10 +220,10 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
onMouseDown={() => onBringToFront(w.id)}
style={{
position: 'fixed',
top: w.maximized ? 0 : w.y,
left: w.maximized ? 0 : w.x,
width: w.maximized ? '100vw' : w.width,
height: w.maximized ? '100vh' : w.height,
top: effectiveMaximized ? 0 : w.y,
left: effectiveMaximized ? 0 : w.x,
width: effectiveMaximized ? '100vw' : w.width,
height: effectiveMaximized ? '100dvh' : w.height,
background: 'transparent',
border: 'none',
borderRadius: 0,
@@ -259,14 +264,14 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
onMouseDown={() => onBringToFront(w.id)}
style={{
position: 'fixed',
top: w.maximized ? 0 : w.y,
left: w.maximized ? 0 : w.x,
width: w.maximized ? '100vw' : w.width,
height: w.maximized ? '100vh' : w.height,
top: effectiveMaximized ? 0 : w.y,
left: effectiveMaximized ? 0 : w.x,
width: effectiveMaximized ? '100vw' : w.width,
height: effectiveMaximized ? '100dvh' : w.height,
background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
borderRadius: w.maximized ? 0 : 12,
boxShadow: w.maximized
borderRadius: effectiveMaximized ? 0 : 12,
boxShadow: effectiveMaximized
? 'none'
: interacting
? '0 20px 50px -12px rgba(0,0,0,0.35)'
@@ -282,9 +287,11 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
>
<div
onMouseDown={(e) => startDrag(e, w)}
onDoubleClick={() => onToggleMax(w.id)}
onDoubleClick={() => {
if (!isMobile) onToggleMax(w.id);
}}
style={{
height: 40,
height: isMobile ? 48 : 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
@@ -296,7 +303,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
fontWeight: 600,
letterSpacing: .2,
userSelect: 'none',
cursor: w.maximized ? 'default' : 'grab'
cursor: effectiveMaximized ? 'default' : 'grab'
}}
>
<span
@@ -311,36 +318,40 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
{titleText}
</span>
<Space size={4}>
<Button
type="text"
size="small"
aria-label="最小化"
icon={<MinusOutlined />}
onClick={() => onUpdateWindow(w.id, { minimized: true })}
style={{
color: 'var(--ant-color-text-secondary, #555)',
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
<Button
type="text"
size="small"
aria-label={w.maximized ? '还原' : '最大化'}
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => onToggleMax(w.id)}
style={{
color: 'var(--ant-color-text-secondary, #555)',
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
{!isMobile && (
<Button
type="text"
size="small"
aria-label="最小化"
icon={<MinusOutlined />}
onClick={() => onUpdateWindow(w.id, { minimized: true })}
style={{
color: 'var(--ant-color-text-secondary, #555)',
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
)}
{!isMobile && (
<Button
type="text"
size="small"
aria-label={w.maximized ? '还原' : '最大化'}
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => onToggleMax(w.id)}
style={{
color: 'var(--ant-color-text-secondary, #555)',
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
)}
<Button
type="text"
size="small"
@@ -367,7 +378,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
overflow: 'hidden'
}}
>
{!w.maximized && resizeHandles(w)}
{!effectiveMaximized && !isMobile && resizeHandles(w)}
{isFileWindow ? (
<ContentComp
filePath={w.filePath || ''}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react';
import type { AppComponentProps, AppOpenComponentProps } from '../types';
import type { PluginItem } from '../../api/plugins';
import { useI18n } from '../../i18n';
export interface PluginAppHostProps extends AppComponentProps {
plugin: PluginItem;
@@ -34,6 +35,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
entry,
onRequestClose,
}) => {
const { lang } = useI18n();
const iframeRef = useRef<HTMLIFrameElement>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
@@ -45,10 +47,11 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({
pluginVersion: plugin.version || '',
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
mode: 'file',
lang,
filePath,
entry: JSON.stringify(entry),
}),
[plugin, filePath, entry]
[plugin, filePath, entry, lang]
);
useEffect(() => {
@@ -86,6 +89,7 @@ export interface PluginAppOpenHostProps extends AppOpenComponentProps {
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
*/
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
const { lang } = useI18n();
const iframeRef = useRef<HTMLIFrameElement>(null);
const onCloseRef = useRef(onRequestClose);
onCloseRef.current = onRequestClose;
@@ -97,8 +101,9 @@ export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, on
pluginVersion: plugin.version || '',
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
mode: 'app',
lang,
}),
[plugin]
[plugin, lang]
);
useEffect(() => {

View File

@@ -3,7 +3,7 @@ import { Avatar, Button, Divider, Flex, Input, List, Modal, Space, Switch, Tag,
import { RobotOutlined, SendOutlined, DeleteOutlined, ToolOutlined, DownOutlined, UpOutlined, CodeOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { agentApi, type AgentChatMessage, type PendingToolCall } from '../api/agent';
import { agentApi, type AgentChatMessage, type PendingMcpCall } from '../api/agent';
import { useI18n } from '../i18n';
import '../styles/ai-agent.css';
@@ -108,7 +108,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<AgentChatMessage[]>([]);
const [pending, setPending] = useState<PendingToolCall[]>([]);
const [pending, setPending] = useState<PendingMcpCall[]>([]);
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
const [expandedRaw, setExpandedRaw] = useState<Record<string, boolean>>({});
const [runningTools, setRunningTools] = useState<Record<string, string>>({});
@@ -153,16 +153,14 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
for (const msg of messages) {
if (!msg || typeof msg !== 'object') continue;
if (msg.role !== 'assistant') continue;
const toolCalls = (msg as any).tool_calls;
const toolCalls = (msg as any).mcp_calls;
if (!Array.isArray(toolCalls)) continue;
for (const call of toolCalls) {
const id = typeof call?.id === 'string' ? call.id : '';
const fn = call?.function;
const name = typeof fn?.name === 'string' ? fn.name : '';
const rawArgs = typeof fn?.arguments === 'string' ? fn.arguments : '';
const name = typeof call?.name === 'string' ? call.name : '';
const args = isPlainObject(call?.arguments) ? call.arguments : {};
if (!id || !name) continue;
const parsedArgs = tryParseJson<Record<string, any>>(rawArgs) || {};
map.set(id, { name, args: parsedArgs });
map.set(id, { name, args });
}
}
return map;
@@ -179,7 +177,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
assistantIndexRef.current = {};
setLoading(true);
const approvedIds = payload.approved_tool_call_ids || [];
const approvedIds = payload.approved_mcp_call_ids || [];
if (Array.isArray(approvedIds) && approvedIds.length > 0) {
const preRunning: Record<string, string> = {};
approvedIds.forEach((id) => {
@@ -196,8 +194,8 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
messages: payload.messages,
auto_execute: autoExecute,
context: effectivePath ? { current_path: effectivePath } : undefined,
approved_tool_call_ids: payload.approved_tool_call_ids,
rejected_tool_call_ids: payload.rejected_tool_call_ids,
approved_mcp_call_ids: payload.approved_mcp_call_ids,
rejected_mcp_call_ids: payload.rejected_mcp_call_ids,
},
(evt) => {
if (seq !== streamSeqRef.current) return;
@@ -241,16 +239,16 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
delete assistantIndexRef.current[id];
return;
}
case 'tool_start': {
const toolCallId = String((evt.data as any)?.tool_call_id || '');
case 'mcp_call_start': {
const toolCallId = String((evt.data as any)?.mcp_call_id || '');
const name = String((evt.data as any)?.name || '');
if (!toolCallId) return;
if (name) toolNameByIdRef.current[toolCallId] = name;
setRunningTools((prev) => ({ ...prev, [toolCallId]: name || prev[toolCallId] || '' }));
return;
}
case 'tool_end': {
const toolCallId = String((evt.data as any)?.tool_call_id || '');
case 'mcp_call_end': {
const toolCallId = String((evt.data as any)?.mcp_call_id || '');
const name = String((evt.data as any)?.name || '');
const msg = (evt.data as any)?.message;
if (toolCallId && name) toolNameByIdRef.current[toolCallId] = name;
@@ -267,14 +265,14 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
return;
}
case 'pending': {
const items = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
const items = Array.isArray((evt.data as any)?.pending_mcp_calls) ? (evt.data as any).pending_mcp_calls : [];
setPending(items);
return;
}
case 'done': {
const base = baseMessagesRef.current || [];
const newMessages = Array.isArray((evt.data as any)?.messages) ? (evt.data as any).messages : [];
const nextPending = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
const nextPending = Array.isArray((evt.data as any)?.pending_mcp_calls) ? (evt.data as any).pending_mcp_calls : [];
setMessages([...base, ...newMessages]);
setPending(nextPending);
setRunningTools({});
@@ -326,23 +324,23 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
}, []);
const approveOne = useCallback(async (id: string) => {
await runStream({ messages, approved_tool_call_ids: [id] });
await runStream({ messages, approved_mcp_call_ids: [id] });
}, [messages, runStream]);
const rejectOne = useCallback(async (id: string) => {
await runStream({ messages, rejected_tool_call_ids: [id] });
await runStream({ messages, rejected_mcp_call_ids: [id] });
}, [messages, runStream]);
const approveAll = useCallback(async () => {
const ids = pending.map((p) => p.id).filter(Boolean);
if (ids.length === 0) return;
await runStream({ messages, approved_tool_call_ids: ids });
await runStream({ messages, approved_mcp_call_ids: ids });
}, [messages, pending, runStream]);
const rejectAll = useCallback(async () => {
const ids = pending.map((p) => p.id).filter(Boolean);
if (ids.length === 0) return;
await runStream({ messages, rejected_tool_call_ids: ids });
await runStream({ messages, rejected_mcp_call_ids: ids });
}, [messages, pending, runStream]);
const messageItems = useMemo(() => {
@@ -665,7 +663,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
const role = String((m as any).role);
const isUser = role === 'user';
const isTool = role === 'tool';
const toolCallId = typeof (m as any).tool_call_id === 'string' ? String((m as any).tool_call_id) : '';
const toolCallId = typeof (m as any).mcp_call_id === 'string' ? String((m as any).mcp_call_id) : '';
const toolInfo = toolCallId ? toolCallsById.get(toolCallId) : null;
const toolName = toolInfo?.name || (toolCallId ? toolNameByIdRef.current[toolCallId] : '') || '';
const msgKey = toolCallId ? `tool:${toolCallId}` : `${role}:${idx}`;

View File

@@ -2,7 +2,25 @@ import { Card, type CardProps } from 'antd';
import { memo } from 'react';
const PageCard = memo((props: CardProps) => {
return <Card styles={{ body: { overflowY: 'auto', height: 'calc(100vh - 145px)' } }} {...props} />;
const bodyStyles = (props.styles as { body?: React.CSSProperties } | undefined)?.body;
return (
<Card
{...props}
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...(props.style || {}) }}
styles={{
body: {
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
display: 'flex',
flexDirection: 'column',
...(bodyStyles || {}),
},
} as any}
/>
);
});
export default PageCard;
export default PageCard;

View File

@@ -1,5 +1,5 @@
html,body,#root { height: 100%; }
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
html,body,#root { min-height: 100%; height: 100%; }
body { margin: 0; font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -283,3 +283,54 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
.plugins-tabs .ant-tabs-tabpane-active {
display: flex;
}
@media (max-width: 767px) {
html, body, #root {
min-height: 100dvh;
}
body {
overflow-x: hidden;
}
.fx-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.fx-grid-item {
padding: 10px;
border-radius: 12px;
}
.fx-grid-item .thumb {
height: 104px;
}
.ant-table-wrapper .ant-table-content {
overflow-x: auto;
}
.ant-table-wrapper table {
min-width: max-content;
}
.ant-drawer .ant-drawer-content-wrapper {
max-width: 100vw !important;
}
.ant-drawer-left > .ant-drawer-content-wrapper,
.ant-drawer-right > .ant-drawer-content-wrapper {
width: 100vw !important;
}
.ant-modal-root .ant-modal {
max-width: calc(100vw - 16px) !important;
width: calc(100vw - 16px) !important;
margin: 8px auto;
}
.ant-modal-root .ant-modal .ant-modal-content {
padding: 16px;
}
}

View File

@@ -0,0 +1,14 @@
import { Grid } from 'antd';
export function useResponsive() {
const screens = Grid.useBreakpoint();
return {
screens,
isMobile: !screens.md,
isTablet: !!screens.md && !screens.xl,
isDesktop: !!screens.md,
};
}
export default useResponsive;

View File

@@ -2,8 +2,8 @@ import { createContext, useCallback, useContext, useMemo, useState, useEffect }
import type { PropsWithChildren } from 'react';
import en from './locales/en.json';
import zhOverrides from './locales/zh.json';
import { normalizeLang, persistLang, readStoredLang, type Lang } from './lang';
type Lang = 'zh' | 'en';
type Dict = Record<string, string>;
const dicts: Record<Lang, Dict> = {
@@ -11,9 +11,13 @@ const dicts: Record<Lang, Dict> = {
zh: { ...en, ...zhOverrides },
};
interface SetLangOptions {
persist?: boolean;
}
export interface I18nContextValue {
lang: Lang;
setLang: (lang: Lang) => void;
setLang: (lang: Lang, options?: SetLangOptions) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
@@ -24,13 +28,26 @@ function interpolate(template: string, params?: Record<string, string | number>)
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
}
export function I18nProvider({ children }: PropsWithChildren) {
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem('lang') as Lang) || 'zh');
interface I18nProviderProps {
defaultLanguage?: Lang;
}
const setLang = useCallback((l: Lang) => {
setLangState(l);
localStorage.setItem('lang', l);
}, []);
export function I18nProvider({ children, defaultLanguage }: PropsWithChildren<I18nProviderProps>) {
const fallbackLang = normalizeLang(defaultLanguage, 'zh');
const [lang, setLangState] = useState<Lang>(() => readStoredLang() ?? fallbackLang);
const setLang = useCallback((nextLang: Lang, options?: SetLangOptions) => {
const normalized = normalizeLang(nextLang, fallbackLang);
setLangState(normalized);
if (options?.persist === false) return;
persistLang(normalized);
}, [fallbackLang]);
useEffect(() => {
if (!readStoredLang()) {
setLangState(fallbackLang);
}
}, [fallbackLang]);
useEffect(() => {
document.documentElement.lang = lang;

42
web/src/i18n/lang.ts Normal file
View File

@@ -0,0 +1,42 @@
export type Lang = 'zh' | 'en';
const LANG_STORAGE_KEY = 'lang';
export function parseLang(raw: unknown): Lang | null {
if (typeof raw !== 'string') return null;
const value = raw.trim().toLowerCase();
if (!value) return null;
if (value === 'en' || value.startsWith('en-')) return 'en';
if (value === 'zh' || value.startsWith('zh-')) return 'zh';
return null;
}
export function normalizeLang(raw: unknown, fallback: Lang = 'zh'): Lang {
return parseLang(raw) ?? fallback;
}
export function readStoredLang(): Lang | null {
if (typeof window === 'undefined') return null;
try {
return parseLang(window.localStorage.getItem(LANG_STORAGE_KEY));
} catch {
return null;
}
}
export function persistLang(lang: Lang): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(LANG_STORAGE_KEY, lang);
} catch {
void 0;
}
}
export function getActiveLang(fallback: Lang = 'zh'): Lang {
if (typeof document !== 'undefined') {
const documentLang = parseLang(document.documentElement.lang);
if (documentLang) return documentLang;
}
return readStoredLang() ?? fallback;
}

View File

@@ -27,10 +27,13 @@
"Register failed": "Register failed",
"Please input email!": "Please input email!",
"Profile": "Profile",
"Client Authorization": "Client Authorization",
"Account Settings": "Account Settings",
"Language": "Language",
"Chinese": "中文",
"English": "English",
"Default Language": "Default Language",
"Used when the user has not selected a language": "Used when the user has not selected a language",
"Full Name": "Full Name",
"Email": "Email",
"Change Password": "Change Password",

View File

@@ -50,8 +50,13 @@
"Register failed": "注册失败",
"Please input email!": "请输入邮箱!",
"Profile": "个人资料",
"Client Authorization": "客户端授权",
"Account Settings": "账户设置",
"Language": "语言",
"Chinese": "中文",
"English": "English",
"Default Language": "默认语言",
"Used when the user has not selected a language": "用户未手动选择语言时使用",
"Full Name": "昵称",
"Email": "邮箱",
"Change Password": "修改密码",

View File

@@ -1,4 +1,4 @@
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin, Drawer } from 'antd';
import { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState, useMemo } from 'react';
@@ -10,7 +10,7 @@ import {
MenuFoldOutlined,
SendOutlined,
WechatOutlined,
WarningOutlined
WarningOutlined,
} from '@ant-design/icons';
import '../styles/sider-menu.css';
import { getLatestVersion } from '../api/config.ts';
@@ -20,6 +20,7 @@ import { useI18n } from '../i18n';
import { useAppWindows } from '../contexts/AppWindowsContext';
import WeChatModal from '../components/WeChatModal';
import { useAuth } from '../contexts/AuthContext';
const { Sider } = Layout;
export interface SideNavProps {
@@ -27,9 +28,20 @@ export interface SideNavProps {
onToggle(): void;
activeKey: string;
onChange(key: string): void;
mobile?: boolean;
open?: boolean;
onClose?: () => void;
}
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
const SideNav = memo(function SideNav({
collapsed,
activeKey,
onChange,
onToggle,
mobile = false,
open = false,
onClose,
}: SideNavProps) {
const status = useSystemStatus();
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
@@ -41,174 +53,170 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
version: string;
body: string;
} | null>(null);
// 根据用户权限过滤导航项
const filteredNavGroups = useMemo(() => {
const isAdmin = user?.is_admin ?? false;
return navGroups
.map(group => ({
.map((group) => ({
...group,
children: group.children.filter(item => !item.adminOnly || isAdmin)
children: group.children.filter((item) => (!item.adminOnly || isAdmin) && !(mobile && item.hideOnMobile)),
}))
.filter(group => group.children.length > 0);
}, [user]);
.filter((group) => group.children.length > 0);
}, [mobile, user]);
useEffect(() => {
getLatestVersion().then(resp => {
getLatestVersion().then((resp) => {
if (resp.latest_version && resp.body) {
setLatestVersion({
version: resp.latest_version,
body: resp.body
body: resp.body,
});
}
});
}, []);
const showVersionModal = () => {
setIsVersionModalOpen(true);
};
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
const { windows, restoreWindow } = useAppWindows();
const minimized = windows.filter(w => w.minimized);
const minimized = windows.filter((w) => w.minimized);
const DEFAULT_APP_ICON =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<rect x="3" y="3" width="18" height="18" rx="4" ry="4" fill="currentColor" />
<rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
</svg>`
</svg>`,
);
return (
<>
<Sider
collapsedWidth={60}
collapsible
trigger={null}
collapsed={collapsed}
width={208}
const currentCollapsed = mobile ? false : collapsed;
const handleChange = (key: string) => {
onChange(key);
if (mobile) {
onClose?.();
}
};
const renderNavBody = (bodyCollapsed: boolean, showCollapseButton: boolean) => (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
justifyContent: bodyCollapsed ? 'center' : 'space-between',
padding: '0 14px',
fontWeight: 600,
fontSize: 18,
letterSpacing: .5,
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img
src={status?.logo}
alt="Foxel"
letterSpacing: 0.5,
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', minWidth: 0 }}>
<img
src={status?.logo}
alt="Foxel"
style={{
width: 24,
height: 24,
objectFit: 'contain',
marginRight: bodyCollapsed ? 0 : 8,
...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: status?.logo?.endsWith('.svg')
? { filter: 'brightness(0) saturate(100%)' }
: {}),
}}
/>
{!bodyCollapsed && (
<span
style={{
width: 24,
height: 24,
objectFit: 'contain',
marginRight: collapsed ? 0 : 8,
...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
}}
/>
{!collapsed && (
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
{status?.title}
</span>
)}
</div>
{/* 展开时显示收缩按钮 */}
{!collapsed && (
<Button
type="text"
icon={<MenuFoldOutlined />}
onClick={onToggle}
style={{ fontSize: 18 }}
/>
)}
</div>
{/* 分组渲染 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
{filteredNavGroups.map((group: NavGroup) => (
<div key={group.key} style={{ marginBottom: 12 }}>
{group.title && (
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: .5,
padding: '6px 10px 4px',
color: token.colorTextTertiary,
textTransform: 'uppercase'
}}
>{t(group.title)}</div>
)}
<Menu
mode="inline"
selectable
inlineIndent={12}
selectedKeys={[activeKey]}
onClick={(e) => onChange(e.key)}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
style={{ borderInline: 'none', background: 'transparent' }}
className="sider-menu-group foxel-sider-menu"
/>
</div>
))}
</div>
<div
style={{
bottom: '10px',
position: 'absolute',
width: '100%',
padding: '12px 8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
flexShrink: 0,
borderTop: `1px solid ${token.colorBorderSecondary}`
}}
>
{/* 最小化应用 Dock */}
{!collapsed && minimized.length > 0 && (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: collapsed ? 'column' : 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
flexWrap: collapsed ? 'nowrap' : 'wrap',
maxHeight: collapsed ? 160 : undefined,
overflowY: collapsed ? 'auto' : 'visible',
fontWeight: 700,
color: resolvedMode === 'dark' ? '#fff' : token.colorText,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry.name}` : w.app.name;
return (
<Tooltip key={w.id} title={title} placement={collapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);
})}
</div>
{status?.title}
</span>
)}
<div style={{
</div>
{showCollapseButton && !bodyCollapsed && (
<Button type="text" icon={<MenuFoldOutlined />} onClick={onToggle} style={{ fontSize: 18 }} />
)}
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
{filteredNavGroups.map((group: NavGroup) => (
<div key={group.key} style={{ marginBottom: 12 }}>
{!!group.title && !bodyCollapsed && (
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 0.5,
padding: '6px 10px 4px',
color: token.colorTextTertiary,
textTransform: 'uppercase',
}}
>
{t(group.title)}
</div>
)}
<Menu
mode="inline"
selectable
inlineIndent={12}
inlineCollapsed={!mobile && bodyCollapsed}
selectedKeys={[activeKey]}
onClick={(e) => handleChange(e.key)}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
style={{ borderInline: 'none', background: 'transparent' }}
className="sider-menu-group foxel-sider-menu"
/>
</div>
))}
</div>
<div
style={{
padding: '12px 8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
flexShrink: 0,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
{!bodyCollapsed && minimized.length > 0 && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
{minimized.map((w) => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
return (
<Tooltip key={w.id} title={title} placement={bodyCollapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);
})}
</div>
)}
<div
style={{
fontSize: 12,
color: token.colorTextSecondary,
textAlign: 'center',
@@ -216,67 +224,78 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}} onClick={showVersionModal}>
{hasUpdate ? (
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}>
<a rel="noopener noreferrer"
style={{ textDecoration: 'none' }}>
{collapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<WarningOutlined />} color="warning">
{t('Update available')} [{latestVersion?.version}]
</Tag>
)}
</a>
</Tooltip>
) : (
latestVersion ? (
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}>
{collapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<CheckCircleOutlined />} color="success">
{status?.version}
</Tag>
)}
</Tooltip>
cursor: 'pointer',
}}
onClick={() => setIsVersionModalOpen(true)}
>
{hasUpdate ? (
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={bodyCollapsed ? 'right' : 'top'}>
<a rel="noopener noreferrer" style={{ textDecoration: 'none' }}>
{bodyCollapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<WarningOutlined />} color="warning">
{t('Update available')} [{latestVersion?.version}]
</Tag>
)}
</a>
</Tooltip>
) : latestVersion ? (
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={bodyCollapsed ? 'right' : 'top'}>
{bodyCollapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
collapsed ? null : <Tag>{status?.version}</Tag>
)
)}
</div>
{!collapsed && (
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}>
<Button
shape="circle"
icon={<GithubOutlined />}
href="https://github.com/DrizzleTime/Foxel"
target="_blank"
/>
<Button
shape="circle"
icon={<WechatOutlined />}
onClick={() => setIsModalOpen(true)}
/>
<Button
shape="circle"
icon={<SendOutlined />}
href="https://t.me/+thDsBfyqJxZkNTU1"
target="_blank"
/>
<Button
shape="circle"
icon={<FileTextOutlined />}
href="https://foxel.cc"
target="_blank"
/>
</div>
<Tag icon={<CheckCircleOutlined />} color="success">
{status?.version}
</Tag>
)}
</Tooltip>
) : (
!bodyCollapsed && <Tag>{status?.version}</Tag>
)}
</div>
</Sider>
{!bodyCollapsed && (
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}>
<Button shape="circle" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank" />
<Button shape="circle" icon={<WechatOutlined />} onClick={() => setIsModalOpen(true)} />
<Button shape="circle" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank" />
<Button shape="circle" icon={<FileTextOutlined />} href="https://foxel.cc" target="_blank" />
</div>
)}
</div>
</div>
);
return (
<>
{mobile ? (
<Drawer
placement="left"
open={open}
onClose={onClose}
title={null}
width={280}
styles={{ body: { padding: 0 } }}
>
{renderNavBody(false, false)}
</Drawer>
) : (
<Sider
collapsedWidth={60}
collapsible
trigger={null}
collapsed={collapsed}
width={208}
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
{renderNavBody(currentCollapsed, true)}
</Sider>
)}
<WeChatModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
<Modal
open={isVersionModalOpen}
@@ -318,31 +337,42 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
/>
)}
<Divider titlePlacement="left" plain>{t('Changelog')}</Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`
}}>
<Divider titlePlacement="left" plain>
{t('Changelog')}
</Divider>
<div
style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<ReactMarkdown
components={{
h3: ({ ...props }) => <h3 style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16,
color: token.colorTextHeading
}} {...props} />,
h3: ({ ...props }) => (
<h3
style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16,
color: token.colorTextHeading,
}}
{...props}
/>
),
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
}}
>{latestVersion.body}</ReactMarkdown>
>
{latestVersion.body}
</ReactMarkdown>
</div>
</>
) : (

View File

@@ -1,6 +1,6 @@
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons';
import { memo, useState } from 'react';
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip, Modal, QRCode } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined, QrcodeOutlined } from '@ant-design/icons';
import { memo, useMemo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router';
@@ -10,6 +10,7 @@ import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext';
import useResponsive from '../hooks/useResponsive';
const { Header } = Layout;
@@ -17,17 +18,24 @@ export interface TopHeaderProps {
collapsed: boolean;
onToggle(): void;
onOpenAiAgent(): void;
showMenuButton?: boolean;
}
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) {
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent, showMenuButton }: TopHeaderProps) {
const { token } = theme.useToken();
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const { user } = useAuth();
const { user, token: authToken } = useAuth();
const [profileOpen, setProfileOpen] = useState(false);
const [clientAuthOpen, setClientAuthOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus();
const { isMobile } = useResponsive();
const clientAuthPayload = useMemo(() => JSON.stringify({
base_url: window.location.origin,
token: authToken || '',
}), [authToken]);
const handleLogout = () => {
authApi.logout();
@@ -35,26 +43,42 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
};
const openProfile = () => setProfileOpen(true);
const openClientAuth = () => setClientAuthOpen(true);
return (
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
{collapsed && (
<Header
style={{
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: isMobile ? 8 : 16,
paddingInline: isMobile ? 12 : 16,
minWidth: 0,
backdropFilter: 'saturate(180%) blur(8px)',
}}
>
{showMenuButton && (
<Button
type="text"
icon={<MenuUnfoldOutlined />}
onClick={onToggle}
style={{ fontSize: 18, marginRight: 8 }}
style={{ fontSize: 18, marginRight: isMobile ? 0 : 8 }}
aria-label={collapsed ? t('Open menu') : t('Collapse menu')}
/>
)}
<Button
icon={<SearchOutlined />}
style={{ maxWidth: 420 }}
style={{ maxWidth: isMobile ? 40 : 420, minWidth: isMobile ? 40 : undefined, paddingInline: isMobile ? 0 : undefined }}
onClick={() => setSearchOpen(true)}
aria-label={t('Search files / tags / types')}
>
{t('Search files / tags / types')}
{!isMobile && t('Search files / tags / types')}
</Button>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
<Flex style={{ marginLeft: 'auto', minWidth: 0 }} align="center" gap={isMobile ? 4 : 12}>
<Tooltip title={t('Notices')}>
<Button
type="text"
@@ -78,8 +102,9 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
menu={{
items: [
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
]
{ key: 'client-auth', label: t('Client Authorization'), icon: <QrcodeOutlined />, onClick: openClientAuth },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout },
],
}}
>
<Button type="text" style={{ paddingInline: 8, height: 40 }}>
@@ -87,13 +112,27 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
<Avatar size={28} src={user?.gravatar_url}>
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
</Avatar>
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
{!isMobile && (
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
)}
</Flex>
</Button>
</Dropdown>
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
<Modal
title={t('Client Authorization')}
open={clientAuthOpen}
onCancel={() => setClientAuthOpen(false)}
footer={null}
width={320}
centered
>
<Flex justify="center" style={{ padding: '8px 0' }}>
<QRCode value={clientAuthPayload} size={220} />
</Flex>
</Modal>
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
</Flex>
</Header>

View File

@@ -15,7 +15,7 @@ import {
} from '@ant-design/icons';
import type { ReactNode } from 'react';
export interface NavItem { key: string; icon: ReactNode; label: string; adminOnly?: boolean; }
export interface NavItem { key: string; icon: ReactNode; label: string; adminOnly?: boolean; hideOnMobile?: boolean; }
export interface NavGroup { key: string; title?: string; children: NavItem[]; }
export const navGroups: NavGroup[] = [
@@ -30,7 +30,7 @@ export const navGroups: NavGroup[] = [
key: 'manage',
title: 'Manage',
children: [
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors', hideOnMobile: true },
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
{ key: 'task-queue', icon: React.createElement(ClockCircleOutlined), label: 'Task Queue' },
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
@@ -45,7 +45,7 @@ export const navGroups: NavGroup[] = [
children: [
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings', adminOnly: true },
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore', hideOnMobile: true },
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
]
}

View File

@@ -180,6 +180,14 @@ const AdaptersPage = memo(function AdaptersPage() {
let valuePropName: string | undefined;
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
if (field.type === 'select') {
inputNode = (
<Select
placeholder={field.placeholder}
options={(field.options || []).map(option => ({ value: option, label: t(option) }))}
/>
);
}
if (field.type === 'boolean') {
inputNode = <Switch />;
valuePropName = 'checked';
@@ -202,7 +210,7 @@ const AdaptersPage = memo(function AdaptersPage() {
<PageCard
title={t('Storage Adapters')}
extra={
<Space>
<Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
</Space>
@@ -214,6 +222,7 @@ const AdaptersPage = memo(function AdaptersPage() {
columns={columns as any}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }}
/>
<Drawer

View File

@@ -3,6 +3,7 @@ import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker, D
import PageCard from '../components/PageCard';
import { auditApi, type AuditLogItem, type PaginatedAuditLogs } from '../api/audit';
import { useI18n } from '../i18n';
import useResponsive from '../hooks/useResponsive';
import { format, formatISO } from 'date-fns';
const { RangePicker } = DatePicker;
@@ -47,6 +48,7 @@ const renderHttpMethodTag = (method: string) => {
};
const AuditLogsPage = memo(function AuditLogsPage() {
const { isMobile } = useResponsive();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PaginatedAuditLogs | null>(null);
const [filters, setFilters] = useState<{
@@ -264,7 +266,7 @@ const AuditLogsPage = memo(function AuditLogsPage() {
{selectedLog && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions
column={2}
column={isMobile ? 1 : 2}
bordered
size="small"
labelStyle={{ minWidth: 120, whiteSpace: 'nowrap', fontWeight: 500 }}

View File

@@ -29,10 +29,12 @@ import { SearchResultsView } from './components/SearchResultsView';
import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client';
import { LoadingSkeleton } from './components/LoadingSkeleton';
import useResponsive from '../../hooks/useResponsive';
const FileExplorerPage = memo(function FileExplorerPage() {
const { navKey = 'files', '*': restPath = '' } = useParams();
const { token } = theme.useToken();
const { isMobile } = useResponsive();
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [isDragging, setIsDragging] = useState(false);
const [showSkeleton, setShowSkeleton] = useState(false);
@@ -43,7 +45,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, openContextMenuAt, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh);
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
const { thumbs } = useThumbnails(entries, path);
@@ -91,6 +93,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
openResult: openSearchResult,
selectResult: selectSearchResult,
openResultContextMenu: openSearchContextMenu,
openResultContextMenuAt: openSearchContextMenuAt,
clearSelection: clearSearchSelection,
} = fileSearch;
@@ -103,6 +106,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
useEffect(() => {
if (isMobile && viewMode !== 'grid') {
setViewMode('grid');
}
}, [isMobile, viewMode]);
const effectiveRefresh = useCallback(() => {
if (isSearching) {
refreshSearch();
@@ -172,7 +181,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
setDetailLoading(true);
try {
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
const stat = await vfsApi.stat(fullPath);
const stat = await vfsApi.stat(fullPath, { verbose: true });
setDetailData(stat as Record<string, unknown>);
} catch (error) {
const messageText = error instanceof Error ? error.message : String(error);
@@ -230,13 +239,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
void handleFileDrop(e.dataTransfer);
};
const getAnchorPoint = useCallback((anchor: HTMLElement) => {
const rect = anchor.getBoundingClientRect();
return {
x: Math.min(rect.right, window.innerWidth - 24),
y: Math.min(rect.bottom + 8, window.innerHeight - 24),
};
}, []);
const openEntryMenuFromAnchor = useCallback((entry: VfsEntry, anchor: HTMLElement) => {
const point = getAnchorPoint(anchor);
openContextMenuAt(entry, point.x, point.y);
}, [getAnchorPoint, openContextMenuAt]);
const openSearchMenuFromAnchor = useCallback((fullPath: string, anchor: HTMLElement) => {
const point = getAnchorPoint(anchor);
void openSearchContextMenuAt(point.x, point.y, fullPath);
}, [getAnchorPoint, openSearchContextMenuAt]);
return (
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
height: 'calc(100vh - 88px)',
height: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
position: 'relative'
@@ -254,10 +282,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
isMobile={isMobile}
onGoUp={goUp}
onNavigate={navigateTo}
onRefresh={effectiveRefresh}
onCreateDir={() => setCreatingDir(true)}
onCreateFile={() => setCreatingFile(true)}
onUploadFile={openFilePicker}
onUploadDirectory={openDirectoryPicker}
onSetViewMode={setViewMode}
@@ -279,7 +309,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onChange={handleDirectoryInputChange}
/>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
{isSearching ? (
<SearchResultsView
viewMode={viewMode}
@@ -289,10 +319,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
items={searchItems}
selectedPaths={searchSelectedPaths}
entrySnapshot={searchEntrySnapshot}
mobile={isMobile}
onClearSearch={clearSearchParams}
onSelect={selectSearchResult}
onOpen={(fullPath) => { void openSearchResult(fullPath); }}
onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }}
onOpenMenu={openSearchMenuFromAnchor}
/>
) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
<LoadingSkeleton mode={viewMode} />
@@ -304,10 +336,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
thumbs={thumbs}
selectedEntries={selectedEntries}
path={path}
mobile={isMobile}
onSelect={handleSelect}
onSelectRange={handleSelectRange}
onOpen={handleOpenEntry}
onContextMenu={openContextMenu}
onOpenMenu={openEntryMenuFromAnchor}
/>
) : (
<FileListView
@@ -408,6 +442,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
<ContextMenu
x={ctxMenu?.x || blankCtxMenu!.x}
y={ctxMenu?.y || blankCtxMenu!.y}
mobile={isMobile}
entry={ctxMenu?.entry}
entries={isSearching ? searchContextEntries : entries}
selectedEntries={isSearching ? searchSelectedNames : selectedEntries}

View File

@@ -1,5 +1,5 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { Menu, theme } from 'antd';
import { Drawer, Menu, theme } from 'antd';
import type { MenuProps } from 'antd';
import type { VfsEntry } from '../../../api/client';
import type { ProcessorTypeMeta } from '../../../api/processors';
@@ -14,6 +14,7 @@ import {
interface ContextMenuProps {
x: number;
y: number;
mobile?: boolean;
entry?: VfsEntry;
entries: VfsEntry[];
selectedEntries: string[];
@@ -51,7 +52,7 @@ interface ActionMenuItem {
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const { token } = theme.useToken();
const { t } = useI18n();
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
const { x, y, mobile = false, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y });
@@ -244,12 +245,40 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
}
}, [position.left, position.top, items.length]);
if (mobile) {
return (
<Drawer
open
placement="bottom"
onClose={onClose}
title={entry ? t('Actions') : t('Quick Actions')}
height="auto"
styles={{ body: { padding: 8 } }}
>
<div onClick={(e) => e.stopPropagation()}>
<Menu
items={items}
mode="inline"
selectable={false}
onClick={({ key }) => {
const handler = handlerMap.get(String(key));
if (!handler) return;
handler();
onClose();
}}
style={{ borderRadius: token.borderRadius, background: 'transparent', border: 'none' }}
/>
</div>
</Drawer>
);
}
return (
<div
ref={containerRef}
style={{ position: 'fixed', top: position.top, left: position.left, zIndex: 9999, boxShadow: '0 4px 16px rgba(0,0,0,.15)', borderRadius: token.borderRadius, background: token.colorBgElevated }}
onContextMenu={(e) => e.preventDefault()}
onClick={onClose} // Close on any click inside the menu area
onClick={onClose}
>
<Menu
items={items}

View File

@@ -106,6 +106,7 @@ export const FileListView: React.FC<FileListViewProps> = ({
dataSource={entries}
columns={columns as any}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={(r) => ({
onClick: (e: any) => onRowClick(r, e),
onDoubleClick: () => onOpen(r),

View File

@@ -1,20 +1,23 @@
import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, theme } from 'antd';
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
import { Tooltip, theme, Button } from 'antd';
import { FolderFilled, PictureOutlined, MoreOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { EmptyState } from './EmptyState';
import { useTheme } from '../../../contexts/ThemeContext';
import { useI18n } from '../../../i18n';
interface Props {
entries: VfsEntry[];
thumbs: Record<string, string>;
selectedEntries: string[];
path: string;
mobile?: boolean;
onSelect: (e: VfsEntry, additive?: boolean) => void;
onSelectRange: (names: string[]) => void;
onOpen: (e: VfsEntry) => void;
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
onOpenMenu?: (entry: VfsEntry, anchor: HTMLElement) => void;
}
const formatSize = (size: number) => {
@@ -24,33 +27,29 @@ const formatSize = (size: number) => {
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
};
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, mobile = false, onSelect, onSelectRange, onOpen, onContextMenu, onOpenMenu }) => {
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const { t } = useI18n();
const lightenColor = (hex: string, amount: number) => {
const parseHex = (h: string) => {
const s = h.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const n = s.length === 3 ? s.split('').map((c) => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return null;
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
};
const rgb = parseHex(hex);
if (!rgb) return hex;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const r = mix(rgb.r);
const g = mix(rgb.g);
const b = mix(rgb.b);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
return `#${toHex(mix(rgb.r))}${toHex(mix(rgb.g))}${toHex(mix(rgb.b))}`;
};
const toRgba = (hex: string, alpha: number) => {
const s = hex.replace('#', '');
const normalized = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const normalized = s.length === 3 ? s.split('').map((c) => c + c).join('') : s;
const num = parseInt(normalized, 16);
if (Number.isNaN(num) || normalized.length !== 6) {
return `rgba(22, 119, 255, ${alpha})`;
@@ -60,13 +59,15 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
const b = num & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const startRef = useRef<{ x: number, y: number } | null>(null);
const [rect, setRect] = useState<{ left: number, top: number, width: number, height: number } | null>(null);
const startRef = useRef<{ x: number; y: number } | null>(null);
const [rect, setRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
const [selecting, setSelecting] = useState(false);
useEffect(() => {
if (mobile) return;
const grid = containerRef.current;
const scrollContainer = grid?.parentElement;
if (!scrollContainer) return;
@@ -82,9 +83,10 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
scrollContainer.addEventListener('mousedown', onBlankMouseDown);
return () => scrollContainer.removeEventListener('mousedown', onBlankMouseDown);
}, []);
}, [mobile]);
useEffect(() => {
if (mobile) return;
const onMove = (ev: MouseEvent) => {
if (!startRef.current) return;
const cx = ev.clientX;
@@ -99,22 +101,19 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
const onUp = () => {
if (!startRef.current) return;
setSelecting(false);
const r = rect;
if (r) {
const container = containerRef.current;
if (container) {
const sel: string[] = [];
entries.forEach(ent => {
const el = itemRefs.current[ent.name];
if (!el) return;
const br = el.getBoundingClientRect();
const rr = { left: r.left, top: r.top, right: r.left + r.width, bottom: r.top + r.height };
const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom };
const intersect = !(br2.left > rr.right || br2.right < rr.left || br2.top > rr.bottom || br2.bottom < rr.top);
if (intersect) sel.push(ent.name);
});
if (sel.length > 0) onSelectRange(sel);
}
const currentRect = rect;
if (currentRect) {
const sel: string[] = [];
entries.forEach((ent) => {
const el = itemRefs.current[ent.name];
if (!el) return;
const br = el.getBoundingClientRect();
const rr = { left: currentRect.left, top: currentRect.top, right: currentRect.left + currentRect.width, bottom: currentRect.top + currentRect.height };
const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom };
const intersect = !(br2.left > rr.right || br2.right < rr.left || br2.top > rr.bottom || br2.bottom < rr.top);
if (intersect) sel.push(ent.name);
});
if (sel.length > 0) onSelectRange(sel);
}
startRef.current = null;
setRect(null);
@@ -129,10 +128,10 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [selecting, rect, entries, onSelectRange]);
}, [entries, mobile, onSelectRange, rect, selecting]);
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
if (mobile || e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.fx-grid-item')) {
return;
@@ -144,25 +143,48 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
};
return (
<div className="fx-grid" style={{ padding: 16 }} ref={containerRef} onMouseDown={handleMouseDown}>
{entries.map(ent => {
<div className="fx-grid" style={{ padding: mobile ? 12 : 16 }} ref={containerRef} onMouseDown={handleMouseDown}>
{entries.map((ent) => {
const isImg = thumbs[ent.name];
const ext = ent.name.split('.').pop()?.toLowerCase();
const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || '');
const isSelected = selectedEntries.includes(ent.name);
return (
<div
key={ent.name}
ref={(el) => { itemRefs.current[ent.name] = el; }}
ref={(el) => {
itemRefs.current[ent.name] = el;
}}
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')}
onClick={(ev) => {
const additive = ev.ctrlKey || ev.metaKey;
onSelect(ent, additive);
if (mobile) {
onOpen(ent);
return;
}
onSelect(ent, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(ent);
}}
onContextMenu={(e) => {
if (!mobile) onContextMenu(e, ent);
}}
onDoubleClick={() => onOpen(ent)}
onContextMenu={(e) => onContextMenu(e, ent)}
style={{ userSelect: 'none' }}
>
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(ent, e.currentTarget);
}}
style={{ position: 'absolute', top: 4, right: 4, zIndex: 2 }}
/>
)}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
{ent.is_dir && (
<FolderFilled
@@ -172,23 +194,19 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
}}
/>
)}
{!ent.is_dir && (
isImg ? (
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
) : isPictureType ? (
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
) : (
getFileIcon(ent.name, 32, resolvedMode)
)
)}
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} /> : isPictureType ? <PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} /> : getFileIcon(ent.name, 32, resolvedMode))}
{ent.type === 'mount' && <span className="badge">M</span>}
</div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip>
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
<Tooltip title={ent.name}>
<div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div>
</Tooltip>
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>
{ent.is_dir ? t('Folder') : formatSize(ent.size)}
</div>
</div>
)
);
})}
{rect && (
{!mobile && rect && (
<div
style={{
position: 'fixed',
@@ -198,7 +216,7 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
height: rect.height,
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
background: toRgba(String(token.colorPrimary || '#1677ff'), 0.16),
zIndex: 999
zIndex: 999,
}}
/>
)}

View File

@@ -1,6 +1,6 @@
import React, { useRef, useState } from 'react';
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined, MoreOutlined, FileAddOutlined } from '@ant-design/icons';
import { Select } from 'antd';
import { useI18n } from '../../../i18n';
import type { ViewMode } from '../types';
@@ -12,10 +12,12 @@ interface HeaderProps {
viewMode: ViewMode;
sortBy: string;
sortOrder: string;
isMobile?: boolean;
onGoUp: () => void;
onNavigate: (path: string) => void;
onRefresh: () => void;
onCreateDir: () => void;
onCreateFile: () => void;
onUploadFile: () => void;
onUploadDirectory: () => void;
onSetViewMode: (mode: ViewMode) => void;
@@ -28,10 +30,12 @@ export const Header: React.FC<HeaderProps> = ({
viewMode,
sortBy,
sortOrder,
isMobile = false,
onGoUp,
onNavigate,
onRefresh,
onCreateDir,
onCreateFile,
onUploadFile,
onUploadDirectory,
onSetViewMode,
@@ -60,6 +64,7 @@ export const Header: React.FC<HeaderProps> = ({
};
const handlePathEdit = () => {
if (isMobile) return;
clearClickTimer();
setEditingPath(true);
setPathInputValue(path);
@@ -78,10 +83,6 @@ export const Header: React.FC<HeaderProps> = ({
setPathInputValue('');
};
const handleBreadcrumbDoubleClick = () => {
handlePathEdit();
};
const renderBreadcrumb = () => {
if (editingPath) {
return (
@@ -104,15 +105,15 @@ export const Header: React.FC<HeaderProps> = ({
const segmentPath = '/' + arr.slice(0, index + 1).join('/');
return {
key: segmentPath,
title: <span style={{ cursor: 'pointer' }} onClick={() => scheduleNavigate(segmentPath)}>{segment}</span>
title: <span style={{ cursor: 'pointer' }} onClick={() => scheduleNavigate(segmentPath)}>{segment}</span>,
};
})
}),
];
return (
<div
style={{
cursor: 'text',
cursor: isMobile ? 'default' : 'text',
padding: `${token.paddingXXS}px ${token.paddingXS}px`,
borderRadius: token.borderRadius,
transition: 'background-color 0.2s',
@@ -121,74 +122,138 @@ export const Header: React.FC<HeaderProps> = ({
height: pathEditorHeight,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center'
alignItems: 'center',
minWidth: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillTertiary; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
onDoubleClick={handleBreadcrumbDoubleClick}
onMouseEnter={(e) => {
if (!isMobile) e.currentTarget.style.backgroundColor = token.colorFillTertiary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
onDoubleClick={handlePathEdit}
>
<Breadcrumb items={breadcrumbItems} separator="/" style={{ fontSize: token.fontSizeSM }} />
</div>
);
};
const mobileMoreItems = [
{
key: 'new-file',
label: t('New File'),
icon: <FileAddOutlined />,
onClick: onCreateFile,
},
{
key: 'upload-folder',
label: t('Upload Folder'),
icon: <UploadOutlined />,
onClick: onUploadDirectory,
},
{
key: 'sort',
label: t('Sort By') + `: ${t(sortBy === 'mtime' ? 'Modified Time' : sortBy === 'size' ? 'Size' : 'Name')}`,
children: [
{ key: 'sort-name', label: t('Name'), onClick: () => onSortChange('name', sortOrder) },
{ key: 'sort-size', label: t('Size'), onClick: () => onSortChange('size', sortOrder) },
{ key: 'sort-mtime', label: t('Modified Time'), onClick: () => onSortChange('mtime', sortOrder) },
],
},
{
key: 'sort-order',
label: sortOrder === 'asc' ? t('Ascending') : t('Descending'),
icon: sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />,
onClick: () => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'),
},
];
const uploadMenu = {
items: [
{ key: 'file', label: t('Upload Files') },
{ key: 'folder', label: t('Upload Folder') },
],
onClick: ({ key }: { key: string }) => {
if (key === 'folder') {
onUploadDirectory();
} else {
onUploadFile();
}
},
};
if (isMobile) {
return (
<Flex align="center" gap={6} style={{ padding: '10px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}`, minWidth: 0 }}>
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
{renderBreadcrumb()}
<Space size={4} style={{ flexShrink: 0 }}>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading} aria-label={t('Refresh')} />
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir} aria-label={t('New Folder')} />
<Button size="small" icon={<UploadOutlined />} onClick={onUploadFile} aria-label={t('Upload Files')} />
<Dropdown menu={{ items: mobileMoreItems }}>
<Button size="small" icon={<MoreOutlined />} aria-label={t('More')} />
</Dropdown>
</Space>
</Flex>
);
}
return (
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}>
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}>
<Flex vertical gap={12} style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Flex align="center" gap={8} style={{ minWidth: 0 }}>
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
<Typography.Text strong>{t('File Manager')}</Typography.Text>
<Divider type="vertical" />
{renderBreadcrumb()}
</Flex>
<Space size={8} wrap>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
<Dropdown.Button
size="small"
icon={<UploadOutlined />}
onClick={onUploadFile}
menu={{
items: [
{ key: 'file', label: t('Upload Files') },
{ key: 'folder', label: t('Upload Folder') },
],
onClick: ({ key }) => {
if (key === 'folder') {
onUploadDirectory();
} else {
onUploadFile();
}
},
}}
>
{t('Upload')}
</Dropdown.Button>
<Select
size="small"
value={sortBy}
onChange={(val) => onSortChange(val, sortOrder)}
style={{ width: 80 }}
options={[
{ value: 'name', label: t('Name') },
{ value: 'size', label: t('Size') },
{ value: 'mtime', label: t('Modified Time') },
]}
/>
<Button
size="small"
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
/>
<Segmented
size="small"
value={viewMode}
onChange={value => onSetViewMode(value as ViewMode)}
options={[
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
]}
/>
</Space>
<Flex align="center" justify="space-between" gap={8} style={{ flexWrap: 'wrap' }}>
<Space size={8} wrap>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading} aria-label={t('Refresh')}>
{t('Refresh')}
</Button>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir} aria-label={t('New Folder')}>
{t('New Folder')}
</Button>
<Dropdown.Button
size="small"
icon={<UploadOutlined />}
onClick={onUploadFile}
menu={uploadMenu}
>
{t('Upload')}
</Dropdown.Button>
</Space>
<Space size={8} wrap>
<Select
size="small"
value={sortBy}
onChange={(val) => onSortChange(val, sortOrder)}
style={{ width: 112 }}
options={[
{ value: 'name', label: t('Name') },
{ value: 'size', label: t('Size') },
{ value: 'mtime', label: t('Modified Time') },
]}
/>
<Button
size="small"
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
/>
<Segmented
size="small"
value={viewMode}
onChange={(value) => onSetViewMode(value as ViewMode)}
options={[
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' },
]}
/>
</Space>
</Flex>
</Flex>
);
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Empty, Flex, Spin, Tag, Typography, theme } from 'antd';
import { Empty, Flex, Spin, Tag, Typography, theme, Button } from 'antd';
import { MoreOutlined } from '@ant-design/icons';
import { useI18n } from '../../../i18n';
import type { VfsEntry } from '../../../api/client';
import type { ViewMode } from '../types';
@@ -13,10 +14,12 @@ interface SearchResultsViewProps {
items: SearchDisplayItem[];
selectedPaths: string[];
entrySnapshot: Record<string, VfsEntry>;
mobile?: boolean;
onClearSearch: () => void;
onSelect: (fullPath: string, additive: boolean) => void;
onOpen: (fullPath: string) => void;
onContextMenu: (e: React.MouseEvent, fullPath: string) => void;
onOpenMenu?: (fullPath: string, anchor: HTMLElement) => void;
}
export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
@@ -27,10 +30,12 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
items,
selectedPaths,
entrySnapshot,
mobile = false,
onClearSearch,
onSelect,
onOpen,
onContextMenu,
onOpenMenu,
}) => {
const { token } = theme.useToken();
const { t } = useI18n();
@@ -75,13 +80,11 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
};
return (
<div style={{ padding: 16 }}>
<div style={{ padding: mobile ? 12 : 16 }}>
<Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
<Typography.Text strong>{t('Search Results')}</Typography.Text>
<Tag color={mode === 'filename' ? 'green' : 'blue'}>
{mode === 'filename' ? t('Name Search') : t('Smart Search')}
</Tag>
<Tag color={mode === 'filename' ? 'green' : 'blue'}>{mode === 'filename' ? t('Name Search') : t('Smart Search')}</Tag>
<Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}>
{query}
</Tag>
@@ -97,10 +100,7 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Flex>
) : viewMode === 'grid' ? (
<div
className="fx-grid"
style={{ padding: 0, gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
>
<div className="fx-grid" style={{ padding: 0, gridTemplateColumns: mobile ? 'repeat(auto-fill, minmax(160px, 1fr))' : 'repeat(auto-fill, minmax(220px, 1fr))' }}>
{items.map(({ item, fullPath, dir, name }) => {
const selected = selectedPaths.includes(fullPath);
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
@@ -110,16 +110,37 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
<div
key={fullPath}
className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')}
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
onDoubleClick={() => onOpen(fullPath)}
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
onClick={(ev) => {
if (mobile) {
onOpen(fullPath);
return;
}
onSelect(fullPath, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(fullPath);
}}
onContextMenu={(ev) => {
if (!mobile) onContextMenu(ev, fullPath);
}}
style={{ userSelect: 'none' }}
>
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(fullPath, e.currentTarget);
}}
style={{ position: 'absolute', top: 4, right: 4, zIndex: 2 }}
/>
)}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
<span className="badge score-badge">{scoreText}</span>
{isDir
? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text>
: <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
{isDir ? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text> : <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
</div>
<div className="name ellipsis">{name}</div>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
@@ -141,45 +162,48 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
<div
key={fullPath}
className={selected ? 'row-selected' : ''}
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
onDoubleClick={() => onOpen(fullPath)}
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
onClick={(ev) => {
if (mobile) {
onOpen(fullPath);
return;
}
onSelect(fullPath, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(fullPath);
}}
onContextMenu={(ev) => {
if (!mobile) onContextMenu(ev, fullPath);
}}
style={{
padding: '10px 12px',
borderRadius: token.borderRadius,
background: token.colorFillTertiary,
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
}}
>
<Flex vertical style={{ gap: 6 }}>
<Typography.Text strong className="ellipsis">
{name}
</Typography.Text>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
{fullPath}
</Typography.Text>
{snippet ? (
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
{snippet}
</Typography.Paragraph>
) : null}
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(fullPath, e.currentTarget);
}}
style={{ position: 'absolute', top: 6, right: 6 }}
/>
)}
<Flex vertical style={{ gap: 6, paddingRight: mobile ? 28 : 0 }}>
<Typography.Text strong className="ellipsis">{name}</Typography.Text>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>{fullPath}</Typography.Text>
{snippet ? <Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>{snippet}</Typography.Paragraph> : null}
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
{retrieval ? (
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
{renderSourceLabel(retrieval)}
</Tag>
) : null}
<Tag
style={{
marginRight: 0,
background: token.colorBgContainer,
borderColor: token.colorBorderSecondary,
color: token.colorText,
}}
>
{scoreText}
</Tag>
{retrieval ? <Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>{renderSourceLabel(retrieval)}</Tag> : null}
<Tag style={{ marginRight: 0, background: token.colorBgContainer, borderColor: token.colorBorderSecondary, color: token.colorText }}>{scoreText}</Tag>
</Flex>
</Flex>
</div>

View File

@@ -15,6 +15,16 @@ export function useContextMenu() {
setBlankCtxMenu({ x: e.clientX, y: e.clientY });
}, []);
const openContextMenuAt = useCallback((entry: VfsEntry, x: number, y: number) => {
setBlankCtxMenu(null);
setCtxMenu({ entry, x, y });
}, []);
const openBlankContextMenuAt = useCallback((x: number, y: number) => {
setCtxMenu(null);
setBlankCtxMenu({ x, y });
}, []);
const closeContextMenus = useCallback(() => {
setCtxMenu(null);
setBlankCtxMenu(null);
@@ -25,6 +35,8 @@ export function useContextMenu() {
blankCtxMenu,
openContextMenu,
openBlankContextMenu,
openContextMenuAt,
openBlankContextMenuAt,
closeContextMenus,
};
}
}

View File

@@ -168,22 +168,19 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
refresh();
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]);
const doDownload = useCallback(async (entry: VfsEntry) => {
const doDownload = useCallback((entry: VfsEntry) => {
if (entry.is_dir) {
message.warning(t('Downloading folders is not supported'));
return;
}
try {
const buf = await vfsApi.readFile((path === '/' ? '' : path) + '/' + entry.name);
const blob = new Blob([buf]);
const url = URL.createObjectURL(blob);
const url = vfsApi.streamUrl((path === '/' ? '' : path) + '/' + entry.name);
const a = document.createElement('a');
a.href = url;
a.download = entry.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e: any) {
message.error(e.message || t('Download failed'));
}

View File

@@ -251,6 +251,20 @@ export function useFileSearch({
openContextMenu(e, entry);
}, [actionPath, ensureEntry, itemByPath, openContextMenu]);
const openResultContextMenuAt = useCallback(async (x: number, y: number, fullPath: string) => {
const info = itemByPath.get(fullPath);
if (!info) return;
setActionPath(info.dir);
setSelectedPaths((prev) => {
if (actionPath !== info.dir) {
return [fullPath];
}
return prev.includes(fullPath) ? prev : [fullPath];
});
const entry = await ensureEntry(info.fullPath, info.name);
openContextMenu({ preventDefault() {}, clientX: x, clientY: y } as React.MouseEvent, entry);
}, [actionPath, ensureEntry, itemByPath, openContextMenu]);
const selectedNames = useMemo(() => {
const names: string[] = [];
for (const p of selectedPaths) {
@@ -308,7 +322,7 @@ export function useFileSearch({
openResult,
selectResult,
openResultContextMenu,
openResultContextMenuAt,
clearSelection,
};
}

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import { authApi } from '../api/auth';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography;
@@ -13,6 +14,7 @@ export default function ForgotPasswordPage() {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false);
const { isMobile } = useResponsive();
const handleSubmit = async (values: { email: string }) => {
setSubmitting(true);
@@ -29,12 +31,12 @@ export default function ForgotPasswordPage() {
return (
<div style={{
minHeight: '100vh',
minHeight: '100dvh',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 16px',
padding: isMobile ? '72px 12px 20px' : '48px 16px',
position: 'relative'
}}>
<div style={{ position: 'absolute', top: 16, right: 16 }}>
@@ -48,7 +50,7 @@ export default function ForgotPasswordPage() {
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
border: '1px solid rgba(99,102,241,0.12)',
}}
styles={{ body: { padding: '40px 36px' } }}
styles={{ body: { padding: isMobile ? '24px 18px' : '40px 36px' } }}
>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{

View File

@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
import WeChatModal from '../components/WeChatModal';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography;
@@ -20,6 +21,7 @@ export default function LoginPage() {
const [wechatModalOpen, setWechatModalOpen] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const { isMobile } = useResponsive();
const handleSubmit = async () => {
const u = username.trim();
@@ -28,14 +30,12 @@ export default function LoginPage() {
setErr(t('Please enter username and password'));
return;
}
console.debug('[LoginPage] submit ->', { username: u, passwordLength: p.length });
setErr('');
setLoading(true);
try {
await login(u, p);
navigate('/');
} catch (e: any) {
console.error('[LoginPage] login failed:', e);
setErr(e.message || t('Login failed'));
} finally {
setLoading(false);
@@ -43,48 +43,60 @@ export default function LoginPage() {
};
return (
<div style={{
display: 'flex',
width: '100vw',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
}}>
<div
style={{
display: 'flex',
width: '100vw',
minHeight: '100dvh',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? '72px 12px 20px' : '24px',
boxSizing: 'border-box',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
}}
>
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
<LanguageSwitcher />
</div>
<div style={{
display: 'flex',
width: '80%',
maxWidth: '1200px',
height: '70%',
maxHeight: '700px',
backgroundColor: 'var(--ant-color-bg-container, #fff)',
borderRadius: '20px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
backdropFilter: 'blur(5px)',
border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
overflow: 'hidden'
}}>
<div style={{
width: '50%',
<div
style={{
width: '100%',
maxWidth: isMobile ? 420 : 1200,
minHeight: isMobile ? 'auto' : '70vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px'
}}>
<div style={{ width: 360 }}>
flexDirection: isMobile ? 'column' : 'row',
borderRadius: 20,
background: 'rgba(255,255,255,0.74)',
backdropFilter: 'blur(16px)',
border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
overflow: 'hidden',
}}
>
<div
style={{
width: isMobile ? '100%' : '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? '24px 18px' : '48px',
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}>{t('Welcome Back')}</Title>
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)', textAlign: 'center' }}>
{t('Welcome Back')}
</Title>
</div>
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>{t('Sign in to your Foxel account')}</Text>
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
{t('Sign in to your Foxel account')}
</Text>
</div>
{err && <Alert message={err} type="error" showIcon style={{ marginBottom: 24 }} />}
{err && <Alert message={err} type="error" showIcon style={{ marginBottom: 8 }} />}
<Form onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item>
@@ -92,7 +104,7 @@ export default function LoginPage() {
prefix={<UserOutlined />}
placeholder={t('Username / Email')}
value={username}
onChange={e => setUsername(e.target.value)}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Form.Item>
@@ -102,7 +114,7 @@ export default function LoginPage() {
prefix={<LockOutlined />}
placeholder={t('Password')}
value={password}
onChange={e => setPassword(e.target.value)}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Item>
@@ -114,12 +126,7 @@ export default function LoginPage() {
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={{ width: '100%' }}
>
<Button type="primary" htmlType="submit" loading={loading} style={{ width: '100%' }}>
{t('Sign In')}
</Button>
</Form.Item>
@@ -133,58 +140,63 @@ export default function LoginPage() {
</Space>
</div>
</div>
<div style={{
width: '50%',
backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
backgroundImage: `radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)`,
backgroundSize: '16px 16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px'
}}>
<div style={{ maxWidth: '500px' }}>
<Title level={3}>{t('Your next-generation file manager')}</Title>
<Text type="secondary" style={{ fontSize: '16px', lineHeight: '1.8' }}>
Foxel 访
</Text>
<div style={{ marginTop: '32px' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Cross-platform sync, access anywhere')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('AI-powered search for quick find')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Flexible sharing and collaboration')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Powerful automation to simplify tasks')}</Text>
</Space>
</Card>
</Space>
</div>
<div style={{ marginTop: '48px', textAlign: 'center' }}>
<Text type="secondary">{t('Join our community:')}</Text>
<Button type="text" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank">GitHub</Button>
<Button type="text" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank">Telegram</Button>
<Button type="text" icon={<WechatOutlined />} onClick={() => setWechatModalOpen(true)}></Button>
{!isMobile && (
<div
style={{
width: '50%',
backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
backgroundImage: 'radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)',
backgroundSize: '16px 16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px',
}}
>
<div style={{ maxWidth: 500 }}>
<Title level={3}>{t('Your next-generation file manager')}</Title>
<Text type="secondary" style={{ fontSize: 16, lineHeight: '1.8' }}>
Foxel 访
</Text>
<div style={{ marginTop: 32 }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<CloudSyncOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Cross-platform sync, access anywhere')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<SearchOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('AI-powered search for quick find')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<ShareAltOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Flexible sharing and collaboration')}</Text>
</Space>
</Card>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
<Space>
<ApartmentOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Text>{t('Powerful automation to simplify tasks')}</Text>
</Space>
</Card>
</Space>
</div>
<div style={{ marginTop: 48, textAlign: 'center' }}>
<Text type="secondary">{t('Join our community:')}</Text>
<Button type="text" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank">GitHub</Button>
<Button type="text" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank">Telegram</Button>
<Button type="text" icon={<WechatOutlined />} onClick={() => setWechatModalOpen(true)}></Button>
</div>
</div>
</div>
</div>
)}
</div>
<WeChatModal open={wechatModalOpen} onClose={() => setWechatModalOpen(false)} />
</div>

View File

@@ -161,6 +161,7 @@ const OfflineDownloadPage = memo(function OfflineDownloadPage() {
dataSource={tasks}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
locale={{ emptyText: t('No offline download tasks') }}
rowKey="id"
style={{ marginBottom: 0 }}

View File

@@ -521,7 +521,7 @@ const PluginsPage = memo(function PluginsPage() {
};
return (
<div style={{ height: 'calc(100vh - 88px)', display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<Upload
accept=".foxpkg"

View File

@@ -554,9 +554,9 @@ const ProcessorsPage = memo(function ProcessorsPage() {
return (
<>
{contextHolder}
<Flex gap={16} style={{ height: 'calc(100vh - 88px)' }}>
<Flex gap={16} style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Card
style={{ flex: '0 0 320px', minWidth: 280, display: 'flex', flexDirection: 'column' }}
style={{ flex: '0 0 320px', minWidth: 280, minHeight: 0, display: 'flex', flexDirection: 'column' }}
title={t('Processor List')}
extra={
<Space size={8}>
@@ -564,13 +564,13 @@ const ProcessorsPage = memo(function ProcessorsPage() {
<Button size="small" onClick={handleReloadProcessors} loading={reloading}>{t('Reload')}</Button>
</Space>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex' } }}
styles={{ body: { padding: 0, flex: 1, minHeight: 0, display: 'flex' } }}
>
{renderProcessorList()}
</Card>
<Card
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}
style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}
title={selectedProcessorMeta ? `${selectedProcessorMeta.name} (${selectedProcessorMeta.type})` : t('Select a processor')}
extra={
<Space size={8}>
@@ -582,7 +582,7 @@ const ProcessorsPage = memo(function ProcessorsPage() {
</Button>
</Space>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
styles={{ body: { padding: 0, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }}
>
<Tabs
activeKey={activeTab}

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext';
import { useNavigate, Navigate } from 'react-router';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography;
@@ -14,6 +15,7 @@ export default function RegisterPage() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const { isMobile } = useResponsive();
if (isAuthenticated) {
return <Navigate to="/" replace />;
@@ -39,19 +41,23 @@ export default function RegisterPage() {
};
return (
<div style={{
display: 'flex',
width: '100vw',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
}}>
<div
style={{
display: 'flex',
width: '100vw',
minHeight: '100dvh',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? '72px 12px 20px' : '24px',
boxSizing: 'border-box',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
}}
>
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
<LanguageSwitcher />
</div>
<Card style={{ width: 420 }}>
<Card style={{ width: '100%', maxWidth: 420 }} styles={{ body: { padding: isMobile ? '20px 16px' : '24px' } }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Title level={2} style={{ marginBottom: 8 }}>{t('Create Account')}</Title>
@@ -61,11 +67,7 @@ export default function RegisterPage() {
{err && <Alert message={err} type="error" showIcon />}
<Form layout="vertical" size="large" onFinish={onFinish}>
<Form.Item
label={t('Username')}
name="username"
rules={[{ required: true, message: t('Please input username!') }]}
>
<Form.Item label={t('Username')} name="username" rules={[{ required: true, message: t('Please input username!') }]}>
<Input prefix={<UserOutlined />} />
</Form.Item>
@@ -80,18 +82,11 @@ export default function RegisterPage() {
<Input prefix={<MailOutlined />} />
</Form.Item>
<Form.Item
label={t('Full Name')}
name="full_name"
>
<Form.Item label={t('Full Name')} name="full_name">
<Input prefix={<UserOutlined />} />
</Form.Item>
<Form.Item
label={t('Password')}
name="password"
rules={[{ required: true, message: t('Please enter password') }]}
>
<Form.Item label={t('Password')} name="password" rules={[{ required: true, message: t('Please enter password') }]}>
<Input.Password prefix={<LockOutlined />} />
</Form.Item>
@@ -133,4 +128,3 @@ export default function RegisterPage() {
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router';
import { authApi } from '../api/auth';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography;
@@ -19,6 +20,7 @@ export default function ResetPasswordPage() {
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const { isMobile } = useResponsive();
useEffect(() => {
if (!token) {
@@ -58,7 +60,7 @@ export default function ResetPasswordPage() {
if (error) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px' }}>
<Result
status="error"
title={t('Reset failed')}
@@ -75,12 +77,12 @@ export default function ResetPasswordPage() {
return (
<div style={{
minHeight: '100vh',
minHeight: '100dvh',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 16px',
padding: isMobile ? '72px 12px 20px' : '48px 16px',
position: 'relative'
}}>
<div style={{ position: 'absolute', top: 16, right: 16 }}>
@@ -94,7 +96,7 @@ export default function ResetPasswordPage() {
border: '1px solid rgba(99,102,241,0.14)',
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
}}
bodyStyle={{ padding: '40px 36px' }}
styles={{ body: { padding: isMobile ? '24px 18px' : '40px 36px' } }}
>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{

View File

@@ -359,7 +359,7 @@ const SetupPage = () => {
<div style={{
display: 'flex',
width: '100%',
minHeight: '100vh',
minHeight: '100dvh',
alignItems: isMobile ? 'flex-start' : 'center',
justifyContent: 'center',
padding: isMobile ? '64px 12px 24px' : '32px 24px',

View File

@@ -111,7 +111,7 @@ const SharePage = memo(function SharePage() {
<PageCard
title={t('My Shares')}
extra={
<Space>
<Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Popconfirm title={t('Confirm clear expired shares?')} onConfirm={handleClearExpired}>
<Button danger>{t('Clear expired shares')}</Button>
@@ -125,6 +125,7 @@ const SharePage = memo(function SharePage() {
columns={columns as any}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
/>
</PageCard>
);

View File

@@ -6,6 +6,7 @@ import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOu
import { useTheme } from '../../contexts/ThemeContext';
import '../../styles/settings-tabs.css';
import { useI18n } from '../../i18n';
import useResponsive from '../../hooks/useResponsive';
import AppearanceSettingsTab from './components/AppearanceSettingsTab';
import AppSettingsTab from './components/AppSettingsTab';
import AiSettingsTab from './components/AiSettingsTab';
@@ -51,6 +52,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
);
const { refreshTheme } = useTheme();
const { t } = useI18n();
const { isMobile } = useResponsive();
useEffect(() => {
getAllConfig()
@@ -64,7 +66,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
});
}, [t]);
const handleSave = async (values: Record<string, unknown>) => {
const handleSave = async (values: Record<string, unknown>): Promise<boolean> => {
setLoading(true);
try {
for (const [key, value] of Object.entries(values)) {
@@ -79,10 +81,13 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
await refreshTheme();
}
return true;
} catch (e: any) {
message.error(e.message || t('Save failed'));
return false;
} finally {
setLoading(false);
}
setLoading(false);
};
// 离开“外观设置”时,恢复后端持久化配置(取消未保存的预览)
@@ -132,7 +137,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
className="fx-settings-tabs"
activeKey={activeTab}
onChange={handleTabChange}
centered
centered={!isMobile}
items={[
{
key: 'appearance',

View File

@@ -2,6 +2,7 @@ import { Alert, Button, Divider, Form, Input, Select, Switch, message } from 'an
import { useEffect, useMemo, useState } from 'react';
import { rolesApi, type RoleInfo } from '../../../api/roles';
import { useI18n } from '../../../i18n';
import { normalizeLang, readStoredLang } from '../../../i18n/lang';
interface AppConfigKey {
key: string;
@@ -12,7 +13,7 @@ interface AppConfigKey {
interface AppSettingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
configKeys: AppConfigKey[];
}
@@ -22,7 +23,7 @@ export default function AppSettingsTab({
onSave,
configKeys,
}: AppSettingsTabProps) {
const { t } = useI18n();
const { t, setLang } = useI18n();
const [rolesLoading, setRolesLoading] = useState(false);
const [roles, setRoles] = useState<RoleInfo[]>([]);
@@ -52,6 +53,7 @@ export default function AppSettingsTab({
const roleId = roleIdRaw ? Number(roleIdRaw) : undefined;
return {
...Object.fromEntries(configKeys.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
APP_DEFAULT_LANGUAGE: normalizeLang(config.APP_DEFAULT_LANGUAGE, 'zh'),
AUTH_ALLOW_REGISTER: allowRegister,
AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined,
};
@@ -66,12 +68,17 @@ export default function AppSettingsTab({
for (const { key } of configKeys) {
payload[key] = vals[key];
}
const defaultLanguage = normalizeLang(vals.APP_DEFAULT_LANGUAGE, 'zh');
payload.APP_DEFAULT_LANGUAGE = defaultLanguage;
const allow = !!vals.AUTH_ALLOW_REGISTER;
payload.AUTH_ALLOW_REGISTER = allow ? 'true' : 'false';
if (allow) {
payload.AUTH_DEFAULT_REGISTER_ROLE_ID = String(vals.AUTH_DEFAULT_REGISTER_ROLE_ID);
}
await onSave(payload);
const saved = await onSave(payload);
if (saved && !readStoredLang()) {
setLang(defaultLanguage, { persist: false });
}
}}
style={{ marginTop: 24 }}
key={JSON.stringify(config)}
@@ -82,6 +89,20 @@ export default function AppSettingsTab({
</Form.Item>
))}
<Form.Item
name="APP_DEFAULT_LANGUAGE"
label={t('Default Language')}
extra={t('Used when the user has not selected a language')}
>
<Select
size="large"
options={[
{ value: 'zh', label: t('Chinese') },
{ value: 'en', label: t('English') },
]}
/>
</Form.Item>
<Divider titlePlacement="left">{t('Registration Settings')}</Divider>
<Alert

View File

@@ -13,7 +13,7 @@ interface ThemeKeyMap {
interface AppearanceSettingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
themeKeys: ThemeKeyMap;
}

View File

@@ -28,7 +28,7 @@ import {
interface EmailSettingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
}
interface EmailFormValues {

View File

@@ -5,7 +5,7 @@ import { useI18n } from '../../../i18n';
interface ProtocolMappingsTabProps {
config: Record<string, string>;
loading: boolean;
onSave: (values: Record<string, unknown>) => Promise<void>;
onSave: (values: Record<string, unknown>) => Promise<boolean>;
}
const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';

View File

@@ -287,6 +287,7 @@ const TaskQueuePage = memo(function TaskQueuePage() {
columns={columns}
loading={loading}
pagination={{ pageSize: 10 }}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }}
/>
</PageCard>

View File

@@ -153,7 +153,7 @@ const TasksPage = memo(function TasksPage() {
<PageCard
title={t('Automation Tasks')}
extra={
<Space>
<Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Task')}</Button>
</Space>
@@ -165,6 +165,7 @@ const TasksPage = memo(function TasksPage() {
columns={columns as any}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }}
/>
<Drawer

View File

@@ -11,6 +11,7 @@ import {
} from '../../api/roles';
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
import { useI18n } from '../../i18n';
import useResponsive from '../../hooks/useResponsive';
import { RolesTable } from './components/RolesTable';
import { RoleEditorDrawer } from './components/RoleEditorDrawer';
import { PathRuleEditorDrawer } from './components/PathRuleEditorDrawer';
@@ -23,6 +24,7 @@ type TabKey = 'users' | 'roles';
const UsersPage = memo(function UsersPage() {
const { t } = useI18n();
const { isMobile } = useResponsive();
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>('users');
const [searchText, setSearchText] = useState('');
@@ -462,13 +464,13 @@ const UsersPage = memo(function UsersPage() {
<PageCard
title={t('User Management')}
extra={
<Space>
<Space wrap>
<Input.Search
allowClear
value={searchText}
placeholder={t('Search users or roles')}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 260 }}
style={{ width: isMobile ? '100%' : 260 }}
/>
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}>

View File

@@ -62,8 +62,8 @@ export const RolesTable = memo(function RolesTable({
columns={columns}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }}
/>
);
});

View File

@@ -78,8 +78,8 @@ export const UsersTable = memo(function UsersTable({
columns={columns}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }}
/>
);
});

View File

@@ -7,12 +7,14 @@ import type { PluginItem } from './api/plugins';
import { pluginsApi } from './api/plugins';
import request from './api/client';
import { vfsApi, type VfsEntry } from './api/vfs';
import { parseLang } from './i18n/lang';
type FrameMode = 'file' | 'app';
type FrameQuery = {
pluginKey: string;
mode: FrameMode;
lang: string;
filePath: string;
pluginVersion: string;
pluginStyles: string[] | null;
@@ -65,6 +67,7 @@ function getQuery(): FrameQuery {
const params = new URLSearchParams(window.location.search);
const pluginKey = (params.get('pluginKey') || '').trim();
const mode = (params.get('mode') || 'file') as FrameMode;
const lang = (params.get('lang') || '').trim();
const filePath = (params.get('filePath') || '').trim();
const pluginVersion = (params.get('pluginVersion') || '').trim();
@@ -88,7 +91,7 @@ function getQuery(): FrameQuery {
}
: null;
return { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry };
return { pluginKey, mode, lang, filePath, pluginVersion, pluginStyles, entry };
}
function postToParent(data: any) {
@@ -279,9 +282,14 @@ async function buildFileContext(filePath: string, entryOverride: VfsEntry | null
}
async function main() {
const query = getQuery();
const frameLang = parseLang(query.lang);
if (frameLang) {
document.documentElement.lang = frameLang;
}
initExternals();
const { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry } = getQuery();
const { pluginKey, mode, filePath, pluginVersion, pluginStyles, entry } = query;
if (!pluginKey) {
renderStatus('Missing pluginKey in query string', true);
return;

View File

@@ -21,8 +21,7 @@ import { pluginsApi } from '../api/plugins';
// 类型定义
import type { VfsEntry, DirListing } from '../api/client';
import type { PluginItem } from '../api/plugins';
type Lang = 'zh' | 'en';
import { getActiveLang, normalizeLang, type Lang } from '../i18n/lang';
type Dict = Record<string, string>;
type Dicts = Partial<Record<Lang, Dict>>;
@@ -197,10 +196,8 @@ declare global {
* 初始化并暴露外部依赖
*/
export function initExternals(): void {
const normalizeLang = (raw: unknown): Lang => (raw === 'en' ? 'en' : 'zh');
const i18nApi = {
getLang: () => normalizeLang(localStorage.getItem('lang')),
getLang: () => getActiveLang(),
subscribe: (cb: (lang: Lang) => void) => {
const handler = (e: Event) => {
const lang = (e as CustomEvent)?.detail?.lang as Lang;

View File

@@ -18,40 +18,93 @@ import UsersPage from '../pages/UsersPage/UsersPage.tsx';
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
import AiAgentWidget from '../components/AiAgentWidget';
import useResponsive from '../hooks/useResponsive';
const ShellBody = memo(function ShellBody() {
const params = useParams<{ navKey?: string; '*': string }>();
const navKey = params.navKey ?? 'files';
const subPath = params['*'] ?? '';
const navigate = useNavigate();
const { isMobile } = useResponsive();
const COLLAPSED_KEY = 'layout.siderCollapsed';
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(COLLAPSED_KEY) === '1');
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [agentOpen, setAgentOpen] = useState(false);
useEffect(() => {
localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
}, [collapsed]);
useEffect(() => {
setMobileNavOpen(false);
}, [isMobile, navKey, subPath]);
const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows();
const settingsTab = navKey === 'settings' ? (subPath.split('/')[0] || undefined) : undefined;
const agentCurrentPath = navKey === 'files' ? ('/' + subPath).replace(/\/+/g, '/').replace(/\/+$/, '') || '/' : null;
const handleToggleNav = () => {
if (isMobile) {
setMobileNavOpen(true);
return;
}
setCollapsed((value) => !value);
};
return (
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
<SideNav
collapsed={collapsed}
onToggle={() => setCollapsed(c => !c)}
activeKey={navKey}
onChange={(key) => {
if (key === 'settings') {
navigate('/settings/appearance', { replace: true });
} else {
navigate(`/${key}`);
}
}}
/>
<Layout style={{ background: 'var(--ant-color-bg-layout)' }}>
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} onOpenAiAgent={() => setAgentOpen(true)} />
<Layout.Content style={{ padding: 16, background: 'var(--ant-color-bg-layout)' }}>
<div style={{ minHeight: 'calc(100vh - 56px - 32px)', background: 'var(--ant-color-bg-layout)' }}>
<Flex vertical gap={16}>
<Layout style={{ height: '100dvh', overflow: 'hidden', background: 'var(--ant-color-bg-layout)' }}>
{!isMobile && (
<SideNav
collapsed={collapsed}
onToggle={handleToggleNav}
activeKey={navKey}
onChange={(key) => {
if (key === 'settings') {
navigate('/settings/appearance', { replace: true });
} else {
navigate(`/${key}`);
}
}}
/>
)}
{isMobile && (
<SideNav
mobile
open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
collapsed={false}
onToggle={handleToggleNav}
activeKey={navKey}
onChange={(key) => {
if (key === 'settings') {
navigate('/settings/appearance', { replace: true });
} else {
navigate(`/${key}`);
}
}}
/>
)}
<Layout style={{ background: 'var(--ant-color-bg-layout)', minWidth: 0, minHeight: 0, overflow: 'hidden' }}>
<TopHeader
collapsed={collapsed}
onToggle={handleToggleNav}
onOpenAiAgent={() => setAgentOpen(true)}
showMenuButton={isMobile || collapsed}
/>
<Layout.Content
style={{
flex: 1,
padding: isMobile ? 12 : 16,
background: 'var(--ant-color-bg-layout)',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
}}
>
<div style={{ flex: 1, minHeight: 0, background: 'var(--ant-color-bg-layout)', overflow: 'hidden' }}>
<Flex vertical style={{ minHeight: 0, height: '100%' }}>
{navKey === 'adapters' && <AdaptersPage />}
{navKey === 'files' && <FileExplorerPage />}
{navKey === 'share' && <SharePage />}
@@ -61,10 +114,7 @@ const ShellBody = memo(function ShellBody() {
{navKey === 'offline' && <OfflineDownloadPage />}
{navKey === 'plugins' && <PluginsPage />}
{navKey === 'settings' && (
<SystemSettingsPage
tabKey={settingsTab}
onTabNavigate={(key, options) => navigate(`/settings/${key}`, options)}
/>
<SystemSettingsPage tabKey={settingsTab} onTabNavigate={(key, options) => navigate(`/settings/${key}`, options)} />
)}
{navKey === 'audit' && <AuditLogsPage />}
{navKey === 'backup' && <BackupPage />}
@@ -73,7 +123,7 @@ const ShellBody = memo(function ShellBody() {
</div>
</Layout.Content>
</Layout>
{/* 常驻渲染应用窗口(过滤最小化在内部处理) */}
<AppWindowsLayer
windows={windows}
onClose={closeWindow}

View File

@@ -46,3 +46,22 @@ html[data-theme='light'] .fx-settings-tabs .ant-tabs-tab-active .ant-tabs-tab-bt
.fx-settings-tabs .ant-tabs-ink-bar {
background: var(--ant-color-primary) !important;
}
@media (max-width: 767px) {
.fx-settings-tabs .ant-tabs-nav {
overflow-x: auto;
}
.fx-settings-tabs .ant-tabs-nav-list {
width: max-content;
min-width: 100%;
flex-wrap: nowrap;
padding: 2px 4px;
}
.fx-settings-tabs .ant-tabs-tab {
flex: 0 0 auto;
justify-content: flex-start;
white-space: nowrap;
}
}