mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 08:42:56 +08:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8957174e6f | ||
|
|
abb6b0ce22 | ||
|
|
74df438053 | ||
|
|
f271a8bee5 | ||
|
|
17236e601f | ||
|
|
71e5f84eb7 | ||
|
|
4e724b9c4a | ||
|
|
ba62bd0d4a | ||
|
|
138296e5a6 | ||
|
|
51326dea08 | ||
|
|
ac6d8ff7ad | ||
|
|
029aa2574d | ||
|
|
eeb0e6aa70 | ||
|
|
d1ceb7ddba | ||
|
|
63b54458e9 | ||
|
|
f7e6815265 | ||
|
|
4d6e0b86ad | ||
|
|
77a4749fec | ||
|
|
8eaa025f7e | ||
|
|
11799cd97c | ||
|
|
c14224827d | ||
|
|
130a304f25 | ||
|
|
bc595310a6 | ||
|
|
bf83187d8c | ||
|
|
02cc31d296 | ||
|
|
c66ca181c6 | ||
|
|
5815e6a545 | ||
|
|
7cf335ab19 | ||
|
|
36365d7410 | ||
|
|
90ddeef027 | ||
|
|
8ac3acebb4 | ||
|
|
5625f2d8bf | ||
|
|
7f33eb85ba | ||
|
|
0da64b8d9c | ||
|
|
7caa602d93 | ||
|
|
a4af9475ef | ||
|
|
ee6e570ccb | ||
|
|
ce45fca8bd | ||
|
|
77058f3535 | ||
|
|
738f3c9718 | ||
|
|
f3d9220569 | ||
|
|
da41393db3 | ||
|
|
0399011406 | ||
|
|
00462f2259 | ||
|
|
f0892ebcd6 | ||
|
|
cf5f19043b | ||
|
|
6444ed264c | ||
|
|
bed8c8b19c | ||
|
|
37e13dabe0 | ||
|
|
9d6c63aff4 | ||
|
|
81095f11df | ||
|
|
7d35c10d71 | ||
|
|
17ebb8d4f4 | ||
|
|
330e8fd72b | ||
|
|
11c717e61d | ||
|
|
45d63febb9 | ||
|
|
5a29c579dc | ||
|
|
b530b16c53 | ||
|
|
7da49191aa | ||
|
|
fbeb673126 | ||
|
|
0a06f4d02c | ||
|
|
f02c29492b | ||
|
|
1a79e87887 | ||
|
|
626ff727b3 | ||
|
|
117a94d793 | ||
|
|
c39bea67a4 | ||
|
|
2cbfb29260 | ||
|
|
155f3a144d | ||
|
|
208a52589f | ||
|
|
0732b611a9 | ||
|
|
7b25e6d3b6 | ||
|
|
04441d0bc4 | ||
|
|
917b542dab | ||
|
|
e43b68beda | ||
|
|
801ff26cc7 | ||
|
|
284c2d24a2 | ||
|
|
a34be25ec0 | ||
|
|
db2e02dd32 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: https://foxel.cc/sponsor.html
|
||||
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
75
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Bug Report / 缺陷报告
|
||||
description: Report reproducible defects with clear context / 请提供可复现的缺陷信息
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping us improve Foxel! / 感谢你帮助改进 Foxel!
|
||||
Please confirm the checklist below before filing. / 在提交前请确认以下事项。
|
||||
- type: checkboxes
|
||||
id: validations
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and docs / 我已搜索现有 Issue 与文档
|
||||
required: true
|
||||
- label: This is not a question or feature request / 这不是问题咨询或功能需求
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Bug Summary / 缺陷摘要
|
||||
description: Briefly describe what is wrong / 简要说明出现了什么问题
|
||||
placeholder: e.g. Upload fails with 500 error / 例如:上传时报 500 错误
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce / 复现步骤
|
||||
description: List numbered steps to trigger the bug / 列出触发问题的步骤
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior / 预期行为
|
||||
description: What should happen instead? / 期望看到什么结果?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
description: What actually happens? Include messages or screenshots / 实际发生了什么?可附报错或截图
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本信息
|
||||
description: Git commit, tag, or build number / 提供 Git 提交、标签或构建号
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment / 运行环境
|
||||
description: OS, browser, API server config, etc. / 操作系统、浏览器、服务端配置等
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs & Attachments / 日志与附件
|
||||
description: Paste relevant logs, stack traces, screenshots / 粘贴相关日志、堆栈或截图
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Feature Request / 功能需求
|
||||
description: Suggest enhancements or new capabilities / 提出改进或新增能力
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Tell us about your idea! / 欢迎分享你的想法!
|
||||
Please complete the sections below so we can evaluate it quickly. / 请完整填写以下信息,便于快速评估。
|
||||
- type: checkboxes
|
||||
id: prechecks
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and roadmap / 我已搜索现有 Issue 与路线图
|
||||
required: true
|
||||
- label: This is not a bug report or question / 这不是缺陷或问题咨询
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Feature Summary / 功能概述
|
||||
description: What do you want to build? / 希望新增什么能力?
|
||||
placeholder: e.g. Support sharing download links / 例如:支持分享下载链接
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / 背景与价值
|
||||
description: Why is this feature important? Who benefits? / 为什么重要?受益者是谁?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: Outline how the feature might work, including API or UI hints / 描述可能的实现方式,包含 API 或 UI 提示
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives / 可选方案
|
||||
description: List any alternatives considered / 如有考虑过其他方案请列出
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: Diagrams, sketches, links, constraints, etc. / 可附上草图、链接或约束
|
||||
validations:
|
||||
required: false
|
||||
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Question / 问题咨询
|
||||
description: Ask about usage, configuration, or clarification / 用于使用、配置或澄清问题
|
||||
title: "[Question] "
|
||||
labels:
|
||||
- question
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Need help? You're in the right place. / 需要帮助?请按以下提示填写。
|
||||
Check the docs before filing. / 提交前请先查阅文档。
|
||||
- type: checkboxes
|
||||
id: prechecks
|
||||
attributes:
|
||||
label: Pre-flight Check / 提交前检查
|
||||
options:
|
||||
- label: I searched existing issues and discussions / 我已搜索现有 Issue 和讨论
|
||||
required: true
|
||||
- label: I read the relevant documentation / 我已阅读相关文档
|
||||
required: true
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question Details / 问题详情
|
||||
description: What do you need help with? Be specific. / 具体说明需要帮助的内容
|
||||
placeholder: Describe the scenario, expectation, and blockers / 说明场景、期望结果与阻碍
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: What You Tried / 已尝试方案
|
||||
description: List commands, configs, or steps attempted / 列出尝试过的命令、配置或步骤
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: Environment details, logs, screenshots / 可补充运行环境、日志或截图
|
||||
validations:
|
||||
required: false
|
||||
51
.github/workflows/docker-clean.yml
vendored
Normal file
51
.github/workflows/docker-clean.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Clean dangling Docker images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker-clean:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Delete untagged GHCR versions
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER}"
|
||||
PACKAGE="$(echo "${GITHUB_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
OWNER_TYPE="$(gh api "/users/${OWNER}" -q '.type')"
|
||||
if [[ "${OWNER_TYPE}" == "Organization" ]]; then
|
||||
SCOPE="orgs/${OWNER}"
|
||||
else
|
||||
SCOPE="users/${OWNER}"
|
||||
fi
|
||||
|
||||
BASE_PATH="/${SCOPE}/packages/container/${PACKAGE}"
|
||||
|
||||
if ! gh api "${BASE_PATH}" >/dev/null 2>&1; then
|
||||
echo "Package ghcr.io/${OWNER}/${PACKAGE} not found or accessible. Nothing to clean."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mapfile -t VERSION_IDS < <(gh api --paginate "${BASE_PATH}/versions?per_page=100" \
|
||||
-q '.[] | select(.metadata.container.tags | length == 0) | .id')
|
||||
|
||||
if [[ ${#VERSION_IDS[@]} -eq 0 ]]; then
|
||||
echo "No untagged versions to delete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deleting ${#VERSION_IDS[@]} untagged versions from ghcr.io/${OWNER}/${PACKAGE}..."
|
||||
for id in "${VERSION_IDS[@]}"; do
|
||||
gh api -X DELETE "${BASE_PATH}/versions/${id}" >/dev/null
|
||||
echo "Deleted version ${id}"
|
||||
done
|
||||
|
||||
echo "Cleanup complete."
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -2,6 +2,8 @@ name: Build and Push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
@@ -48,4 +50,4 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
|
||||
186
CONTRIBUTING.md
186
CONTRIBUTING.md
@@ -1,76 +1,76 @@
|
||||
<div align="right">
|
||||
<b>English</b> | <a href="./CONTRIBUTING_zh.md">简体中文</a>
|
||||
</div>
|
||||
|
||||
# Contributing to Foxel
|
||||
|
||||
🎉 首先,非常感谢您愿意花时间为 Foxel 做出贡献!
|
||||
We appreciate every minute you spend helping Foxel improve. This guide explains the contribution workflow so you can get started quickly.
|
||||
|
||||
我们热烈欢迎各种形式的贡献。无论是报告 Bug、提出新功能建议、完善文档,还是直接提交代码,都将对项目产生积极的影响。
|
||||
## Table of Contents
|
||||
|
||||
本指南将帮助您顺利地参与到项目中来。
|
||||
|
||||
## 目录
|
||||
|
||||
- [如何贡献](#如何贡献)
|
||||
- [🐛 报告 Bug](#-报告-bug)
|
||||
- [✨ 提交功能建议](#-提交功能建议)
|
||||
- [🛠️ 贡献代码](#️-贡献代码)
|
||||
- [开发环境搭建](#开发环境搭建)
|
||||
- [依赖准备](#依赖准备)
|
||||
- [后端 (FastAPI)](#后端-fastapi)
|
||||
- [前端 (React + Vite)](#前端-react--vite)
|
||||
- [代码贡献指南](#代码贡献指南)
|
||||
- [贡献存储适配器 (Adapter)](#贡献存储适配器-adapter)
|
||||
- [贡献前端应用 (App)](#贡献前端应用-app)
|
||||
- [提交规范](#提交规范)
|
||||
- [Git 分支管理](#git-分支管理)
|
||||
- [Commit Message 格式](#commit-message-格式)
|
||||
- [Pull Request 流程](#pull-request-流程)
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [🐛 Report Bugs](#-report-bugs)
|
||||
- [✨ Suggest Features](#-suggest-features)
|
||||
- [🛠️ Contribute Code](#️-contribute-code)
|
||||
- [Development Environment](#development-environment)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Backend (FastAPI)](#backend-fastapi)
|
||||
- [Frontend (React + Vite)](#frontend-react--vite)
|
||||
- [Contribution Guidelines](#contribution-guidelines)
|
||||
- [Storage Adapters](#storage-adapters)
|
||||
- [Frontend Apps](#frontend-apps)
|
||||
- [Submission Rules](#submission-rules)
|
||||
- [Git Branching](#git-branching)
|
||||
- [Commit Message Format](#commit-message-format)
|
||||
- [Pull Request Flow](#pull-request-flow)
|
||||
|
||||
---
|
||||
|
||||
## 如何贡献
|
||||
## How to Contribute
|
||||
|
||||
### 🐛 报告 Bug
|
||||
### 🐛 Report Bugs
|
||||
|
||||
如果您在使用的过程中发现了 Bug,请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 来报告。请在报告中提供以下信息:
|
||||
If you discover a bug, open a ticket via [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) and include:
|
||||
|
||||
- **清晰的标题**:简明扼要地描述问题。
|
||||
- **复现步骤**:详细说明如何一步步重现该 Bug。
|
||||
- **期望行为** vs **实际行为**:描述您预期的结果和实际发生的情况。
|
||||
- **环境信息**:例如操作系统、浏览器版本、Foxel 版本等。
|
||||
- **A clear title** that summarises the problem.
|
||||
- **Reproduction steps** with enough detail to trigger the bug.
|
||||
- **Expected vs actual behaviour** to highlight the gap.
|
||||
- **Environment details** such as operating system, browser version, and the Foxel build you used.
|
||||
|
||||
### ✨ 提交功能建议
|
||||
### ✨ Suggest Features
|
||||
|
||||
我们欢迎任何关于新功能或改进的建议。请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 创建一个 "Feature Request",并详细阐述您的想法:
|
||||
To propose a new capability or an improvement, create an Issue and choose the "Feature Request" template. Document:
|
||||
|
||||
- **问题描述**:说明该功能要解决什么问题。
|
||||
- **方案设想**:描述您希望该功能如何工作。
|
||||
- **相关信息**:提供任何有助于理解您想法的截图、链接或参考。
|
||||
- **Problem statement** – what pain point will the feature solve?
|
||||
- **Proposed solution** – how you expect it to work.
|
||||
- **Supporting material** – screenshots, references, or related links if helpful.
|
||||
|
||||
### 🛠️ 贡献代码
|
||||
### 🛠️ Contribute Code
|
||||
|
||||
如果您希望直接贡献代码,请参考下面的开发和提交流程。
|
||||
Follow the development setup below before opening a pull request. Keep changes focused and small so they are easier to review.
|
||||
|
||||
## 开发环境搭建
|
||||
## Development Environment
|
||||
|
||||
### 依赖准备
|
||||
### Prerequisites
|
||||
|
||||
- **Git**: 用于版本控制。
|
||||
- **Python**: >= 3.13
|
||||
- **Bun**: 用于前端包管理和脚本运行。
|
||||
Install the following tooling first:
|
||||
|
||||
### 后端 (FastAPI)
|
||||
- **Git** for version control
|
||||
- **Python** 3.13 or newer
|
||||
- **Bun** for frontend package management and scripts
|
||||
|
||||
后端 API 服务基于 Python 和 FastAPI 构建。
|
||||
### Backend (FastAPI)
|
||||
|
||||
1. **克隆仓库**
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DrizzleTime/foxel.git
|
||||
cd Foxel
|
||||
```
|
||||
|
||||
2. **创建并激活 Python 虚拟环境**
|
||||
2. **Create and activate a virtual environment**
|
||||
|
||||
我们推荐使用 `uv` 来管理虚拟环境,以获得最佳性能。
|
||||
`uv` is recommended for performance and reproducibility:
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
@@ -78,91 +78,85 @@
|
||||
# On Windows: .venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **安装依赖**
|
||||
3. **Install dependencies**
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
4. **初始化环境**
|
||||
4. **Prepare local resources**
|
||||
|
||||
在启动服务前,请进行以下准备:
|
||||
- Create the data directory:
|
||||
|
||||
- **创建数据目录**:
|
||||
在项目根目录执行 `mkdir -p data/db`。这将创建用于存放数据库等文件的目录。
|
||||
> [!IMPORTANT]
|
||||
> 请确保应用拥有对 `data/db` 目录的读写权限。
|
||||
```bash
|
||||
mkdir -p data/db
|
||||
```
|
||||
|
||||
- **创建 `.env` 配置文件**:
|
||||
在项目根目录创建名为 `.env` 的文件,并填入以下内容。这些密钥用于保障应用安全,您可以按需修改。
|
||||
Ensure the application user can read and write to `data/db`.
|
||||
|
||||
- Create an `.env` file in the project root and provide the required secrets. Replace the sample values with your own random strings:
|
||||
|
||||
```dotenv
|
||||
SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
```
|
||||
|
||||
5. **启动开发服务器**
|
||||
5. **Start the development server**
|
||||
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
API 服务将在 `http://localhost:8000` 上运行,您可以通过 `http://localhost:8000/docs` 访问自动生成的 API 文档。
|
||||
The API is available at `http://localhost:8000`, and the interactive docs live at `http://localhost:8000/docs`.
|
||||
|
||||
### 前端 (React + Vite)
|
||||
### Frontend (React + Vite)
|
||||
|
||||
前端应用使用 React, Vite, 和 TypeScript 构建。
|
||||
|
||||
1. **进入前端目录**
|
||||
1. **Enter the frontend directory**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **启动开发服务器**
|
||||
3. **Run the dev server**
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
前端开发服务器将在 `http://localhost:5173` 运行。它已经配置了代理,会自动将 `/api` 请求转发到后端服务。
|
||||
The Vite dev server runs at `http://localhost:5173` and proxies `/api` requests to the backend.
|
||||
|
||||
## 代码贡献指南
|
||||
## Contribution Guidelines
|
||||
|
||||
### 贡献存储适配器 (Adapter)
|
||||
### Storage Adapters
|
||||
|
||||
存储适配器是 Foxel 的核心扩展点,用于接入不同的存储后端 (如 S3, FTP, Alist 等)。
|
||||
Storage adapters integrate new storage providers (for example S3, FTP, or Alist).
|
||||
|
||||
1. **创建适配器文件**: 在 [`services/adapters/`](services/adapters/) 目录下,创建一个新文件,例如 `my_new_adapter.py`。
|
||||
2. **实现适配器类**:
|
||||
- 创建一个类,继承自 [`services.adapters.base.BaseAdapter`](services/adapters/base.py)。
|
||||
- 实现 `BaseAdapter` 中定义的所有抽象方法,如 `list_dir`, `get_meta`, `upload`, `download` 等。请仔细阅读基类中的文档注释以理解每个方法的作用和参数。
|
||||
1. Create a new module under [`services/adapters/`](services/adapters/) (for example `my_new_adapter.py`).
|
||||
2. Implement a class that inherits from [`services.adapters.base.BaseAdapter`](services/adapters/base.py) and provide concrete implementations for the abstract methods such as `list_dir`, `get_meta`, `upload`, and `download`.
|
||||
|
||||
### 贡献前端应用 (App)
|
||||
### Frontend Apps
|
||||
|
||||
前端应用允许用户在浏览器中直接预览或编辑特定类型的文件。
|
||||
Frontend apps enable in-browser previews or editors for specific file types.
|
||||
|
||||
1. **创建应用组件**: 在 [`web/src/apps/`](web/src/apps/) 目录下,为您的应用创建一个新的文件夹,并在其中创建 React 组件。
|
||||
2. **定义应用类型**: 您的应用需要实现 [`web/src/apps/types.ts`](web/src/apps/types.ts) 中定义的 `FoxelApp` 接口。
|
||||
3. **注册应用**: 在 [`web/src/apps/registry.ts`](web/src/apps/registry.ts) 中,导入您的应用组件,并将其添加到 `APP_REGISTRY`。在注册时,您需要指定该应用可以处理的文件类型(通过 MIME Type 或文件扩展名)。
|
||||
1. Add a new folder in [`web/src/apps/`](web/src/apps/) for your app and expose a React component.
|
||||
2. Implement the `FoxelApp` interface defined in [`web/src/apps/types.ts`](web/src/apps/types.ts).
|
||||
3. Register the app in [`web/src/apps/registry.ts`](web/src/apps/registry.ts) and declare the MIME types or extensions it supports.
|
||||
|
||||
## 提交规范
|
||||
## Submission Rules
|
||||
|
||||
### Git 分支管理
|
||||
### Git Branching
|
||||
|
||||
- 从最新的 `main` 分支创建您的特性分支。
|
||||
Start your work from the latest `main` branch and push feature changes on a dedicated branch.
|
||||
|
||||
### Commit Message 格式
|
||||
### Commit Message Format
|
||||
|
||||
我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范。这有助于自动化生成更新日志和版本管理。
|
||||
|
||||
Commit Message 格式如下:
|
||||
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification to drive release tooling.
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
@@ -172,27 +166,27 @@ Commit Message 格式如下:
|
||||
<footer>
|
||||
```
|
||||
|
||||
- **type**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` 等。
|
||||
- **scope**: (可选) 本次提交影响的范围,例如 `adapter`, `ui`, `api`。
|
||||
- **subject**: 简明扼要的描述。
|
||||
- **type**: e.g. `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
|
||||
- **scope** (optional): the area impacted by the change, such as `adapter`, `ui`, or `api`.
|
||||
- **subject**: a concise summary written in the imperative mood.
|
||||
|
||||
**示例:**
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
feat(adapter): Add support for Alist storage
|
||||
feat(adapter): add support for Alist storage
|
||||
```
|
||||
|
||||
```
|
||||
fix(ui): Correct display issue in file list view
|
||||
fix(ui): correct display issue in file list view
|
||||
```
|
||||
|
||||
### Pull Request 流程
|
||||
### Pull Request Flow
|
||||
|
||||
1. Fork 仓库并克隆到本地。
|
||||
2. 创建并切换到您的特性分支。
|
||||
3. 完成代码编写和测试。
|
||||
4. 将您的分支推送到您的 Fork 仓库。
|
||||
5. 在 Foxel 主仓库创建一个 Pull Request,目标分支为 `main`。
|
||||
6. 在 PR 描述中清晰地说明您的更改内容、目的和任何相关的 Issue 编号。
|
||||
1. Fork the repository and clone it locally.
|
||||
2. Create and switch to your feature branch.
|
||||
3. Implement the change and run relevant checks.
|
||||
4. Push the branch to your fork.
|
||||
5. Open a pull request against `main` in the Foxel repository.
|
||||
6. Explain the change set, its motivation, and reference related Issues in the PR description.
|
||||
|
||||
项目维护者会尽快审查您的 PR。感谢您的耐心和贡献!
|
||||
Maintainers will review your pull request as soon as possible.
|
||||
|
||||
202
CONTRIBUTING_zh.md
Normal file
202
CONTRIBUTING_zh.md
Normal file
@@ -0,0 +1,202 @@
|
||||
<div align="right">
|
||||
<a href="./CONTRIBUTING.md">English</a> | <b>简体中文</b>
|
||||
</div>
|
||||
|
||||
# Contributing to Foxel
|
||||
|
||||
🎉 首先,非常感谢您愿意花时间为 Foxel 做出贡献!
|
||||
|
||||
我们热烈欢迎各种形式的贡献。无论是报告 Bug、提出新功能建议、完善文档,还是直接提交代码,都将对项目产生积极的影响。
|
||||
|
||||
本指南将帮助您顺利地参与到项目中来。
|
||||
|
||||
## 目录
|
||||
|
||||
- [如何贡献](#如何贡献)
|
||||
- [🐛 报告 Bug](#-报告-bug)
|
||||
- [✨ 提交功能建议](#-提交功能建议)
|
||||
- [🛠️ 贡献代码](#️-贡献代码)
|
||||
- [开发环境搭建](#开发环境搭建)
|
||||
- [依赖准备](#依赖准备)
|
||||
- [后端 (FastAPI)](#后端-fastapi)
|
||||
- [前端 (React + Vite)](#前端-react--vite)
|
||||
- [代码贡献指南](#代码贡献指南)
|
||||
- [贡献存储适配器 (Adapter)](#贡献存储适配器-adapter)
|
||||
- [贡献前端应用 (App)](#贡献前端应用-app)
|
||||
- [提交规范](#提交规范)
|
||||
- [Git 分支管理](#git-分支管理)
|
||||
- [Commit Message 格式](#commit-message-格式)
|
||||
- [Pull Request 流程](#pull-request-流程)
|
||||
|
||||
---
|
||||
|
||||
## 如何贡献
|
||||
|
||||
### 🐛 报告 Bug
|
||||
|
||||
如果您在使用的过程中发现了 Bug,请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 来报告。请在报告中提供以下信息:
|
||||
|
||||
- **清晰的标题**:简明扼要地描述问题。
|
||||
- **复现步骤**:详细说明如何一步步重现该 Bug。
|
||||
- **期望行为** vs **实际行为**:描述您预期的结果和实际发生的情况。
|
||||
- **环境信息**:例如操作系统、浏览器版本、Foxel 版本等。
|
||||
|
||||
### ✨ 提交功能建议
|
||||
|
||||
我们欢迎任何关于新功能或改进的建议。请通过 [GitHub Issues](https://github.com/DrizzleTime/Foxel/issues) 创建一个 "Feature Request",并详细阐述您的想法:
|
||||
|
||||
- **问题描述**:说明该功能要解决什么问题。
|
||||
- **方案设想**:描述您希望该功能如何工作。
|
||||
- **相关信息**:提供任何有助于理解您想法的截图、链接或参考。
|
||||
|
||||
### 🛠️ 贡献代码
|
||||
|
||||
如果您希望直接贡献代码,请参考下面的开发和提交流程。
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
### 依赖准备
|
||||
|
||||
- **Git**: 用于版本控制。
|
||||
- **Python**: >= 3.13
|
||||
- **Bun**: 用于前端包管理和脚本运行。
|
||||
|
||||
### 后端 (FastAPI)
|
||||
|
||||
后端 API 服务基于 Python 和 FastAPI 构建。
|
||||
|
||||
1. **克隆仓库**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DrizzleTime/foxel.git
|
||||
cd Foxel
|
||||
```
|
||||
|
||||
2. **创建并激活 Python 虚拟环境**
|
||||
|
||||
我们推荐使用 `uv` 来管理虚拟环境,以获得最佳性能。
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
# On Windows: .venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **安装依赖**
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
4. **初始化环境**
|
||||
|
||||
在启动服务前,请进行以下准备:
|
||||
|
||||
- **创建数据目录**:
|
||||
在项目根目录执行 `mkdir -p data/db`。这将创建用于存放数据库等文件的目录。
|
||||
> [!IMPORTANT]
|
||||
> 请确保应用拥有对 `data/db` 目录的读写权限。
|
||||
|
||||
- **创建 `.env` 配置文件**:
|
||||
在项目根目录创建名为 `.env` 的文件,并填入以下内容。这些密钥用于保障应用安全,您可以按需修改。
|
||||
|
||||
```dotenv
|
||||
SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
```
|
||||
|
||||
5. **启动开发服务器**
|
||||
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
API 服务将在 `http://localhost:8000` 上运行,您可以通过 `http://localhost:8000/docs` 访问自动生成的 API 文档。
|
||||
|
||||
### 前端 (React + Vite)
|
||||
|
||||
前端应用使用 React, Vite, 和 TypeScript 构建。
|
||||
|
||||
1. **进入前端目录**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **启动开发服务器**
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
前端开发服务器将在 `http://localhost:5173` 运行。它已经配置了代理,会自动将 `/api` 请求转发到后端服务。
|
||||
|
||||
## 代码贡献指南
|
||||
|
||||
### 贡献存储适配器 (Adapter)
|
||||
|
||||
存储适配器是 Foxel 的核心扩展点,用于接入不同的存储后端 (如 S3, FTP, Alist 等)。
|
||||
|
||||
1. **创建适配器文件**: 在 [`services/adapters/`](services/adapters/) 目录下,创建一个新文件,例如 `my_new_adapter.py`。
|
||||
2. **实现适配器类**:
|
||||
- 创建一个类,继承自 [`services.adapters.base.BaseAdapter`](services/adapters/base.py)。
|
||||
- 实现 `BaseAdapter` 中定义的所有抽象方法,如 `list_dir`, `get_meta`, `upload`, `download` 等。请仔细阅读基类中的文档注释以理解每个方法的作用和参数。
|
||||
|
||||
### 贡献前端应用 (App)
|
||||
|
||||
前端应用允许用户在浏览器中直接预览或编辑特定类型的文件。
|
||||
|
||||
1. **创建应用组件**: 在 [`web/src/apps/`](web/src/apps/) 目录下,为您的应用创建一个新的文件夹,并在其中创建 React 组件。
|
||||
2. **定义应用类型**: 您的应用需要实现 [`web/src/apps/types.ts`](web/src/apps/types.ts) 中定义的 `FoxelApp` 接口。
|
||||
3. **注册应用**: 在 [`web/src/apps/registry.ts`](web/src/apps/registry.ts) 中,导入您的应用组件,并将其添加到 `APP_REGISTRY`。在注册时,您需要指定该应用可以处理的文件类型(通过 MIME Type 或文件扩展名)。
|
||||
|
||||
## 提交规范
|
||||
|
||||
### Git 分支管理
|
||||
|
||||
- 从最新的 `main` 分支创建您的特性分支。
|
||||
|
||||
### Commit Message 格式
|
||||
|
||||
我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范。这有助于自动化生成更新日志和版本管理。
|
||||
|
||||
Commit Message 格式如下:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
<BLANK LINE>
|
||||
<body>
|
||||
<BLANK LINE>
|
||||
<footer>
|
||||
```
|
||||
|
||||
- **type**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` 等。
|
||||
- **scope**: (可选) 本次提交影响的范围,例如 `adapter`, `ui`, `api`。
|
||||
- **subject**: 简明扼要的描述。
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
feat(adapter): Add support for Alist storage
|
||||
```
|
||||
|
||||
```
|
||||
fix(ui): Correct display issue in file list view
|
||||
```
|
||||
|
||||
### Pull Request 流程
|
||||
|
||||
1. Fork 仓库并克隆到本地。
|
||||
2. 创建并切换到您的特性分支。
|
||||
3. 完成代码编写和测试。
|
||||
4. 将您的分支推送到您的 Fork 仓库。
|
||||
5. 在 Foxel 主仓库创建一个 Pull Request,目标分支为 `main`。
|
||||
6. 在 PR 描述中清晰地说明您的更改内容、目的和任何相关的 Issue 编号。
|
||||
|
||||
项目维护者会尽快审查您的 PR。感谢您的耐心和贡献!
|
||||
@@ -13,7 +13,9 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx git ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
@@ -27,9 +29,12 @@ COPY . .
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
RUN mkdir -p data/db data/mount && \
|
||||
chmod 777 data/db data/mount
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<blockquote>
|
||||
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
|
||||
</blockquote>
|
||||
<img src="https://foxel.cc/image/ad-min.png" alt="UI Screenshot">
|
||||
</div>
|
||||
|
||||
## 👀 Online Demo
|
||||
@@ -73,7 +74,7 @@ chmod 777 data/db data/mount
|
||||
|
||||
We welcome contributions from the community! Whether it's submitting bugs, suggesting new features, or contributing code directly.
|
||||
|
||||
Before you start, please read our [`CONTRIBUTING.md`](CONTRIBUTING.md) file, which will guide you on how to set up your development environment and the submission process.
|
||||
Before you start, please read our [`CONTRIBUTING.md`](CONTRIBUTING.md) file, which explains the development environment and submission process. A Simplified Chinese translation is available in [`CONTRIBUTING_zh.md`](CONTRIBUTING_zh.md).
|
||||
|
||||
## 🌐 Community
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<em><strong>数据之洋浩瀚无涯,当以洞察之目引航,然其脉络深隐,非表象所能尽窥。</strong></em><br>
|
||||
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
|
||||
</blockquote>
|
||||
<img src="https://foxel.cc/image/ad-min.png" alt="UI Screenshot">
|
||||
</div>
|
||||
|
||||
## 👀 在线体验
|
||||
@@ -74,7 +75,7 @@ chmod 777 data/db data/mount
|
||||
|
||||
我们非常欢迎来自社区的贡献!无论是提交 Bug、建议新功能还是直接贡献代码。
|
||||
|
||||
在开始之前,请先阅读我们的 [`CONTRIBUTING.md`](CONTRIBUTING.md) 文件,它会指导你如何设置开发环境以及提交流程。
|
||||
在开始之前,请先阅读我们的 [`CONTRIBUTING_zh.md`](CONTRIBUTING_zh.md) 文件,它会指导你如何设置开发环境以及提交流程。
|
||||
|
||||
## 🌐 社区
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
||||
from .routes import webdav
|
||||
from .routes import plugins
|
||||
|
||||
|
||||
@@ -17,4 +18,8 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(share.public_router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(vector_db.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(ai_providers.router)
|
||||
app.include_router(plugins.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(offline_downloads.router)
|
||||
app.include_router(email.router)
|
||||
|
||||
177
api/routes/ai_providers.py
Normal file
177
api/routes/ai_providers.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from typing import Annotated, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from api.response import success
|
||||
from schemas.ai import (
|
||||
AIDefaultsUpdate,
|
||||
AIModelCreate,
|
||||
AIModelUpdate,
|
||||
AIProviderCreate,
|
||||
AIProviderUpdate,
|
||||
)
|
||||
from services.ai_providers import AIProviderService
|
||||
from services.auth import User, get_current_active_user
|
||||
from services.vector_db import VectorDBService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
||||
service = AIProviderService()
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_providers(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
providers = await service.list_providers()
|
||||
return success({"providers": providers})
|
||||
|
||||
|
||||
@router.post("/providers")
|
||||
async def create_provider(
|
||||
payload: AIProviderCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
provider = await service.create_provider(payload.dict())
|
||||
return success(provider)
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}")
|
||||
async def get_provider(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
provider = await service.get_provider(provider_id, with_models=True)
|
||||
return success(provider)
|
||||
|
||||
|
||||
@router.put("/providers/{provider_id}")
|
||||
async def update_provider(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
payload: AIProviderUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
data = {k: v for k, v in payload.dict().items() if v is not None}
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
provider = await service.update_provider(provider_id, data)
|
||||
return success(provider)
|
||||
|
||||
|
||||
@router.delete("/providers/{provider_id}")
|
||||
async def delete_provider(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
await service.delete_provider(provider_id)
|
||||
return success({"id": provider_id})
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/sync-models")
|
||||
async def sync_models(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
try:
|
||||
result = await service.sync_models(provider_id)
|
||||
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Failed to synchronize models: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return success(result)
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/remote-models")
|
||||
async def fetch_remote_models(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
try:
|
||||
models = await service.fetch_remote_models(provider_id)
|
||||
except (httpx.RequestError, httpx.HTTPStatusError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Failed to pull models: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return success({"models": models})
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/models")
|
||||
async def list_models(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
models = await service.list_models(provider_id)
|
||||
return success({"models": models})
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/models")
|
||||
async def create_model(
|
||||
provider_id: Annotated[int, Path(..., gt=0)],
|
||||
payload: AIModelCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
model = await service.create_model(provider_id, payload.dict())
|
||||
return success(model)
|
||||
|
||||
|
||||
@router.put("/models/{model_id}")
|
||||
async def update_model(
|
||||
model_id: Annotated[int, Path(..., gt=0)],
|
||||
payload: AIModelUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
data = {k: v for k, v in payload.dict().items() if v is not None}
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
model = await service.update_model(model_id, data)
|
||||
return success(model)
|
||||
|
||||
|
||||
@router.delete("/models/{model_id}")
|
||||
async def delete_model(
|
||||
model_id: Annotated[int, Path(..., gt=0)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
await service.delete_model(model_id)
|
||||
return success({"id": model_id})
|
||||
|
||||
|
||||
def _get_embedding_dimension(entry: Optional[Dict]) -> Optional[int]:
|
||||
if not entry:
|
||||
return None
|
||||
value = entry.get("embedding_dimensions")
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
async def get_defaults(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
defaults = await service.get_default_models()
|
||||
return success(defaults)
|
||||
|
||||
|
||||
@router.put("/defaults")
|
||||
async def update_defaults(
|
||||
payload: AIDefaultsUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
previous = await service.get_default_models()
|
||||
try:
|
||||
updated = await service.set_default_models(payload.as_mapping())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
prev_dim = _get_embedding_dimension(previous.get("embedding"))
|
||||
next_dim = _get_embedding_dimension(updated.get("embedding"))
|
||||
|
||||
if prev_dim and next_dim and prev_dim != next_dim:
|
||||
try:
|
||||
await VectorDBService().clear_all_data()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear vector database: {exc}") from exc
|
||||
|
||||
return success(updated)
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, HTTPException, Depends, Form
|
||||
import hashlib
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from services.auth import (
|
||||
authenticate_user_db,
|
||||
@@ -7,10 +8,17 @@ from services.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
register_user,
|
||||
Token,
|
||||
get_current_active_user,
|
||||
User,
|
||||
request_password_reset,
|
||||
verify_password_reset_token,
|
||||
reset_password_with_token,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from datetime import timedelta
|
||||
from api.response import success
|
||||
from models.database import UserAccount
|
||||
from services.auth import verify_password, get_password_hash
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -21,6 +29,7 @@ class RegisterRequest(BaseModel):
|
||||
email: str | None = None
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
@router.post("/register", summary="注册第一个管理员用户")
|
||||
async def register(data: RegisterRequest):
|
||||
"""
|
||||
@@ -51,3 +60,96 @@ async def login_for_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
|
||||
@router.get("/me", summary="获取当前登录用户信息")
|
||||
async def get_me(current_user: Annotated[User, Depends(get_current_active_user)]):
|
||||
"""
|
||||
返回当前登录用户的基本信息,并附带 gravatar 头像链接。
|
||||
"""
|
||||
email = (current_user.email or "").strip().lower()
|
||||
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
|
||||
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
return success({
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"full_name": current_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
email: str | None = None
|
||||
full_name: str | None = None
|
||||
old_password: str | None = None
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.put("/me", summary="更新当前登录用户信息")
|
||||
async def update_me(
|
||||
payload: UpdateMeRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
db_user = await UserAccount.get_or_none(id=current_user.id)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if payload.email is not None:
|
||||
exists = await UserAccount.filter(email=payload.email).exclude(id=db_user.id).exists()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="邮箱已被占用")
|
||||
db_user.email = payload.email
|
||||
|
||||
if payload.full_name is not None:
|
||||
db_user.full_name = payload.full_name
|
||||
|
||||
if payload.new_password:
|
||||
if not payload.old_password:
|
||||
raise HTTPException(status_code=400, detail="请提供原密码")
|
||||
if not verify_password(payload.old_password, db_user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
db_user.hashed_password = get_password_hash(payload.new_password)
|
||||
|
||||
await db_user.save()
|
||||
|
||||
email = (db_user.email or "").strip().lower()
|
||||
md5_hash = hashlib.md5(email.encode("utf-8")).hexdigest()
|
||||
gravatar_url = f"https://cn.cravatar.com/avatar/{md5_hash}?s=64&d=identicon"
|
||||
return success({
|
||||
"id": db_user.id,
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"full_name": db_user.full_name,
|
||||
"gravatar_url": gravatar_url,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/request", summary="请求密码重置邮件")
|
||||
async def password_reset_request_endpoint(payload: PasswordResetRequest):
|
||||
await request_password_reset(payload.email)
|
||||
return success(msg="如果邮箱存在,将发送重置邮件")
|
||||
|
||||
|
||||
@router.get("/password-reset/verify", summary="校验密码重置令牌")
|
||||
async def password_reset_verify(token: str):
|
||||
user = await verify_password_reset_token(token)
|
||||
return success({
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm", summary="使用令牌重置密码")
|
||||
async def password_reset_confirm(payload: PasswordResetConfirm):
|
||||
await reset_password_with_token(payload.token, payload.password)
|
||||
return success(msg="密码已重置")
|
||||
|
||||
@@ -37,10 +37,13 @@ async def get_all_config(
|
||||
|
||||
@router.get("/status")
|
||||
async def get_system_status():
|
||||
logo = await ConfigCenter.get("APP_LOGO", "/logo.svg")
|
||||
favicon = await ConfigCenter.get("APP_FAVICON", logo)
|
||||
system_info = {
|
||||
"version": VERSION,
|
||||
"title": await ConfigCenter.get("APP_NAME", "Foxel"),
|
||||
"logo": await ConfigCenter.get("APP_LOGO", "/logo.svg"),
|
||||
"logo": logo,
|
||||
"favicon": favicon,
|
||||
"is_initialized": await has_users(),
|
||||
"app_domain": await ConfigCenter.get("APP_DOMAIN"),
|
||||
"file_domain": await ConfigCenter.get("FILE_DOMAIN"),
|
||||
|
||||
92
api/routes/email.py
Normal file
92
api/routes/email.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from services.auth import User, get_current_active_user
|
||||
from services.email import EmailService, EmailTemplateRenderer
|
||||
from schemas.email import EmailTestRequest, EmailTemplateUpdate, EmailTemplatePreviewPayload
|
||||
from api.response import success
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/email",
|
||||
tags=["email"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def trigger_test_email(
|
||||
payload: EmailTestRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
task = await EmailService.enqueue_email(
|
||||
recipients=[str(payload.to)],
|
||||
subject=payload.subject,
|
||||
template=payload.template,
|
||||
context=payload.context,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Triggered email test",
|
||||
details={"task_id": task.id, "template": payload.template, "to": str(payload.to)},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"task_id": task.id})
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_email_templates(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
templates = await EmailTemplateRenderer.list_templates()
|
||||
return success({"templates": templates})
|
||||
|
||||
|
||||
@router.get("/templates/{name}")
|
||||
async def get_email_template(
|
||||
name: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
content = await EmailTemplateRenderer.load(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return success({"name": name, "content": content})
|
||||
|
||||
|
||||
@router.post("/templates/{name}")
|
||||
async def update_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplateUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
await EmailTemplateRenderer.save(name, payload.content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await LogService.action(
|
||||
"route:email",
|
||||
"Updated email template",
|
||||
details={"template": name},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
return success({"name": name})
|
||||
|
||||
|
||||
@router.post("/templates/{name}/preview")
|
||||
async def preview_email_template(
|
||||
name: str,
|
||||
payload: EmailTemplatePreviewPayload,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
try:
|
||||
html = await EmailTemplateRenderer.render(name, payload.context)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return success({"html": html})
|
||||
79
api/routes/offline_downloads.py
Normal file
79
api/routes/offline_downloads.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from api.response import success
|
||||
from schemas.offline_downloads import OfflineDownloadCreate
|
||||
from services.auth import User, get_current_active_user
|
||||
from services.logging import LogService
|
||||
from services.task_queue import task_queue_service, TaskProgress
|
||||
from services.virtual_fs import path_is_directory
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/offline-downloads",
|
||||
tags=["OfflineDownloads"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_offline_download(
|
||||
payload: OfflineDownloadCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
dest_dir = payload.dest_dir
|
||||
try:
|
||||
is_dir = await path_is_directory(dest_dir)
|
||||
except HTTPException:
|
||||
is_dir = False
|
||||
if not is_dir:
|
||||
raise HTTPException(400, detail="Destination directory not found")
|
||||
|
||||
task = await task_queue_service.add_task(
|
||||
"offline_http_download",
|
||||
{
|
||||
"url": str(payload.url),
|
||||
"dest_dir": dest_dir,
|
||||
"filename": payload.filename,
|
||||
},
|
||||
)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(
|
||||
stage="queued",
|
||||
percent=0.0,
|
||||
bytes_total=None,
|
||||
bytes_done=0,
|
||||
detail="Waiting to start",
|
||||
),
|
||||
)
|
||||
|
||||
await LogService.action(
|
||||
"route:offline_downloads",
|
||||
f"Offline download task created {task.id}",
|
||||
details={"url": str(payload.url), "dest_dir": dest_dir, "filename": payload.filename},
|
||||
user_id=current_user.id if hasattr(current_user, "id") else None,
|
||||
)
|
||||
|
||||
return success({"task_id": task.id})
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_offline_downloads(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
tasks = [t for t in task_queue_service.get_all_tasks() if t.name == "offline_http_download"]
|
||||
data = [t.dict() for t in tasks]
|
||||
return success(data)
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_offline_download(
|
||||
task_id: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
task = task_queue_service.get_task(task_id)
|
||||
if not task or task.name != "offline_http_download":
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return success(task.dict())
|
||||
@@ -1,10 +1,20 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, Body, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from typing import Annotated
|
||||
from services.processors.registry import get_config_schemas
|
||||
from services.processors.registry import (
|
||||
get,
|
||||
get_config_schema,
|
||||
get_config_schemas,
|
||||
get_module_path,
|
||||
reload_processors,
|
||||
)
|
||||
from services.task_queue import task_queue_service
|
||||
from services.auth import get_current_active_user, User
|
||||
from api.response import success
|
||||
from pydantic import BaseModel
|
||||
from services.virtual_fs import path_is_directory, resolve_adapter_and_rel
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
router = APIRouter(prefix="/api/processors", tags=["processors"])
|
||||
|
||||
@@ -22,6 +32,7 @@ async def list_processors(
|
||||
"supported_exts": meta.get("supported_exts", []),
|
||||
"config_schema": meta["config_schema"],
|
||||
"produces_file": meta.get("produces_file", False),
|
||||
"module_path": meta.get("module_path"),
|
||||
})
|
||||
return success(out)
|
||||
|
||||
@@ -34,12 +45,29 @@ class ProcessRequest(BaseModel):
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class ProcessDirectoryRequest(BaseModel):
|
||||
path: str
|
||||
processor_type: str
|
||||
config: dict
|
||||
overwrite: bool = True
|
||||
max_depth: Optional[int] = None
|
||||
suffix: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateSourceRequest(BaseModel):
|
||||
source: str
|
||||
|
||||
|
||||
@router.post("/process")
|
||||
async def process_file_with_processor(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
req: ProcessRequest = Body(...)
|
||||
):
|
||||
save_to = req.path if req.overwrite else req.save_to
|
||||
is_dir = await path_is_directory(req.path)
|
||||
if is_dir and not req.overwrite:
|
||||
raise HTTPException(400, detail="Directory processing requires overwrite")
|
||||
|
||||
save_to = None if is_dir else (req.path if req.overwrite else req.save_to)
|
||||
task = await task_queue_service.add_task(
|
||||
"process_file",
|
||||
{
|
||||
@@ -47,6 +75,176 @@ async def process_file_with_processor(
|
||||
"processor_type": req.processor_type,
|
||||
"config": req.config,
|
||||
"save_to": save_to,
|
||||
"overwrite": req.overwrite,
|
||||
},
|
||||
)
|
||||
return success({"task_id": task.id})
|
||||
|
||||
|
||||
@router.post("/process-directory")
|
||||
async def process_directory_with_processor(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
req: ProcessDirectoryRequest = Body(...)
|
||||
):
|
||||
if req.max_depth is not None and req.max_depth < 0:
|
||||
raise HTTPException(400, detail="max_depth must be >= 0")
|
||||
|
||||
is_dir = await path_is_directory(req.path)
|
||||
if not is_dir:
|
||||
raise HTTPException(400, detail="Path must be a directory")
|
||||
|
||||
schema = get_config_schema(req.processor_type)
|
||||
_processor = get(req.processor_type)
|
||||
if not schema or not _processor:
|
||||
raise HTTPException(404, detail="Processor not found")
|
||||
|
||||
produces_file = bool(schema.get("produces_file"))
|
||||
raw_suffix = req.suffix if req.suffix is not None else None
|
||||
if raw_suffix is not None and raw_suffix.strip() == "":
|
||||
raw_suffix = None
|
||||
suffix = raw_suffix
|
||||
overwrite = req.overwrite
|
||||
|
||||
if produces_file:
|
||||
if not overwrite and not suffix:
|
||||
raise HTTPException(400, detail="Suffix is required when not overwriting files")
|
||||
else:
|
||||
overwrite = False
|
||||
suffix = None
|
||||
|
||||
supported_exts = schema.get("supported_exts") or []
|
||||
allowed_exts = {
|
||||
ext.lower().lstrip('.')
|
||||
for ext in supported_exts
|
||||
if isinstance(ext, str)
|
||||
}
|
||||
|
||||
def matches_extension(file_rel: str) -> bool:
|
||||
if not allowed_exts:
|
||||
return True
|
||||
if '.' not in file_rel:
|
||||
return '' in allowed_exts
|
||||
ext = file_rel.rsplit('.', 1)[-1].lower()
|
||||
return ext in allowed_exts or f'.{ext}' in allowed_exts
|
||||
|
||||
adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(req.path)
|
||||
rel = rel.rstrip('/')
|
||||
|
||||
list_dir = getattr(adapter_instance, "list_dir", None)
|
||||
if not callable(list_dir):
|
||||
raise HTTPException(501, detail="Adapter does not implement list_dir")
|
||||
|
||||
def build_absolute_path(mount_path: str, rel_path: str) -> str:
|
||||
rel_norm = rel_path.lstrip('/')
|
||||
mount_norm = mount_path.rstrip('/')
|
||||
if not mount_norm:
|
||||
return '/' + rel_norm if rel_norm else '/'
|
||||
return f"{mount_norm}/{rel_norm}" if rel_norm else mount_norm
|
||||
|
||||
def apply_suffix(path_str: str, suffix_str: str) -> str:
|
||||
path_obj = Path(path_str)
|
||||
name = path_obj.name
|
||||
if not name:
|
||||
return path_str
|
||||
if '.' in name:
|
||||
base, ext = name.rsplit('.', 1)
|
||||
new_name = f"{base}{suffix_str}.{ext}"
|
||||
else:
|
||||
new_name = f"{name}{suffix_str}"
|
||||
return str(path_obj.with_name(new_name))
|
||||
|
||||
scheduled_tasks: List[str] = []
|
||||
stack: List[Tuple[str, int]] = [(rel, 0)]
|
||||
page_size = 200
|
||||
|
||||
while stack:
|
||||
current_rel, depth = stack.pop()
|
||||
page = 1
|
||||
while True:
|
||||
entries, total = await list_dir(root, current_rel, page, page_size, "name", "asc")
|
||||
entries = entries or []
|
||||
if not entries and (total or 0) == 0:
|
||||
break
|
||||
|
||||
for entry in entries:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
child_rel = f"{current_rel}/{name}" if current_rel else name
|
||||
if entry.get("is_dir"):
|
||||
if req.max_depth is None or depth < req.max_depth:
|
||||
stack.append((child_rel.rstrip('/'), depth + 1))
|
||||
continue
|
||||
if not matches_extension(child_rel):
|
||||
continue
|
||||
absolute_path = build_absolute_path(adapter_model.path, child_rel)
|
||||
save_to = None
|
||||
if produces_file and not overwrite and suffix:
|
||||
save_to = apply_suffix(absolute_path, suffix)
|
||||
task = await task_queue_service.add_task(
|
||||
"process_file",
|
||||
{
|
||||
"path": absolute_path,
|
||||
"processor_type": req.processor_type,
|
||||
"config": req.config,
|
||||
"save_to": save_to,
|
||||
"overwrite": overwrite,
|
||||
},
|
||||
)
|
||||
scheduled_tasks.append(task.id)
|
||||
|
||||
if total is None or page * page_size >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return success({
|
||||
"task_ids": scheduled_tasks,
|
||||
"scheduled": len(scheduled_tasks),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/source/{processor_type}")
|
||||
async def get_processor_source(
|
||||
processor_type: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
module_path = get_module_path(processor_type)
|
||||
if not module_path:
|
||||
raise HTTPException(404, detail="Processor not found")
|
||||
path_obj = Path(module_path)
|
||||
if not path_obj.exists():
|
||||
raise HTTPException(404, detail="Processor source not found")
|
||||
try:
|
||||
content = await run_in_threadpool(path_obj.read_text, encoding='utf-8')
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, detail=f"Failed to read source: {exc}")
|
||||
return success({"source": content, "module_path": str(path_obj)})
|
||||
|
||||
|
||||
@router.put("/source/{processor_type}")
|
||||
async def update_processor_source(
|
||||
processor_type: str,
|
||||
req: UpdateSourceRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
module_path = get_module_path(processor_type)
|
||||
if not module_path:
|
||||
raise HTTPException(404, detail="Processor not found")
|
||||
path_obj = Path(module_path)
|
||||
if not path_obj.exists():
|
||||
raise HTTPException(404, detail="Processor source not found")
|
||||
try:
|
||||
await run_in_threadpool(path_obj.write_text, req.source, encoding='utf-8')
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, detail=f"Failed to write source: {exc}")
|
||||
return success(True)
|
||||
|
||||
|
||||
@router.post("/reload")
|
||||
async def reload_processor_modules(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
errors = reload_processors()
|
||||
if errors:
|
||||
raise HTTPException(500, detail="; ".join(errors))
|
||||
return success(True)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from schemas.fs import SearchResultItem
|
||||
from services.auth import get_current_active_user, User
|
||||
from services.ai import get_text_embedding
|
||||
@@ -6,24 +9,96 @@ from services.vector_db import VectorDBService
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
async def search_files_by_vector(q: str, top_k: int):
|
||||
embedding = await get_text_embedding(q)
|
||||
vector_db = VectorDBService()
|
||||
results = vector_db.search_vectors("vector_collection", embedding, top_k)
|
||||
items = [
|
||||
SearchResultItem(id=res["id"], path=res["entity"]["path"], score=res["distance"])
|
||||
for res in results[0]
|
||||
]
|
||||
return {"items": items, "query": q}
|
||||
|
||||
async def search_files_by_name(q: str, top_k: int):
|
||||
def _normalize_result(raw: Dict[str, Any], source: str, fallback_score: float = 0.0) -> SearchResultItem:
|
||||
entity = dict(raw.get("entity") or {})
|
||||
source_path = entity.get("source_path")
|
||||
stored_path = entity.get("path")
|
||||
path = source_path or stored_path or ""
|
||||
chunk_id_value = entity.get("chunk_id")
|
||||
chunk_id = str(chunk_id_value) if chunk_id_value is not None else None
|
||||
snippet = entity.get("text") or entity.get("description") or entity.get("name")
|
||||
mime = entity.get("mime")
|
||||
start_offset = entity.get("start_offset")
|
||||
end_offset = entity.get("end_offset")
|
||||
raw_score = raw.get("distance")
|
||||
score = float(raw_score) if raw_score is not None else fallback_score
|
||||
|
||||
metadata = {
|
||||
"retrieval_source": source,
|
||||
"raw_distance": raw_score,
|
||||
}
|
||||
if stored_path and stored_path != path:
|
||||
metadata["stored_path"] = stored_path
|
||||
vector_id = entity.get("vector_id")
|
||||
if vector_id:
|
||||
metadata["vector_id"] = vector_id
|
||||
|
||||
return SearchResultItem(
|
||||
id=str(raw.get("id")),
|
||||
path=path,
|
||||
score=score,
|
||||
chunk_id=chunk_id,
|
||||
snippet=snippet,
|
||||
mime=mime,
|
||||
source_type=entity.get("type") or source,
|
||||
start_offset=start_offset,
|
||||
end_offset=end_offset,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
async def _vector_search(query: str, top_k: int) -> List[SearchResultItem]:
|
||||
vector_db = VectorDBService()
|
||||
results = vector_db.search_by_path("vector_collection", q, top_k)
|
||||
items = [
|
||||
SearchResultItem(id=idx, path=res["entity"]["path"], score=res["distance"])
|
||||
for idx, res in enumerate(results[0])
|
||||
]
|
||||
return {"items": items, "query": q}
|
||||
try:
|
||||
embedding = await get_text_embedding(query)
|
||||
except Exception:
|
||||
embedding = None
|
||||
if not embedding:
|
||||
return []
|
||||
|
||||
try:
|
||||
raw_results = await vector_db.search_vectors("vector_collection", embedding, max(top_k, 10))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
results: List[SearchResultItem] = []
|
||||
for bucket in raw_results or []:
|
||||
for record in bucket or []:
|
||||
results.append(_normalize_result(record, "vector"))
|
||||
return results
|
||||
|
||||
|
||||
async def _filename_search(query: str, page: int, page_size: int) -> Tuple[List[SearchResultItem], bool]:
|
||||
vector_db = VectorDBService()
|
||||
limit = max(page * page_size + 1, page_size * (page + 2))
|
||||
limit = min(limit, 2000)
|
||||
try:
|
||||
raw_results = await vector_db.search_by_path("vector_collection", query, limit)
|
||||
except Exception:
|
||||
return [], False
|
||||
|
||||
records = raw_results[0] if raw_results else []
|
||||
deduped: List[SearchResultItem] = []
|
||||
seen_paths: set[str] = set()
|
||||
for record in records or []:
|
||||
item = _normalize_result(record, "filename", fallback_score=1.0)
|
||||
stored_path = item.metadata.get("stored_path") if item.metadata else None
|
||||
key = item.path or stored_path or ""
|
||||
if key in seen_paths:
|
||||
continue
|
||||
seen_paths.add(key)
|
||||
deduped.append(item)
|
||||
|
||||
start = max(page - 1, 0) * page_size
|
||||
end = start + page_size
|
||||
page_items = deduped[start:end]
|
||||
for offset, item in enumerate(page_items):
|
||||
if item.metadata is None:
|
||||
item.metadata = {}
|
||||
item.metadata.setdefault("retrieval_rank", start + offset)
|
||||
has_more = len(deduped) > end
|
||||
return page_items, has_more
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -31,11 +106,32 @@ async def search_files(
|
||||
q: str = Query(..., description="搜索查询"),
|
||||
top_k: int = Query(10, description="返回结果数量"),
|
||||
mode: str = Query("vector", description="搜索模式: 'vector' 或 'filename'"),
|
||||
page: int = Query(1, description="分页页码,仅在文件名搜索模式下生效"),
|
||||
page_size: int = Query(10, description="分页大小,仅在文件名搜索模式下生效"),
|
||||
user: User = Depends(get_current_active_user),
|
||||
):
|
||||
if not q.strip():
|
||||
return {"items": [], "query": q}
|
||||
|
||||
top_k = max(top_k, 1)
|
||||
page = max(page, 1)
|
||||
page_size = max(min(page_size, 100), 1)
|
||||
|
||||
if mode == "vector":
|
||||
return await search_files_by_vector(q, top_k)
|
||||
items = (await _vector_search(q, top_k))[:top_k]
|
||||
elif mode == "filename":
|
||||
return await search_files_by_name(q, top_k)
|
||||
items, has_more = await _filename_search(q, page, page_size)
|
||||
return {
|
||||
"items": items,
|
||||
"query": q,
|
||||
"mode": mode,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_more": has_more,
|
||||
},
|
||||
}
|
||||
else:
|
||||
return {"items": [], "query": q, "error": "Invalid search mode"}
|
||||
items = (await _vector_search(q, top_k))[:top_k]
|
||||
|
||||
return {"items": items, "query": q, "mode": mode}
|
||||
|
||||
@@ -83,6 +83,18 @@ async def get_my_shares(current_user: User = Depends(get_current_active_user)):
|
||||
return [ShareInfo.from_orm(s) for s in shares]
|
||||
|
||||
|
||||
@router.delete("/expired")
|
||||
async def delete_expired_shares(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""
|
||||
删除当前用户的所有已过期分享。
|
||||
"""
|
||||
user_account = await UserAccount.get(id=current_user.id)
|
||||
deleted_count = await share_service.delete_expired_shares(user=user_account)
|
||||
return success({"deleted_count": deleted_count})
|
||||
|
||||
|
||||
@router.delete("/{share_id}")
|
||||
async def delete_share(
|
||||
share_id: int,
|
||||
|
||||
@@ -2,11 +2,17 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Annotated
|
||||
|
||||
from models.database import AutomationTask
|
||||
from schemas.tasks import AutomationTaskCreate, AutomationTaskUpdate
|
||||
from schemas.tasks import (
|
||||
AutomationTaskCreate,
|
||||
AutomationTaskUpdate,
|
||||
TaskQueueSettings,
|
||||
TaskQueueSettingsResponse,
|
||||
)
|
||||
from api.response import success
|
||||
from services.auth import get_current_active_user, User
|
||||
from services.logging import LogService
|
||||
from services.task_queue import task_queue_service
|
||||
from services.config import ConfigCenter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/tasks",
|
||||
@@ -24,6 +30,37 @@ async def get_task_queue_status(
|
||||
return success([task.dict() for task in tasks])
|
||||
|
||||
|
||||
@router.get("/queue/settings")
|
||||
async def get_task_queue_settings(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
payload = TaskQueueSettingsResponse(
|
||||
concurrency=task_queue_service.get_concurrency(),
|
||||
active_workers=task_queue_service.get_active_worker_count(),
|
||||
)
|
||||
return success(payload.model_dump())
|
||||
|
||||
|
||||
@router.post("/queue/settings")
|
||||
async def update_task_queue_settings(
|
||||
settings: TaskQueueSettings,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
await task_queue_service.set_concurrency(settings.concurrency)
|
||||
await ConfigCenter.set("TASK_QUEUE_CONCURRENCY", str(task_queue_service.get_concurrency()))
|
||||
await LogService.action(
|
||||
"route:tasks",
|
||||
"Updated task queue settings",
|
||||
details={"concurrency": settings.concurrency},
|
||||
user_id=getattr(current_user, "id", None),
|
||||
)
|
||||
payload = TaskQueueSettingsResponse(
|
||||
concurrency=task_queue_service.get_concurrency(),
|
||||
active_workers=task_queue_service.get_active_worker_count(),
|
||||
)
|
||||
return success(payload.model_dump())
|
||||
|
||||
|
||||
@router.get("/queue/{task_id}")
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
|
||||
@@ -1,19 +1,91 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.auth import get_current_active_user
|
||||
from models.database import UserAccount
|
||||
from services.vector_db import VectorDBService
|
||||
from services.vector_db import (
|
||||
VectorDBService,
|
||||
VectorDBConfigManager,
|
||||
list_providers,
|
||||
get_provider_entry,
|
||||
)
|
||||
from services.vector_db.providers import get_provider_class
|
||||
from api.response import success
|
||||
|
||||
router = APIRouter(prefix="/api/vector-db", tags=["vector-db"])
|
||||
|
||||
|
||||
class VectorDBConfigPayload(BaseModel):
|
||||
type: str = Field(..., description="向量数据库提供者类型")
|
||||
config: Dict[str, Any] = Field(default_factory=dict, description="提供者配置参数")
|
||||
|
||||
|
||||
@router.post("/clear-all", summary="清空向量数据库")
|
||||
async def clear_vector_db(user: UserAccount = Depends(get_current_active_user)):
|
||||
if user.username != 'admin':
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
try:
|
||||
service = VectorDBService()
|
||||
service.clear_all_data()
|
||||
await service.clear_all_data()
|
||||
return success(msg="向量数据库已清空")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stats", summary="获取向量数据库统计")
|
||||
async def get_vector_db_stats(user: UserAccount = Depends(get_current_active_user)):
|
||||
try:
|
||||
service = VectorDBService()
|
||||
data = await service.get_all_stats()
|
||||
return success(data=data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/providers", summary="列出可用向量数据库提供者")
|
||||
async def list_vector_providers(user: UserAccount = Depends(get_current_active_user)):
|
||||
return success(list_providers())
|
||||
|
||||
|
||||
@router.get("/config", summary="获取当前向量数据库配置")
|
||||
async def get_vector_db_config(user: UserAccount = Depends(get_current_active_user)):
|
||||
service = VectorDBService()
|
||||
data = await service.current_provider()
|
||||
return success(data)
|
||||
|
||||
|
||||
@router.post("/config", summary="更新向量数据库配置")
|
||||
async def update_vector_db_config(payload: VectorDBConfigPayload, user: UserAccount = Depends(get_current_active_user)):
|
||||
entry = get_provider_entry(payload.type)
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"未知的向量数据库类型: {payload.type}")
|
||||
if not entry.get("enabled", True):
|
||||
raise HTTPException(status_code=400, detail="该向量数据库类型暂不可用")
|
||||
|
||||
provider_cls = get_provider_class(payload.type)
|
||||
if not provider_cls:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"未找到类型 {payload.type} 对应的实现")
|
||||
|
||||
# 先尝试建立连接,确保配置有效
|
||||
test_provider = provider_cls(payload.config)
|
||||
try:
|
||||
await test_provider.initialize()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
finally:
|
||||
client = getattr(test_provider, "client", None)
|
||||
close_fn = getattr(client, "close", None)
|
||||
if callable(close_fn):
|
||||
try:
|
||||
close_fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await VectorDBConfigManager.save_config(payload.type, payload.config)
|
||||
service = VectorDBService()
|
||||
await service.reload()
|
||||
config_data = await service.current_provider()
|
||||
stats = await service.get_all_stats()
|
||||
return success({"config": config_data, "stats": stats})
|
||||
|
||||
@@ -15,8 +15,9 @@ from services.virtual_fs import (
|
||||
stream_file,
|
||||
generate_temp_link_token,
|
||||
verify_temp_link_token,
|
||||
maybe_redirect_download,
|
||||
)
|
||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
|
||||
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename, is_video_filename
|
||||
from schemas import MkdirRequest, MoveRequest
|
||||
from api.response import success
|
||||
from services.config import ConfigCenter
|
||||
@@ -50,6 +51,12 @@ async def get_file(
|
||||
except Exception as e:
|
||||
raise HTTPException(500, detail=f"RAW file processing failed: {e}")
|
||||
|
||||
adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(full_path)
|
||||
|
||||
redirect_response = await maybe_redirect_download(adapter_instance, adapter_model, root, rel)
|
||||
if redirect_response is not None:
|
||||
return redirect_response
|
||||
|
||||
try:
|
||||
content = await read_file(full_path)
|
||||
except FileNotFoundError:
|
||||
@@ -114,8 +121,8 @@ async def get_thumb(
|
||||
adapter, mount, root, rel = await resolve_adapter_and_rel(full_path)
|
||||
if not rel or rel.endswith('/'):
|
||||
raise HTTPException(400, detail="Not a file")
|
||||
if not is_image_filename(rel):
|
||||
raise HTTPException(404, detail="Not an image")
|
||||
if not (is_image_filename(rel) or is_video_filename(rel)):
|
||||
raise HTTPException(404, detail="Not an image or video")
|
||||
# type: ignore
|
||||
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
|
||||
headers = {
|
||||
@@ -219,31 +226,41 @@ async def api_mkdir(
|
||||
@router.post("/move")
|
||||
async def api_move(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
body: MoveRequest
|
||||
body: MoveRequest,
|
||||
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
|
||||
):
|
||||
src = body.src if body.src.startswith('/') else '/' + body.src
|
||||
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
||||
await move_path(src, dst)
|
||||
return success({"moved": True, "src": src, "dst": dst})
|
||||
debug_info = await move_path(src, dst, overwrite=overwrite, return_debug=True, allow_cross=True)
|
||||
queued = bool(debug_info.get("queued"))
|
||||
response = {
|
||||
"moved": not queued,
|
||||
"queued": queued,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"overwrite": overwrite,
|
||||
}
|
||||
if queued:
|
||||
response["task_id"] = debug_info.get("task_id")
|
||||
response["task_name"] = debug_info.get("task_name")
|
||||
return success(response)
|
||||
|
||||
|
||||
@router.post("/rename")
|
||||
async def api_rename(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
body: MoveRequest,
|
||||
overwrite: bool = Query(False, description="是否允许覆盖已存在目标"),
|
||||
debug: bool = Query(False, description="返回调试信息")
|
||||
overwrite: bool = Query(False, description="是否允许覆盖已存在目标")
|
||||
):
|
||||
src = body.src if body.src.startswith('/') else '/' + body.src
|
||||
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
||||
from services.virtual_fs import rename_path
|
||||
debug_info = await rename_path(src, dst, overwrite=overwrite, return_debug=debug)
|
||||
await rename_path(src, dst, overwrite=overwrite, return_debug=False)
|
||||
return success({
|
||||
"renamed": True,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"overwrite": overwrite,
|
||||
**({"debug": debug_info} if debug else {})
|
||||
})
|
||||
|
||||
|
||||
@@ -252,19 +269,23 @@ async def api_copy(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
body: MoveRequest,
|
||||
overwrite: bool = Query(False, description="是否覆盖已存在目标"),
|
||||
debug: bool = Query(False, description="返回调试信息")
|
||||
):
|
||||
from services.virtual_fs import copy_path
|
||||
src = body.src if body.src.startswith('/') else '/' + body.src
|
||||
dst = body.dst if body.dst.startswith('/') else '/' + body.dst
|
||||
debug_info = await copy_path(src, dst, overwrite=overwrite, return_debug=debug)
|
||||
return success({
|
||||
"copied": True,
|
||||
debug_info = await copy_path(src, dst, overwrite=overwrite, return_debug=True, allow_cross=True)
|
||||
queued = bool(debug_info.get("queued"))
|
||||
response = {
|
||||
"copied": not queued,
|
||||
"queued": queued,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"overwrite": overwrite,
|
||||
**({"debug": debug_info} if debug else {})
|
||||
})
|
||||
}
|
||||
if queued:
|
||||
response["task_id"] = debug_info.get("task_id")
|
||||
response["task_name"] = debug_info.get("task_name")
|
||||
return success(response)
|
||||
|
||||
|
||||
@router.post("/upload/{full_path:path}")
|
||||
|
||||
273
api/routes/webdav.py
Normal file
273
api/routes/webdav.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from email.utils import formatdate
|
||||
from urllib.parse import urlparse, unquote
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Response, HTTPException, Depends
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from services.auth import authenticate_user_db, User, UserInDB
|
||||
from services.virtual_fs import (
|
||||
list_virtual_dir,
|
||||
stat_file,
|
||||
write_file_stream,
|
||||
make_dir,
|
||||
delete_path,
|
||||
move_path,
|
||||
copy_path,
|
||||
stream_file,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/webdav", tags=["webdav"])
|
||||
|
||||
|
||||
def _dav_headers(extra: Optional[dict] = None) -> dict:
|
||||
headers = {
|
||||
"DAV": "1",
|
||||
"MS-Author-Via": "DAV",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Allow": ", ".join([
|
||||
"OPTIONS",
|
||||
"PROPFIND",
|
||||
"GET",
|
||||
"HEAD",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"MKCOL",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
]),
|
||||
}
|
||||
if extra:
|
||||
headers.update(extra)
|
||||
return headers
|
||||
|
||||
|
||||
async def _get_basic_user(request: Request) -> User:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth:
|
||||
raise HTTPException(401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
|
||||
scheme, _, param = auth.partition(" ")
|
||||
scheme_lower = scheme.lower()
|
||||
if scheme_lower == "basic":
|
||||
try:
|
||||
decoded = base64.b64decode(param).decode("utf-8")
|
||||
username, _, password = decoded.partition(":")
|
||||
except Exception:
|
||||
raise HTTPException(401, detail="Invalid Basic auth", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
user_or_false: Optional[UserInDB] = await authenticate_user_db(username, password)
|
||||
if not user_or_false:
|
||||
raise HTTPException(401, detail="Invalid credentials", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
u: UserInDB = user_or_false
|
||||
return User(id=u.id, username=u.username, email=u.email, full_name=u.full_name, disabled=u.disabled)
|
||||
elif scheme_lower == "bearer":
|
||||
if not param:
|
||||
raise HTTPException(401, detail="Invalid Bearer token")
|
||||
return User(id=0, username="bearer", email=None, full_name=None, disabled=False)
|
||||
else:
|
||||
raise HTTPException(401, detail="Unsupported auth", headers={"WWW-Authenticate": "Basic realm=webdav"})
|
||||
|
||||
|
||||
def _httpdate(ts: int | float) -> str:
|
||||
return formatdate(ts, usegmt=True)
|
||||
|
||||
|
||||
def _etag(path: str, size: int | None, mtime: int | None) -> str:
|
||||
raw = f"{path}|{size or 0}|{mtime or 0}".encode("utf-8")
|
||||
return '"' + hashlib.md5(raw).hexdigest() + '"'
|
||||
|
||||
|
||||
def _href_for(path: str, is_dir: bool) -> str:
|
||||
from urllib.parse import quote
|
||||
p = "/webdav" + (path if path.startswith("/") else "/" + path)
|
||||
if is_dir and not p.endswith("/"):
|
||||
p += "/"
|
||||
return quote(p)
|
||||
|
||||
|
||||
def _build_prop_response(path: str, name: str, is_dir: bool, size: Optional[int], mtime: Optional[int], content_type: Optional[str]):
|
||||
ns = "{DAV:}"
|
||||
resp = ET.Element(ns + "response")
|
||||
href = ET.SubElement(resp, ns + "href")
|
||||
href.text = _href_for(path, is_dir)
|
||||
|
||||
propstat = ET.SubElement(resp, ns + "propstat")
|
||||
prop = ET.SubElement(propstat, ns + "prop")
|
||||
|
||||
displayname = ET.SubElement(prop, ns + "displayname")
|
||||
displayname.text = name
|
||||
|
||||
resourcetype = ET.SubElement(prop, ns + "resourcetype")
|
||||
if is_dir:
|
||||
ET.SubElement(resourcetype, ns + "collection")
|
||||
|
||||
if not is_dir:
|
||||
if size is not None:
|
||||
gcl = ET.SubElement(prop, ns + "getcontentlength")
|
||||
gcl.text = str(size)
|
||||
if content_type:
|
||||
gct = ET.SubElement(prop, ns + "getcontenttype")
|
||||
gct.text = content_type
|
||||
|
||||
if mtime is not None:
|
||||
glm = ET.SubElement(prop, ns + "getlastmodified")
|
||||
glm.text = _httpdate(mtime)
|
||||
|
||||
etag = ET.SubElement(prop, ns + "getetag")
|
||||
etag.text = _etag(path, size, mtime)
|
||||
|
||||
status = ET.SubElement(propstat, ns + "status")
|
||||
status.text = "HTTP/1.1 200 OK"
|
||||
return resp
|
||||
|
||||
|
||||
def _multistatus_xml(responses: list[ET.Element]) -> bytes:
|
||||
ns = "{DAV:}"
|
||||
ms = ET.Element(ns + "multistatus")
|
||||
for r in responses:
|
||||
ms.append(r)
|
||||
return ET.tostring(ms, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _normalize_fs_path(path: str) -> str:
|
||||
full = "/" + path if not path.startswith("/") else path
|
||||
return unquote(full)
|
||||
|
||||
|
||||
@router.options("/{path:path}")
|
||||
async def options_root(path: str = ""):
|
||||
return Response(status_code=200, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PROPFIND"])
|
||||
async def propfind(request: Request, path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
depth = request.headers.get("Depth", "1").lower()
|
||||
if depth not in ("0", "1", "infinity"):
|
||||
depth = "1"
|
||||
|
||||
responses: list[ET.Element] = []
|
||||
|
||||
# 先获取当前路径信息
|
||||
try:
|
||||
st = await stat_file(full_path)
|
||||
is_dir = bool(st.get("is_dir"))
|
||||
name = st.get("name") or full_path.rsplit("/", 1)[-1] or "/"
|
||||
size = None if is_dir else int(st.get("size", 0))
|
||||
mtime = int(st.get("mtime", 0)) if st.get("mtime") is not None else None
|
||||
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
|
||||
responses.append(_build_prop_response(full_path, name, is_dir, size, mtime, ctype))
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
|
||||
if depth in ("1", "infinity"):
|
||||
try:
|
||||
listing = await list_virtual_dir(full_path, page_num=1, page_size=1000)
|
||||
for ent in listing["items"]:
|
||||
is_dir = bool(ent.get("is_dir"))
|
||||
name = ent.get("name")
|
||||
child_path = full_path.rstrip("/") + "/" + name
|
||||
size = None if is_dir else int(ent.get("size", 0))
|
||||
mtime = int(ent.get("mtime", 0)) if ent.get("mtime") is not None else None
|
||||
ctype = None if is_dir else (mimetypes.guess_type(name)[0] or "application/octet-stream")
|
||||
responses.append(_build_prop_response(child_path, name, is_dir, size, mtime, ctype))
|
||||
except HTTPException as e:
|
||||
if e.status_code == 400:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
xml = _multistatus_xml(responses)
|
||||
return Response(content=xml, status_code=207, media_type='application/xml; charset="utf-8"', headers=_dav_headers())
|
||||
|
||||
|
||||
@router.get("/{path:path}")
|
||||
async def dav_get(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
range_header = request.headers.get("Range")
|
||||
return await stream_file(full_path, range_header)
|
||||
|
||||
|
||||
@router.head("/{path:path}")
|
||||
async def dav_head(path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
try:
|
||||
st = await stat_file(full_path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="Not found")
|
||||
is_dir = bool(st.get("is_dir"))
|
||||
headers = _dav_headers()
|
||||
if not is_dir:
|
||||
size = int(st.get("size", 0))
|
||||
name = st.get("name") or full_path.rsplit("/", 1)[-1]
|
||||
ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
mtime = int(st.get("mtime", 0)) if st.get("mtime") is not None else None
|
||||
headers.update({
|
||||
"Content-Length": str(size),
|
||||
"Content-Type": ctype,
|
||||
"ETag": _etag(full_path, size, mtime),
|
||||
})
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["PUT"])
|
||||
async def dav_put(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
async def body_iter():
|
||||
async for chunk in request.stream():
|
||||
if chunk:
|
||||
yield chunk
|
||||
size = await write_file_stream(full_path, body_iter(), overwrite=True)
|
||||
return Response(status_code=201, headers=_dav_headers({"Content-Length": "0"}))
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["DELETE"])
|
||||
async def dav_delete(path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
await delete_path(full_path)
|
||||
return Response(status_code=204, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["MKCOL"])
|
||||
async def dav_mkcol(path: str, user: User = Depends(_get_basic_user)):
|
||||
full_path = _normalize_fs_path(path)
|
||||
await make_dir(full_path)
|
||||
return Response(status_code=201, headers=_dav_headers())
|
||||
|
||||
|
||||
def _parse_destination(dest: str) -> str:
|
||||
if not dest:
|
||||
raise HTTPException(400, detail="Missing Destination header")
|
||||
p = urlparse(dest)
|
||||
path = p.path if p.scheme else dest
|
||||
if path.startswith("/webdav"):
|
||||
rel = path[len("/webdav"):]
|
||||
else:
|
||||
rel = path
|
||||
return _normalize_fs_path(rel)
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["MOVE"])
|
||||
async def dav_move(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_src = _normalize_fs_path(path)
|
||||
dest_header = request.headers.get("Destination")
|
||||
dst = _parse_destination(dest_header or "")
|
||||
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
|
||||
await move_path(full_src, dst, overwrite=overwrite)
|
||||
return Response(status_code=204, headers=_dav_headers())
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["COPY"])
|
||||
async def dav_copy(path: str, request: Request, user: User = Depends(_get_basic_user)):
|
||||
full_src = _normalize_fs_path(path)
|
||||
dest_header = request.headers.get("Destination")
|
||||
dst = _parse_destination(dest_header or "")
|
||||
overwrite = request.headers.get("Overwrite", "T").upper() != "F"
|
||||
await copy_path(full_src, dst, overwrite=overwrite)
|
||||
return Response(status_code=201 if not overwrite else 204, headers=_dav_headers())
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
set -e
|
||||
python migrate/run.py
|
||||
nginx -g 'daemon off;' &
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 1 -b 0.0.0.0:8000 main:app
|
||||
4
main.py
4
main.py
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from services.config import VERSION, ConfigCenter
|
||||
from services.adapters.registry import runtime_registry
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -15,6 +16,7 @@ load_dotenv()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs("data/db", exist_ok=True)
|
||||
await init_db()
|
||||
await runtime_registry.refresh()
|
||||
await ConfigCenter.set("APP_VERSION", VERSION)
|
||||
@@ -29,7 +31,7 @@ async def lifespan(app: FastAPI):
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Foxel",
|
||||
description="AList-like virtual storage aggregator",
|
||||
description="A highly extensible private cloud storage solution for individuals and teams",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
include_routers(app)
|
||||
|
||||
@@ -36,6 +36,81 @@ class Configuration(Model):
|
||||
table = "configurations"
|
||||
|
||||
|
||||
class AIProvider(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(max_length=100)
|
||||
identifier = fields.CharField(max_length=100, unique=True)
|
||||
provider_type = fields.CharField(max_length=50, null=True)
|
||||
api_format = fields.CharField(max_length=20)
|
||||
base_url = fields.CharField(max_length=512, null=True)
|
||||
api_key = fields.CharField(max_length=512, null=True)
|
||||
logo_url = fields.CharField(max_length=512, null=True)
|
||||
extra_config = fields.JSONField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "ai_providers"
|
||||
|
||||
|
||||
class AIModel(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
provider: fields.ForeignKeyRelation[AIProvider] = fields.ForeignKeyField(
|
||||
"models.AIProvider", related_name="models", on_delete=fields.CASCADE
|
||||
)
|
||||
name = fields.CharField(max_length=255)
|
||||
display_name = fields.CharField(max_length=255, null=True)
|
||||
description = fields.TextField(null=True)
|
||||
capabilities = fields.JSONField(null=True)
|
||||
context_window = fields.IntField(null=True)
|
||||
metadata = fields.JSONField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "ai_models"
|
||||
unique_together = ("provider", "name")
|
||||
|
||||
@property
|
||||
def embedding_dimensions(self) -> int | None:
|
||||
metadata = self.metadata or {}
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
value = metadata.get("embedding_dimensions")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@embedding_dimensions.setter
|
||||
def embedding_dimensions(self, value: int | None) -> None:
|
||||
base_metadata = self.metadata if isinstance(self.metadata, dict) else {}
|
||||
metadata = dict(base_metadata or {})
|
||||
if value is None:
|
||||
metadata.pop("embedding_dimensions", None)
|
||||
else:
|
||||
try:
|
||||
metadata["embedding_dimensions"] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
metadata.pop("embedding_dimensions", None)
|
||||
self.metadata = metadata or None
|
||||
|
||||
|
||||
class AIDefaultModel(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
ability = fields.CharField(max_length=50, unique=True)
|
||||
model: fields.ForeignKeyRelation[AIModel] = fields.ForeignKeyField(
|
||||
"models.AIModel", related_name="default_for", on_delete=fields.CASCADE
|
||||
)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "ai_default_models"
|
||||
|
||||
|
||||
class AutomationTask(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(max_length=100)
|
||||
|
||||
@@ -28,7 +28,7 @@ http {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location ~ ^/(api|docs|openapi\.json$) {
|
||||
location ~ ^/(api|webdav|docs|openapi\.json$) {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
108
pyproject.toml
108
pyproject.toml
@@ -1,94 +1,26 @@
|
||||
[project]
|
||||
name = "foxel"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
version = "1"
|
||||
description = "foxel.cc"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aioboto3==15.1.0",
|
||||
"aiobotocore==2.24.0",
|
||||
"aiofiles==24.1.0",
|
||||
"aiohappyeyeballs==2.6.1",
|
||||
"aiohttp==3.12.15",
|
||||
"aioitertools==0.12.0",
|
||||
"aiosignal==1.4.0",
|
||||
"aiosqlite==0.21.0",
|
||||
"annotated-types==0.7.0",
|
||||
"anyio==4.10.0",
|
||||
"asyncclick==8.2.2.2",
|
||||
"attrs==25.3.0",
|
||||
"bcrypt==4.3.0",
|
||||
"boto3==1.39.11",
|
||||
"botocore==1.39.11",
|
||||
"certifi==2025.8.3",
|
||||
"click==8.2.1",
|
||||
"dictdiffer==0.9.0",
|
||||
"dnspython==2.7.0",
|
||||
"email-validator==2.2.0",
|
||||
"fastapi==0.116.1",
|
||||
"fastapi-cli==0.0.8",
|
||||
"fastapi-cloud-cli==0.1.5",
|
||||
"frozenlist==1.7.0",
|
||||
"grpcio==1.74.0",
|
||||
"h11==0.16.0",
|
||||
"httpcore==1.0.9",
|
||||
"httptools==0.6.4",
|
||||
"httpx==0.28.1",
|
||||
"idna==3.10",
|
||||
"imageio==2.37.0",
|
||||
"iso8601==2.1.0",
|
||||
"jinja2==3.1.6",
|
||||
"jmespath==1.0.1",
|
||||
"markdown-it-py==4.0.0",
|
||||
"markupsafe==3.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"milvus-lite==2.5.1",
|
||||
"multidict==6.6.4",
|
||||
"numpy==2.3.2",
|
||||
"pandas==2.3.1",
|
||||
"passlib==1.7.4",
|
||||
"pillow==11.3.0",
|
||||
"propcache==0.3.2",
|
||||
"protobuf==6.32.0",
|
||||
"pyaes==1.6.1",
|
||||
"pyasn1==0.6.1",
|
||||
"pydantic==2.11.7",
|
||||
"pydantic-core==2.33.2",
|
||||
"pygments==2.19.2",
|
||||
"pyjwt==2.10.1",
|
||||
"pymilvus==2.6.0",
|
||||
"pypika-tortoise==0.6.1",
|
||||
"pysocks==1.7.1",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.1.1",
|
||||
"python-multipart==0.0.20",
|
||||
"pytz==2025.2",
|
||||
"pyyaml==6.0.2",
|
||||
"rawpy==0.25.1",
|
||||
"rich==14.1.0",
|
||||
"rich-toolkit==0.15.0",
|
||||
"rignore==0.6.4",
|
||||
"rsa==4.9.1",
|
||||
"s3transfer==0.13.1",
|
||||
"sentry-sdk==2.35.0",
|
||||
"setuptools==80.9.0",
|
||||
"shellingham==1.5.4",
|
||||
"six==1.17.0",
|
||||
"sniffio==1.3.1",
|
||||
"starlette==0.47.2",
|
||||
"telethon==1.40.0",
|
||||
"tortoise-orm==0.25.1",
|
||||
"tqdm==4.67.1",
|
||||
"typer==0.16.0",
|
||||
"typing-extensions==4.14.1",
|
||||
"typing-inspection==0.4.1",
|
||||
"tzdata==2025.2",
|
||||
"ujson==5.10.0",
|
||||
"urllib3==2.5.0",
|
||||
"uvicorn==0.35.0",
|
||||
"uvloop==0.21.0",
|
||||
"watchfiles==1.1.0",
|
||||
"websockets==15.0.1",
|
||||
"wrapt==1.17.3",
|
||||
"yarl==1.20.1",
|
||||
"aioboto3>=15.2.0",
|
||||
"aiofiles>=25.1.0",
|
||||
"fastapi>=0.116.1",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"bcrypt>=3.2.2,<4.0",
|
||||
"pillow>=11.3.0",
|
||||
"pyjwt>=2.10.1",
|
||||
"pysocks>=1.7.1",
|
||||
"python-dotenv>=1.1.1",
|
||||
"python-multipart>=0.0.20",
|
||||
"qdrant-client>=1.15.1",
|
||||
"rawpy>=0.25.1",
|
||||
"telethon>=1.41.2",
|
||||
"tortoise-orm>=0.25.1",
|
||||
"uvicorn>=0.37.0",
|
||||
"pymilvus[milvus-lite]>=2.6.2",
|
||||
"paramiko>=4.0.0",
|
||||
"pydantic[email]>=2.11.7",
|
||||
]
|
||||
|
||||
101
schemas/ai.py
Normal file
101
schemas/ai.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from services.ai_providers import ABILITIES, normalize_capabilities
|
||||
|
||||
|
||||
class AIProviderBase(BaseModel):
|
||||
name: str
|
||||
identifier: str = Field(..., pattern=r"^[a-z0-9_\-\.]+$")
|
||||
provider_type: Optional[str] = None
|
||||
api_format: str
|
||||
base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
extra_config: Optional[dict] = None
|
||||
|
||||
@field_validator("api_format")
|
||||
def normalize_format(cls, value: str) -> str:
|
||||
fmt = value.lower()
|
||||
if fmt not in {"openai", "gemini"}:
|
||||
raise ValueError("api_format must be 'openai' or 'gemini'")
|
||||
return fmt
|
||||
|
||||
|
||||
class AIProviderCreate(AIProviderBase):
|
||||
pass
|
||||
|
||||
|
||||
class AIProviderUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
provider_type: Optional[str] = None
|
||||
api_format: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
extra_config: Optional[dict] = None
|
||||
|
||||
@field_validator("api_format")
|
||||
def normalize_format(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return value
|
||||
fmt = value.lower()
|
||||
if fmt not in {"openai", "gemini"}:
|
||||
raise ValueError("api_format must be 'openai' or 'gemini'")
|
||||
return fmt
|
||||
|
||||
|
||||
class AIModelBase(BaseModel):
|
||||
name: str
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
capabilities: Optional[List[str]] = None
|
||||
context_window: Optional[int] = None
|
||||
embedding_dimensions: Optional[int] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
@field_validator("capabilities")
|
||||
def validate_capabilities(cls, items: Optional[List[str]]) -> Optional[List[str]]:
|
||||
if items is None:
|
||||
return None
|
||||
normalized = normalize_capabilities(items)
|
||||
invalid = set(items) - set(normalized)
|
||||
if invalid:
|
||||
raise ValueError(f"Unsupported capabilities: {', '.join(invalid)}")
|
||||
return normalized
|
||||
|
||||
|
||||
class AIModelCreate(AIModelBase):
|
||||
pass
|
||||
|
||||
|
||||
class AIModelUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
capabilities: Optional[List[str]] = None
|
||||
context_window: Optional[int] = None
|
||||
embedding_dimensions: Optional[int] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
@field_validator("capabilities")
|
||||
def validate_capabilities(cls, items: Optional[List[str]]) -> Optional[List[str]]:
|
||||
if items is None:
|
||||
return None
|
||||
normalized = normalize_capabilities(items)
|
||||
invalid = set(items) - set(normalized)
|
||||
if invalid:
|
||||
raise ValueError(f"Unsupported capabilities: {', '.join(invalid)}")
|
||||
return normalized
|
||||
|
||||
|
||||
class AIDefaultsUpdate(BaseModel):
|
||||
chat: Optional[int] = None
|
||||
vision: Optional[int] = None
|
||||
embedding: Optional[int] = None
|
||||
rerank: Optional[int] = None
|
||||
voice: Optional[int] = None
|
||||
tools: Optional[int] = None
|
||||
|
||||
def as_mapping(self) -> dict:
|
||||
return {ability: getattr(self, ability) for ability in ABILITIES}
|
||||
18
schemas/email.py
Normal file
18
schemas/email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
to: EmailStr
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(default="test", min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class EmailTemplatePreviewPayload(BaseModel):
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -8,7 +8,7 @@ class VfsEntry(BaseModel):
|
||||
size: int
|
||||
mtime: int
|
||||
type: Optional[str] = None
|
||||
is_image: Optional[bool] = None
|
||||
has_thumbnail: Optional[bool] = None
|
||||
|
||||
|
||||
class DirListing(BaseModel):
|
||||
@@ -21,6 +21,13 @@ class SearchResultItem(BaseModel):
|
||||
id: int | str
|
||||
path: str
|
||||
score: float
|
||||
chunk_id: Optional[str] = None
|
||||
snippet: Optional[str] = None
|
||||
mime: Optional[str] = None
|
||||
source_type: Optional[str] = None
|
||||
start_offset: Optional[int] = None
|
||||
end_offset: Optional[int] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
|
||||
7
schemas/offline_downloads.py
Normal file
7
schemas/offline_downloads.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel, HttpUrl, Field
|
||||
|
||||
|
||||
class OfflineDownloadCreate(BaseModel):
|
||||
url: HttpUrl
|
||||
dest_dir: str = Field(..., min_length=1)
|
||||
filename: str = Field(..., min_length=1)
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
@@ -29,3 +29,11 @@ class AutomationTaskRead(AutomationTaskBase):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskQueueSettings(BaseModel):
|
||||
concurrency: int = Field(..., ge=1, description="Desired number of concurrent task workers")
|
||||
|
||||
|
||||
class TaskQueueSettingsResponse(TaskQueueSettings):
|
||||
active_workers: int = Field(..., ge=0, description="Currently running worker count")
|
||||
|
||||
628
services/adapters/ftp.py
Normal file
628
services/adapters/ftp.py
Normal file
@@ -0,0 +1,628 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Tuple, AsyncIterator, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from ftplib import FTP, error_perm
|
||||
import mimetypes
|
||||
|
||||
from models import StorageAdapter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
def _join_remote(root: str, rel: str) -> str:
|
||||
root = (root or "/").rstrip("/") or "/"
|
||||
rel = (rel or "").lstrip("/")
|
||||
if not rel:
|
||||
return root
|
||||
return f"{root}/{rel}"
|
||||
|
||||
|
||||
def _parse_mlst_line(line: str) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
try:
|
||||
facts, _, name = line.partition(" ")
|
||||
for part in facts.split(";"):
|
||||
if not part or "=" not in part:
|
||||
continue
|
||||
k, v = part.split("=", 1)
|
||||
out[k.strip().lower()] = v.strip()
|
||||
if name:
|
||||
out["name"] = name.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _parse_modify_to_epoch(mod: str) -> int:
|
||||
# Formats we may see: YYYYMMDDHHMMSS or YYYYMMDDHHMMSS(.sss)
|
||||
try:
|
||||
mod = mod.strip()
|
||||
mod = mod.split(".")[0]
|
||||
if len(mod) >= 14:
|
||||
y = int(mod[0:4])
|
||||
m = int(mod[4:6])
|
||||
d = int(mod[6:8])
|
||||
hh = int(mod[8:10])
|
||||
mm = int(mod[10:12])
|
||||
ss = int(mod[12:14])
|
||||
import datetime as _dt
|
||||
return int(_dt.datetime(y, m, d, hh, mm, ss, tzinfo=_dt.timezone.utc).timestamp())
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Range:
|
||||
start: int
|
||||
end: Optional[int] # inclusive
|
||||
|
||||
|
||||
class FTPAdapter:
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config
|
||||
self.host: str = cfg.get("host")
|
||||
self.port: int = int(cfg.get("port", 21))
|
||||
self.username: Optional[str] = cfg.get("username")
|
||||
self.password: Optional[str] = cfg.get("password")
|
||||
self.passive: bool = bool(cfg.get("passive", True))
|
||||
self.timeout: int = int(cfg.get("timeout", 15))
|
||||
self.root_path: str = cfg.get("root", "/") or "/"
|
||||
|
||||
if not self.host:
|
||||
raise ValueError("FTP adapter requires 'host'")
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
base = self.root_path.rstrip("/") or "/"
|
||||
if sub_path:
|
||||
return _join_remote(base, sub_path)
|
||||
return base
|
||||
|
||||
def _connect(self) -> FTP:
|
||||
ftp = FTP()
|
||||
ftp.connect(self.host, self.port, timeout=self.timeout)
|
||||
if self.username:
|
||||
ftp.login(self.username, self.password or "")
|
||||
else:
|
||||
ftp.login()
|
||||
ftp.set_pasv(self.passive)
|
||||
return ftp
|
||||
|
||||
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]:
|
||||
path = _join_remote(root, rel.strip('/'))
|
||||
|
||||
def _do_list() -> List[Dict]:
|
||||
ftp = self._connect()
|
||||
try:
|
||||
ftp.cwd(path)
|
||||
except error_perm as e:
|
||||
# path may be file
|
||||
ftp.quit()
|
||||
raise NotADirectoryError(rel) from e
|
||||
|
||||
entries: List[Dict] = []
|
||||
# Try MLSD first
|
||||
try:
|
||||
for name, facts in ftp.mlsd():
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
is_dir = (facts.get("type") == "dir")
|
||||
size = int(facts.get("size") or 0)
|
||||
mtime = _parse_modify_to_epoch(facts.get("modify") or "")
|
||||
entries.append({
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else size,
|
||||
"mtime": mtime,
|
||||
"type": "dir" if is_dir else "file",
|
||||
})
|
||||
ftp.quit()
|
||||
return entries
|
||||
except Exception:
|
||||
# Fallback to NLST + probing
|
||||
pass
|
||||
|
||||
names = []
|
||||
try:
|
||||
names = ftp.nlst()
|
||||
except Exception:
|
||||
ftp.quit()
|
||||
return []
|
||||
|
||||
for name in names:
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
is_dir = False
|
||||
size = 0
|
||||
mtime = 0
|
||||
try:
|
||||
# If we can CWD, it's a directory
|
||||
ftp.cwd(_join_remote(path, name))
|
||||
ftp.cwd(path)
|
||||
is_dir = True
|
||||
except Exception:
|
||||
is_dir = False
|
||||
try:
|
||||
size = ftp.size(_join_remote(path, name)) or 0
|
||||
except Exception:
|
||||
size = 0
|
||||
try:
|
||||
mdtm = ftp.sendcmd("MDTM " + _join_remote(path, name))
|
||||
# Example: '213 20241012XXXXXX'
|
||||
if mdtm.startswith("213 "):
|
||||
mtime = _parse_modify_to_epoch(mdtm.split(" ", 1)[1])
|
||||
except Exception:
|
||||
pass
|
||||
entries.append({
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else int(size or 0),
|
||||
"mtime": int(mtime or 0),
|
||||
"type": "dir" if is_dir else "file",
|
||||
})
|
||||
ftp.quit()
|
||||
return entries
|
||||
|
||||
entries = await asyncio.to_thread(_do_list)
|
||||
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
f = sort_by.lower()
|
||||
if f == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif f == "size":
|
||||
key += (item.get("size", 0),)
|
||||
elif f == "mtime":
|
||||
key += (item.get("mtime", 0),)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
|
||||
entries.sort(key=get_sort_key, reverse=reverse)
|
||||
total = len(entries)
|
||||
start = (page_num - 1) * page_size
|
||||
end = start + page_size
|
||||
return entries[start:end], total
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_read() -> bytes:
|
||||
ftp = self._connect()
|
||||
try:
|
||||
chunks: List[bytes] = []
|
||||
ftp.retrbinary("RETR " + path, lambda b: chunks.append(b))
|
||||
return b"".join(chunks)
|
||||
except error_perm as e:
|
||||
if str(e).startswith("550"):
|
||||
raise FileNotFoundError(rel)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await asyncio.to_thread(_do_read)
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _ensure_dirs(ftp: FTP, dir_path: str):
|
||||
parts = [p for p in dir_path.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
ftp.mkd(cur)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _do_write():
|
||||
ftp = self._connect()
|
||||
try:
|
||||
parent = "/" if "/" not in path.strip("/") else path.rsplit("/", 1)[0]
|
||||
_ensure_dirs(ftp, parent)
|
||||
from io import BytesIO
|
||||
bio = BytesIO(data)
|
||||
ftp.storbinary("STOR " + path, bio)
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_write)
|
||||
await LogService.info(
|
||||
"adapter:ftp",
|
||||
f"Wrote file to {rel}",
|
||||
details={"adapter_id": self.record.id, "path": path, "size": len(data)},
|
||||
)
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
# KISS: 聚合后一次性写入
|
||||
buf = bytearray()
|
||||
async for chunk in data_iter:
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
await self.write_file(root, rel, bytes(buf))
|
||||
return len(buf)
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_mkdir():
|
||||
ftp = self._connect()
|
||||
try:
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
ftp.mkd(cur)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_mkdir)
|
||||
await LogService.info("adapter:ftp", f"Created directory {rel}", details={"adapter_id": self.record.id, "path": path})
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_delete():
|
||||
ftp = self._connect()
|
||||
try:
|
||||
# Try file delete
|
||||
try:
|
||||
ftp.delete(path)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Recursively delete dir
|
||||
def _rm_tree(dir_path: str):
|
||||
try:
|
||||
ftp.cwd(dir_path)
|
||||
except Exception:
|
||||
return
|
||||
items = []
|
||||
try:
|
||||
for name, facts in ftp.mlsd():
|
||||
if name in (".", ".."):
|
||||
continue
|
||||
items.append((name, facts.get("type") == "dir"))
|
||||
except Exception:
|
||||
try:
|
||||
names = ftp.nlst()
|
||||
except Exception:
|
||||
names = []
|
||||
for n in names:
|
||||
if n in (".", ".."):
|
||||
continue
|
||||
# Best-effort dir check
|
||||
try:
|
||||
ftp.cwd(_join_remote(dir_path, n))
|
||||
ftp.cwd(dir_path)
|
||||
items.append((n, True))
|
||||
except Exception:
|
||||
items.append((n, False))
|
||||
for n, is_dir in items:
|
||||
child = _join_remote(dir_path, n)
|
||||
if is_dir:
|
||||
_rm_tree(child)
|
||||
else:
|
||||
try:
|
||||
ftp.delete(child)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ftp.rmd(dir_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_rm_tree(path)
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_delete)
|
||||
await LogService.info("adapter:ftp", f"Deleted {rel}", details={"adapter_id": self.record.id, "path": path})
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
src = _join_remote(root, src_rel)
|
||||
dst = _join_remote(root, dst_rel)
|
||||
|
||||
def _do_move():
|
||||
ftp = self._connect()
|
||||
try:
|
||||
# Ensure dst parent exists
|
||||
parent = "/" if "/" not in dst.strip("/") else dst.rsplit("/", 1)[0]
|
||||
parts = [p for p in parent.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
ftp.mkd(cur)
|
||||
except Exception:
|
||||
pass
|
||||
ftp.rename(src, dst)
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_move)
|
||||
await LogService.info("adapter:ftp", f"Moved {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst})
|
||||
|
||||
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 = _join_remote(root, src_rel)
|
||||
dst = _join_remote(root, dst_rel)
|
||||
|
||||
# naive implementation: download then upload; recursively for dirs
|
||||
async def _is_dir(path: str) -> bool:
|
||||
def _probe() -> bool:
|
||||
ftp = self._connect()
|
||||
try:
|
||||
try:
|
||||
ftp.cwd(path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return await asyncio.to_thread(_probe)
|
||||
|
||||
if await _is_dir(src):
|
||||
# list children, create dst dir, copy recursively
|
||||
await self.mkdir(root, dst_rel)
|
||||
|
||||
children, _ = await self.list_dir(root, src_rel, page_num=1, page_size=10_000)
|
||||
for ent in children:
|
||||
child_src = f"{src_rel.rstrip('/')}/{ent['name']}"
|
||||
child_dst = f"{dst_rel.rstrip('/')}/{ent['name']}"
|
||||
await self.copy(root, child_src, child_dst, overwrite)
|
||||
await LogService.info(
|
||||
"adapter:ftp", f"Copied directory {src_rel} to {dst_rel}",
|
||||
details={"adapter_id": self.record.id, "src": src, "dst": dst}
|
||||
)
|
||||
return
|
||||
|
||||
# file
|
||||
data = await self.read_file(root, src_rel)
|
||||
if not overwrite:
|
||||
# best-effort existence check
|
||||
try:
|
||||
await self.stat_file(root, dst_rel)
|
||||
raise FileExistsError(dst_rel)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
await self.write_file(root, dst_rel, data)
|
||||
await LogService.info("adapter:ftp", f"Copied {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst})
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_stat():
|
||||
ftp = self._connect()
|
||||
try:
|
||||
# Try MLST
|
||||
try:
|
||||
resp: List[str] = []
|
||||
ftp.retrlines("MLST " + path, resp.append)
|
||||
# The last line usually contains facts
|
||||
facts = {}
|
||||
if resp:
|
||||
facts = _parse_mlst_line(resp[-1])
|
||||
name = rel.split("/")[-1]
|
||||
t = facts.get("type") or "file"
|
||||
is_dir = t == "dir"
|
||||
size = int(facts.get("size") or 0)
|
||||
mtime = _parse_modify_to_epoch(facts.get("modify") or "")
|
||||
return {
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else size,
|
||||
"mtime": mtime,
|
||||
"type": "dir" if is_dir else "file",
|
||||
"path": path,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Probe directory
|
||||
try:
|
||||
ftp.cwd(path)
|
||||
return {
|
||||
"name": rel.split("/")[-1],
|
||||
"is_dir": True,
|
||||
"size": 0,
|
||||
"mtime": 0,
|
||||
"type": "dir",
|
||||
"path": path,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Treat as file
|
||||
try:
|
||||
size = ftp.size(path) or 0
|
||||
except Exception:
|
||||
size = 0
|
||||
try:
|
||||
mdtm = ftp.sendcmd("MDTM " + path)
|
||||
mtime = _parse_modify_to_epoch(mdtm.split(" ", 1)[1]) if mdtm.startswith("213 ") else 0
|
||||
except Exception:
|
||||
mtime = 0
|
||||
return {
|
||||
"name": rel.split("/")[-1],
|
||||
"is_dir": False,
|
||||
"size": int(size or 0),
|
||||
"mtime": int(mtime or 0),
|
||||
"type": "file",
|
||||
"path": path,
|
||||
}
|
||||
except error_perm as e:
|
||||
if str(e).startswith("550"):
|
||||
raise FileNotFoundError(rel)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await asyncio.to_thread(_do_stat)
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
path = _join_remote(root, rel)
|
||||
# Get size (best-effort)
|
||||
def _get_size() -> Optional[int]:
|
||||
ftp = self._connect()
|
||||
try:
|
||||
try:
|
||||
return int(ftp.size(path) or 0)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total_size = await asyncio.to_thread(_get_size)
|
||||
mime, _ = mimetypes.guess_type(rel)
|
||||
content_type = mime or "application/octet-stream"
|
||||
|
||||
rng: Optional[_Range] = None
|
||||
status = 200
|
||||
headers = {"Accept-Ranges": "bytes", "Content-Type": content_type}
|
||||
if range_header and range_header.startswith("bytes=") and total_size is not None:
|
||||
try:
|
||||
s, e = (range_header.removeprefix("bytes=").split("-", 1))
|
||||
start = int(s) if s.strip() else 0
|
||||
end = int(e) if e.strip() else (total_size - 1)
|
||||
if start >= total_size:
|
||||
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
||||
if end >= total_size:
|
||||
end = total_size - 1
|
||||
rng = _Range(start, end)
|
||||
status = 206
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail="Invalid Range header")
|
||||
elif total_size is not None:
|
||||
headers["Content-Length"] = str(total_size)
|
||||
|
||||
queue: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=8)
|
||||
|
||||
class _Stop(Exception):
|
||||
pass
|
||||
|
||||
def _worker():
|
||||
ftp = self._connect()
|
||||
remaining = None
|
||||
if rng is not None:
|
||||
remaining = (rng.end - rng.start + 1) if rng.end is not None else None
|
||||
|
||||
def _cb(chunk: bytes):
|
||||
nonlocal remaining
|
||||
if not chunk:
|
||||
return
|
||||
try:
|
||||
if remaining is not None:
|
||||
if len(chunk) > remaining:
|
||||
part = chunk[:remaining]
|
||||
queue.put_nowait(part)
|
||||
remaining = 0
|
||||
raise _Stop()
|
||||
else:
|
||||
queue.put_nowait(chunk)
|
||||
remaining -= len(chunk)
|
||||
if remaining <= 0:
|
||||
raise _Stop()
|
||||
else:
|
||||
queue.put_nowait(chunk)
|
||||
except _Stop:
|
||||
raise
|
||||
except Exception:
|
||||
# queue full or event loop closed
|
||||
raise _Stop()
|
||||
|
||||
try:
|
||||
if rng is not None:
|
||||
ftp.retrbinary("RETR " + path, _cb, rest=rng.start)
|
||||
else:
|
||||
ftp.retrbinary("RETR " + path, _cb)
|
||||
queue.put_nowait(None)
|
||||
except _Stop:
|
||||
try:
|
||||
queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
except error_perm as e:
|
||||
try:
|
||||
queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
if str(e).startswith("550"):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def agen():
|
||||
worker_fut = asyncio.to_thread(_worker)
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
await worker_fut
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(agen(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
|
||||
ADAPTER_TYPE = "ftp"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "host", "label": "主机", "type": "string", "required": True, "placeholder": "ftp.example.com"},
|
||||
{"key": "port", "label": "端口", "type": "number", "required": False, "default": 21},
|
||||
{"key": "username", "label": "用户名", "type": "string", "required": False},
|
||||
{"key": "password", "label": "密码", "type": "password", "required": False},
|
||||
{"key": "passive", "label": "被动模式", "type": "boolean", "required": False, "default": True},
|
||||
{"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 15},
|
||||
{"key": "root", "label": "根路径", "type": "string", "required": False, "default": "/"},
|
||||
]
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter):
|
||||
return FTPAdapter(rec)
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List, Dict, Tuple, AsyncIterator
|
||||
import httpx
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
from fastapi import HTTPException
|
||||
from models import StorageAdapter
|
||||
|
||||
@@ -20,6 +20,7 @@ class OneDriveAdapter:
|
||||
self.client_secret = cfg.get("client_secret")
|
||||
self.refresh_token = cfg.get("refresh_token")
|
||||
self.root = cfg.get("root", "/").strip("/")
|
||||
self.enable_redirect_307 = bool(cfg.get("enable_direct_download_307"))
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError(
|
||||
@@ -380,6 +381,26 @@ class OneDriveAdapter:
|
||||
|
||||
return StreamingResponse(file_iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
async def get_direct_download_response(self, root: str, rel: str):
|
||||
if not self.enable_redirect_307:
|
||||
return None
|
||||
|
||||
api_path = self._get_api_path(rel)
|
||||
if not api_path:
|
||||
raise IsADirectoryError("不能对目录进行直链重定向")
|
||||
|
||||
resp = await self._request("GET", api_path_segment=api_path)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
|
||||
item_data = resp.json()
|
||||
download_url = item_data.get("@microsoft.graph.downloadUrl")
|
||||
if not download_url:
|
||||
return None
|
||||
|
||||
return Response(status_code=307, headers={"Location": download_url})
|
||||
|
||||
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
|
||||
"""
|
||||
获取文件的缩略图。
|
||||
@@ -434,6 +455,7 @@ CONFIG_SCHEMA = [
|
||||
"required": True, "help_text": "可以通过运行 'python -m services.adapters.onedrive' 获取"},
|
||||
{"key": "root", "label": "根目录 (Root Path)", "type": "string",
|
||||
"required": False, "placeholder": "默认为根目录 /"},
|
||||
{"key": "enable_direct_download_307", "label": "Enable 307 redirect download", "type": "boolean", "default": False},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -34,8 +34,15 @@ class QuarkAdapter:
|
||||
cfg = record.config or {}
|
||||
self.cookie: str = cfg.get("cookie") or cfg.get("Cookie")
|
||||
self.root_fid: str = cfg.get("root_fid", "0")
|
||||
self.use_transcoding_address: bool = bool(cfg.get("use_transcoding_address", False))
|
||||
self.only_list_video_file: bool = bool(cfg.get("only_list_video_file", False))
|
||||
def _as_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
self.use_transcoding_address: bool = _as_bool(cfg.get("use_transcoding_address", False))
|
||||
self.only_list_video_file: bool = _as_bool(cfg.get("only_list_video_file", False))
|
||||
|
||||
if not self.cookie:
|
||||
raise ValueError("Quark 适配器需要 cookie 配置")
|
||||
@@ -716,8 +723,8 @@ ADAPTER_TYPE = "Quark"
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
|
||||
{"key": "root_fid", "label": "根 FID", "type": "string", "required": False, "default": "0"},
|
||||
{"key": "use_transcoding_address", "label": "视频转码直链", "type": "checkbox", "required": False, "default": False},
|
||||
{"key": "only_list_video_file", "label": "仅列出视频文件", "type": "checkbox", "required": False, "default": False},
|
||||
{"key": "use_transcoding_address", "label": "视频转码直链", "type": "boolean", "required": False, "default": False},
|
||||
{"key": "only_list_video_file", "label": "仅列出视频文件", "type": "boolean", "required": False, "default": False},
|
||||
]
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter:
|
||||
|
||||
447
services/adapters/sftp.py
Normal file
447
services/adapters/sftp.py
Normal file
@@ -0,0 +1,447 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import stat as statmod
|
||||
from typing import List, Dict, Tuple, AsyncIterator, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import paramiko
|
||||
|
||||
from models import StorageAdapter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
def _join_remote(root: str, rel: str) -> str:
|
||||
root = (root or "/").rstrip("/") or "/"
|
||||
rel = (rel or "").lstrip("/")
|
||||
if not rel:
|
||||
return root
|
||||
return f"{root}/{rel}"
|
||||
|
||||
|
||||
class SFTPAdapter:
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config
|
||||
self.host: str = cfg.get("host")
|
||||
self.port: int = int(cfg.get("port", 22))
|
||||
self.username: str | None = cfg.get("username")
|
||||
self.password: str | None = cfg.get("password")
|
||||
self.timeout: int = int(cfg.get("timeout", 15))
|
||||
self.root_path: str = cfg.get("root") # 必填
|
||||
self.allow_unknown_host: bool = bool(cfg.get("allow_unknown_host", True))
|
||||
|
||||
if not self.host:
|
||||
raise ValueError("SFTP adapter requires 'host'")
|
||||
if not self.username or not self.password:
|
||||
raise ValueError("SFTP adapter requires 'username' and 'password'")
|
||||
if not self.root_path:
|
||||
raise ValueError("SFTP adapter requires 'root'")
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
base = self.root_path.rstrip("/") or "/"
|
||||
if sub_path:
|
||||
return _join_remote(base, sub_path)
|
||||
return base
|
||||
|
||||
def _connect(self) -> paramiko.SFTPClient:
|
||||
ssh = paramiko.SSHClient()
|
||||
if self.allow_unknown_host:
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
timeout=self.timeout,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
return ssh.open_sftp()
|
||||
|
||||
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]:
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_list() -> List[Dict]:
|
||||
sftp = self._connect()
|
||||
try:
|
||||
attrs = sftp.listdir_attr(path)
|
||||
entries: List[Dict] = []
|
||||
for a in attrs:
|
||||
name = a.filename
|
||||
is_dir = statmod.S_ISDIR(a.st_mode)
|
||||
entries.append({
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else int(a.st_size or 0),
|
||||
"mtime": int(a.st_mtime or 0),
|
||||
"type": "dir" if is_dir else "file",
|
||||
})
|
||||
return entries
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries = await asyncio.to_thread(_do_list)
|
||||
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
def get_sort_key(item):
|
||||
key = (not item["is_dir"],)
|
||||
f = sort_by.lower()
|
||||
if f == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif f == "size":
|
||||
key += (item.get("size", 0),)
|
||||
elif f == "mtime":
|
||||
key += (item.get("mtime", 0),)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
|
||||
entries.sort(key=get_sort_key, reverse=reverse)
|
||||
total = len(entries)
|
||||
start = (page_num - 1) * page_size
|
||||
end = start + page_size
|
||||
return entries[start:end], total
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_read() -> bytes:
|
||||
sftp = self._connect()
|
||||
try:
|
||||
with sftp.open(path, "rb") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except IOError as e:
|
||||
if getattr(e, "errno", None) == 2:
|
||||
raise FileNotFoundError(rel)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await asyncio.to_thread(_do_read)
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _ensure_dirs(sftp: paramiko.SFTPClient, dir_path: str):
|
||||
parts = [p for p in dir_path.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
sftp.mkdir(cur)
|
||||
except IOError:
|
||||
# likely exists
|
||||
pass
|
||||
|
||||
def _do_write():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
parent = "/" if "/" not in path.strip("/") else path.rsplit("/", 1)[0]
|
||||
_ensure_dirs(sftp, parent)
|
||||
with sftp.open(path, "wb") as f:
|
||||
f.write(data)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_write)
|
||||
await LogService.info("adapter:sftp", f"Wrote file to {rel}", details={"adapter_id": self.record.id, "path": path, "size": len(data)})
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
buf = bytearray()
|
||||
async for chunk in data_iter:
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
await self.write_file(root, rel, bytes(buf))
|
||||
return len(buf)
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_mkdir():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
sftp.mkdir(cur)
|
||||
except IOError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_mkdir)
|
||||
await LogService.info("adapter:sftp", f"Created directory {rel}", details={"adapter_id": self.record.id, "path": path})
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_delete():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
# Try file remove first
|
||||
try:
|
||||
sftp.remove(path)
|
||||
return
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
def _rm_tree(dp: str):
|
||||
try:
|
||||
for a in sftp.listdir_attr(dp):
|
||||
child = _join_remote(dp, a.filename)
|
||||
if statmod.S_ISDIR(a.st_mode):
|
||||
_rm_tree(child)
|
||||
else:
|
||||
try:
|
||||
sftp.remove(child)
|
||||
except Exception:
|
||||
pass
|
||||
sftp.rmdir(dp)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
_rm_tree(path)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_delete)
|
||||
await LogService.info("adapter:sftp", f"Deleted {rel}", details={"adapter_id": self.record.id, "path": path})
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
src = _join_remote(root, src_rel)
|
||||
dst = _join_remote(root, dst_rel)
|
||||
|
||||
def _do_move():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
# ensure dst parent exists
|
||||
parent = "/" if "/" not in dst.strip("/") else dst.rsplit("/", 1)[0]
|
||||
parts = [p for p in parent.strip("/").split("/") if p]
|
||||
cur = "/"
|
||||
for p in parts:
|
||||
cur = _join_remote(cur, p)
|
||||
try:
|
||||
sftp.mkdir(cur)
|
||||
except IOError:
|
||||
pass
|
||||
sftp.rename(src, dst)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do_move)
|
||||
await LogService.info("adapter:sftp", f"Moved {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst})
|
||||
|
||||
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 = _join_remote(root, src_rel)
|
||||
dst = _join_remote(root, dst_rel)
|
||||
|
||||
def _is_dir() -> bool:
|
||||
sftp = self._connect()
|
||||
try:
|
||||
st = sftp.stat(src)
|
||||
return statmod.S_ISDIR(st.st_mode)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if await asyncio.to_thread(_is_dir):
|
||||
await self.mkdir(root, dst_rel)
|
||||
|
||||
children, _ = await self.list_dir(root, src_rel, page_num=1, page_size=10_000)
|
||||
for ent in children:
|
||||
child_src = f"{src_rel.rstrip('/')}/{ent['name']}"
|
||||
child_dst = f"{dst_rel.rstrip('/')}/{ent['name']}"
|
||||
await self.copy(root, child_src, child_dst, overwrite)
|
||||
await LogService.info("adapter:sftp", f"Copied directory {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst})
|
||||
return
|
||||
|
||||
# file copy
|
||||
data = await self.read_file(root, src_rel)
|
||||
if not overwrite:
|
||||
try:
|
||||
await self.stat_file(root, dst_rel)
|
||||
raise FileExistsError(dst_rel)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
await self.write_file(root, dst_rel, data)
|
||||
await LogService.info("adapter:sftp", f"Copied {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst})
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _do_stat():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
st = sftp.stat(path)
|
||||
is_dir = statmod.S_ISDIR(st.st_mode)
|
||||
info = {
|
||||
"name": rel.split("/")[-1],
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else int(st.st_size or 0),
|
||||
"mtime": int(st.st_mtime or 0),
|
||||
"type": "dir" if is_dir else "file",
|
||||
"path": path,
|
||||
}
|
||||
return info
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except IOError as e:
|
||||
if getattr(e, "errno", None) == 2:
|
||||
raise FileNotFoundError(rel)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await asyncio.to_thread(_do_stat)
|
||||
|
||||
async def exists(self, root: str, rel: str) -> bool:
|
||||
try:
|
||||
await self.stat_file(root, rel)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
path = _join_remote(root, rel)
|
||||
|
||||
def _get_stat():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
st = sftp.stat(path)
|
||||
return int(st.st_size or 0)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
file_size = await asyncio.to_thread(_get_stat)
|
||||
if file_size is None:
|
||||
raise HTTPException(404, detail="File not found")
|
||||
|
||||
mime, _ = mimetypes.guess_type(rel)
|
||||
content_type = mime or "application/octet-stream"
|
||||
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
status = 200
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(file_size),
|
||||
}
|
||||
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
try:
|
||||
s, e = (range_header.removeprefix("bytes=").split("-", 1))
|
||||
if s.strip():
|
||||
start = int(s)
|
||||
if e.strip():
|
||||
end = int(e)
|
||||
if start >= file_size:
|
||||
raise HTTPException(416, detail="Requested Range Not Satisfiable")
|
||||
if end >= file_size:
|
||||
end = file_size - 1
|
||||
status = 206
|
||||
headers["Content-Length"] = str(end - start + 1)
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail="Invalid Range header")
|
||||
|
||||
queue: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=8)
|
||||
|
||||
def _worker():
|
||||
sftp = self._connect()
|
||||
try:
|
||||
with sftp.open(path, "rb") as f:
|
||||
f.seek(start)
|
||||
remaining = end - start + 1
|
||||
chunk_size = 64 * 1024
|
||||
while remaining > 0:
|
||||
to_read = chunk_size if remaining > chunk_size else remaining
|
||||
data = f.read(to_read)
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
queue.put_nowait(data)
|
||||
except Exception:
|
||||
break
|
||||
remaining -= len(data)
|
||||
try:
|
||||
queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def agen():
|
||||
worker_fut = asyncio.to_thread(_worker)
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
await worker_fut
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(agen(), status_code=status, headers=headers, media_type=content_type)
|
||||
|
||||
|
||||
ADAPTER_TYPE = "sftp"
|
||||
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "host", "label": "主机", "type": "string", "required": True, "placeholder": "sftp.example.com"},
|
||||
{"key": "port", "label": "端口", "type": "number", "required": False, "default": 22},
|
||||
{"key": "username", "label": "用户名", "type": "string", "required": True},
|
||||
{"key": "password", "label": "密码", "type": "password", "required": True},
|
||||
{"key": "root", "label": "根路径", "type": "string", "required": True, "placeholder": "/data"},
|
||||
{"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 15},
|
||||
{"key": "allow_unknown_host", "label": "允许未知主机指纹", "type": "boolean", "required": False, "default": True},
|
||||
]
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter):
|
||||
return SFTPAdapter(rec)
|
||||
@@ -74,33 +74,32 @@ class TelegramAdapter:
|
||||
for message in messages:
|
||||
if not message:
|
||||
continue
|
||||
|
||||
|
||||
media = message.document or message.video or message.photo
|
||||
if not media:
|
||||
continue
|
||||
|
||||
filename = None
|
||||
size = 0
|
||||
|
||||
if message.photo:
|
||||
photo_size = message.photo.sizes[-1]
|
||||
size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||
filename = f"photo_{message.id}.jpg"
|
||||
file_meta = message.file
|
||||
if not file_meta:
|
||||
continue
|
||||
|
||||
elif message.document or message.video:
|
||||
size = media.size
|
||||
if hasattr(media, 'attributes'):
|
||||
for attr in media.attributes:
|
||||
if hasattr(attr, 'file_name') and attr.file_name:
|
||||
filename = attr.file_name
|
||||
break
|
||||
|
||||
filename = file_meta.name
|
||||
if not filename:
|
||||
if message.text and '.' in message.text and len(message.text) < 256 and '\n' not in message.text:
|
||||
filename = message.text
|
||||
|
||||
if not filename:
|
||||
filename = f"unknown_{message.id}"
|
||||
else:
|
||||
filename = f"unknown_{message.id}"
|
||||
|
||||
size = file_meta.size
|
||||
if size is None:
|
||||
# 兼容缺失 size 的情况
|
||||
if hasattr(media, "size") and media.size is not None:
|
||||
size = media.size
|
||||
elif message.photo and getattr(message.photo, "sizes", None):
|
||||
photo_size = message.photo.sizes[-1]
|
||||
size = getattr(photo_size, "size", 0) or 0
|
||||
else:
|
||||
size = 0
|
||||
|
||||
entries.append({
|
||||
"name": f"{message.id}_{filename}",
|
||||
@@ -246,13 +245,27 @@ class TelegramAdapter:
|
||||
if not message or not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
if message.photo:
|
||||
photo_size = media.sizes[-1]
|
||||
file_size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||
mime_type = "image/jpeg"
|
||||
else:
|
||||
file_size = media.size
|
||||
mime_type = media.mime_type or "application/octet-stream"
|
||||
file_meta = message.file
|
||||
file_size = file_meta.size if file_meta and file_meta.size is not None else None
|
||||
if file_size is None:
|
||||
if hasattr(media, "size") and media.size is not None:
|
||||
file_size = media.size
|
||||
elif message.photo and getattr(message.photo, "sizes", None):
|
||||
photo_size = message.photo.sizes[-1]
|
||||
file_size = getattr(photo_size, "size", 0) or 0
|
||||
else:
|
||||
file_size = 0
|
||||
|
||||
mime_type = None
|
||||
if file_meta and getattr(file_meta, "mime_type", None):
|
||||
mime_type = file_meta.mime_type
|
||||
if not mime_type:
|
||||
if hasattr(media, "mime_type") and media.mime_type:
|
||||
mime_type = media.mime_type
|
||||
elif message.photo:
|
||||
mime_type = "image/jpeg"
|
||||
else:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
@@ -321,11 +334,16 @@ class TelegramAdapter:
|
||||
if not message or not media:
|
||||
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
|
||||
|
||||
if message.photo:
|
||||
photo_size = media.sizes[-1]
|
||||
size = photo_size.size if hasattr(photo_size, 'size') else 0
|
||||
else:
|
||||
size = media.size
|
||||
file_meta = message.file
|
||||
size = file_meta.size if file_meta and file_meta.size is not None else None
|
||||
if size is None:
|
||||
if hasattr(media, "size") and media.size is not None:
|
||||
size = media.size
|
||||
elif message.photo and getattr(message.photo, "sizes", None):
|
||||
photo_size = message.photo.sizes[-1]
|
||||
size = getattr(photo_size, "size", 0) or 0
|
||||
else:
|
||||
size = 0
|
||||
|
||||
return {
|
||||
"name": rel,
|
||||
@@ -339,4 +357,4 @@ class TelegramAdapter:
|
||||
await client.disconnect()
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
|
||||
return TelegramAdapter(rec)
|
||||
return TelegramAdapter(rec)
|
||||
|
||||
283
services/ai.py
283
services/ai.py
@@ -1,70 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from typing import List
|
||||
from services.config import ConfigCenter
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
from models.database import AIModel, AIProvider
|
||||
from services.ai_providers import AIProviderService
|
||||
|
||||
|
||||
provider_service = AIProviderService()
|
||||
|
||||
|
||||
class MissingModelError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
async def describe_image_base64(base64_image: str, detail: str = "high") -> str:
|
||||
"""
|
||||
传入base64图片和文本提示,返回图片描述文本。
|
||||
传入 base64 图片并返回描述文本。缺省时返回错误提示。
|
||||
"""
|
||||
OAI_API_URL = await ConfigCenter.get("AI_VISION_API_URL")
|
||||
VISION_MODEL = await ConfigCenter.get("AI_VISION_MODEL")
|
||||
API_KEY = await ConfigCenter.get("AI_VISION_API_KEY")
|
||||
payload = {
|
||||
"model": VISION_MODEL,
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": detail
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "描述这个图片"
|
||||
}
|
||||
]}
|
||||
]
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(OAI_API_URL, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result["choices"][0]["message"]["content"]
|
||||
model, provider = await _require_model("vision")
|
||||
if provider.api_format == "openai":
|
||||
return await _describe_with_openai(provider, model, base64_image, detail)
|
||||
return await _describe_with_gemini(provider, model, base64_image, detail)
|
||||
except MissingModelError as exc:
|
||||
return str(exc)
|
||||
except httpx.ReadTimeout:
|
||||
return "请求超时,请稍后重试。"
|
||||
except Exception as e:
|
||||
return f"请求失败: {str(e)}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"请求失败: {exc}"
|
||||
|
||||
|
||||
async def get_text_embedding(text: str) -> List[float]:
|
||||
"""
|
||||
传入文本,返回嵌入向量。
|
||||
传入文本,返回嵌入向量。若未配置模型则抛出异常。
|
||||
"""
|
||||
OAI_API_URL = await ConfigCenter.get("AI_EMBED_API_URL")
|
||||
EMBED_MODEL = await ConfigCenter.get("AI_EMBED_MODEL")
|
||||
API_KEY = await ConfigCenter.get("AI_EMBED_API_KEY")
|
||||
model, provider = await _require_model("embedding")
|
||||
if provider.api_format == "openai":
|
||||
return await _embedding_with_openai(provider, model, text)
|
||||
return await _embedding_with_gemini(provider, model, text)
|
||||
|
||||
|
||||
async def rerank_texts(query: str, documents: Sequence[str]) -> List[float]:
|
||||
"""调用重排序模型,为一组文档返回得分。未配置时返回空列表。"""
|
||||
if not documents:
|
||||
return []
|
||||
try:
|
||||
model, provider = await _require_model("rerank")
|
||||
except MissingModelError:
|
||||
return []
|
||||
|
||||
try:
|
||||
if provider.api_format == "openai":
|
||||
return await _rerank_with_openai(provider, model, query, documents)
|
||||
return await _rerank_with_gemini(provider, model, query, documents)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
async def _require_model(ability: str) -> Tuple[AIModel, AIProvider]:
|
||||
model = await provider_service.get_default_model(ability)
|
||||
if not model:
|
||||
raise MissingModelError(f"未配置默认 {ability} 模型,请前往系统设置完成配置。")
|
||||
provider = getattr(model, "provider", None)
|
||||
if provider is None:
|
||||
await model.fetch_related("provider")
|
||||
provider = model.provider
|
||||
if provider is None:
|
||||
raise MissingModelError("模型缺少关联的提供商配置。")
|
||||
if not provider.base_url:
|
||||
raise MissingModelError("该提供商未设置 API 地址。")
|
||||
return model, provider
|
||||
|
||||
|
||||
def _openai_endpoint(provider: AIProvider, path: str) -> str:
|
||||
base = (provider.base_url or "").rstrip("/")
|
||||
if not base:
|
||||
raise MissingModelError("提供商 API 地址未配置。")
|
||||
return f"{base}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _openai_headers(provider: AIProvider) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def _gemini_endpoint(provider: AIProvider, path: str) -> str:
|
||||
base = (provider.base_url or "").rstrip("/")
|
||||
if not base:
|
||||
raise MissingModelError("提供商 API 地址未配置。")
|
||||
url = f"{base}/{path.lstrip('/')}"
|
||||
if provider.api_key:
|
||||
connector = "&" if "?" in url else "?"
|
||||
url = f"{url}{connector}key={provider.api_key}"
|
||||
return url
|
||||
|
||||
|
||||
async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
|
||||
url = _openai_endpoint(provider, "/chat/completions")
|
||||
payload = {
|
||||
"model": EMBED_MODEL,
|
||||
"input": text
|
||||
"model": model.name,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": detail,
|
||||
},
|
||||
},
|
||||
{"type": "text", "text": "描述这个图片"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=_openai_headers(provider), json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
return body["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
async def _describe_with_gemini(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
|
||||
detail_text = f"描述这个图片,细节等级:{detail}"
|
||||
model_name = model.name if model.name.startswith("models/") else f"models/{model.name}"
|
||||
url = _gemini_endpoint(provider, f"{model_name}:generateContent")
|
||||
payload = {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "image/jpeg",
|
||||
"data": base64_image,
|
||||
}
|
||||
},
|
||||
{"text": detail_text},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
if OAI_API_URL.endswith("chat/completions"):
|
||||
url = OAI_API_URL.replace("chat/completions", "embeddings")
|
||||
else:
|
||||
url = OAI_API_URL
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
return result["data"][0]["embedding"]
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
candidates = body.get("candidates") or []
|
||||
if not candidates:
|
||||
return ""
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
text_parts = [part.get("text") for part in parts if isinstance(part, dict) and part.get("text")]
|
||||
return "\n".join(text_parts)
|
||||
|
||||
|
||||
async def _embedding_with_openai(provider: AIProvider, model: AIModel, text: str) -> List[float]:
|
||||
url = _openai_endpoint(provider, "/embeddings")
|
||||
payload = {
|
||||
"model": model.name,
|
||||
"input": text,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=_openai_headers(provider), json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
return body["data"][0]["embedding"]
|
||||
|
||||
|
||||
async def _embedding_with_gemini(provider: AIProvider, model: AIModel, text: str) -> List[float]:
|
||||
model_name = model.name if model.name.startswith("models/") else f"models/{model.name}"
|
||||
url = _gemini_endpoint(provider, f"{model_name}:embedContent")
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"content": {
|
||||
"parts": [{"text": text}],
|
||||
},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
embedding = body.get("embedding") or {}
|
||||
return embedding.get("values") or []
|
||||
|
||||
|
||||
async def _rerank_with_openai(
|
||||
provider: AIProvider,
|
||||
model: AIModel,
|
||||
query: str,
|
||||
documents: Sequence[str],
|
||||
) -> List[float]:
|
||||
url = _openai_endpoint(provider, "/rerank")
|
||||
payload = {
|
||||
"model": model.name,
|
||||
"query": query,
|
||||
"documents": [
|
||||
{"id": str(idx), "text": content}
|
||||
for idx, content in enumerate(documents)
|
||||
],
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=_openai_headers(provider), json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
results = body.get("results") or body.get("data") or []
|
||||
scores: List[float] = []
|
||||
for item in results:
|
||||
try:
|
||||
scores.append(float(item.get("score", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
scores.append(0.0)
|
||||
return scores
|
||||
|
||||
|
||||
async def _rerank_with_gemini(
|
||||
provider: AIProvider,
|
||||
model: AIModel,
|
||||
query: str,
|
||||
documents: Sequence[str],
|
||||
) -> List[float]:
|
||||
model_name = model.name if model.name.startswith("models/") else f"models/{model.name}"
|
||||
url = _gemini_endpoint(provider, f"{model_name}:rankContent")
|
||||
payload = {
|
||||
"query": {"text": query},
|
||||
"documents": [
|
||||
{"id": str(idx), "content": {"parts": [{"text": content}]}}
|
||||
for idx, content in enumerate(documents)
|
||||
],
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
|
||||
scores: List[float] = []
|
||||
ranked = body.get("rankedDocuments") or body.get("results") or []
|
||||
for item in ranked:
|
||||
raw_score = item.get("relevanceScore") or item.get("score") or item.get("confidenceScore")
|
||||
try:
|
||||
scores.append(float(raw_score))
|
||||
except (TypeError, ValueError):
|
||||
scores.append(0.0)
|
||||
return scores
|
||||
|
||||
347
services/ai_providers.py
Normal file
347
services/ai_providers.py
Normal file
@@ -0,0 +1,347 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from tortoise.exceptions import DoesNotExist
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from models.database import AIDefaultModel, AIModel, AIProvider
|
||||
|
||||
|
||||
ABILITIES = ["chat", "vision", "embedding", "rerank", "voice", "tools"]
|
||||
|
||||
OPENAI_EMBEDDING_DIMS = {
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-ada-002": 1536,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_embedding_dim(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
casted = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return casted if casted > 0 else None
|
||||
|
||||
|
||||
def _apply_embedding_dim_to_metadata(
|
||||
data: Dict[str, Any],
|
||||
embedding_dim: Optional[int],
|
||||
base_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = base_metadata if isinstance(base_metadata, dict) else {}
|
||||
metadata: Dict[str, Any] = dict(source)
|
||||
override = data.get("metadata")
|
||||
if isinstance(override, dict) and override:
|
||||
metadata.update(override)
|
||||
if embedding_dim is None:
|
||||
metadata.pop("embedding_dimensions", None)
|
||||
else:
|
||||
metadata["embedding_dimensions"] = embedding_dim
|
||||
data["metadata"] = metadata or None
|
||||
return data
|
||||
|
||||
|
||||
def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]:
|
||||
if not items:
|
||||
return []
|
||||
normalized = []
|
||||
for cap in items:
|
||||
key = str(cap).strip().lower()
|
||||
if key in ABILITIES and key not in normalized:
|
||||
normalized.append(key)
|
||||
return normalized
|
||||
|
||||
|
||||
def infer_openai_capabilities(model_id: str) -> Tuple[List[str], Optional[int]]:
|
||||
lower = model_id.lower()
|
||||
caps = set()
|
||||
|
||||
if any(keyword in lower for keyword in ["gpt", "chat", "turbo", "o1", "sonnet", "haiku", "thinking"]):
|
||||
caps.update({"chat", "tools"})
|
||||
|
||||
if any(keyword in lower for keyword in ["vision", "gpt-4o", "gpt-4.1", "o1", "vision-preview", "omni"]):
|
||||
caps.add("vision")
|
||||
|
||||
if any(keyword in lower for keyword in ["embed", "embedding"]):
|
||||
caps.add("embedding")
|
||||
|
||||
if "rerank" in lower or "re-rank" in lower:
|
||||
caps.add("rerank")
|
||||
|
||||
if any(keyword in lower for keyword in ["tts", "speech", "audio"]):
|
||||
caps.add("voice")
|
||||
|
||||
embedding_dim = OPENAI_EMBEDDING_DIMS.get(model_id)
|
||||
return normalize_capabilities(caps), embedding_dim
|
||||
|
||||
|
||||
def infer_gemini_capabilities(methods: Iterable[str]) -> List[str]:
|
||||
caps = set()
|
||||
for method in methods:
|
||||
m = method.lower()
|
||||
if m in {"generatecontent", "counttokens"}:
|
||||
caps.update({"chat", "tools", "vision"})
|
||||
if m == "embedcontent":
|
||||
caps.add("embedding")
|
||||
if m in {"generatespeech", "audiogeneration"}:
|
||||
caps.add("voice")
|
||||
if m == "rerank":
|
||||
caps.add("rerank")
|
||||
return normalize_capabilities(caps)
|
||||
|
||||
|
||||
def serialize_provider(provider: AIProvider) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"identifier": provider.identifier,
|
||||
"provider_type": provider.provider_type,
|
||||
"api_format": provider.api_format,
|
||||
"base_url": provider.base_url,
|
||||
"api_key": provider.api_key,
|
||||
"logo_url": provider.logo_url,
|
||||
"extra_config": provider.extra_config or {},
|
||||
"created_at": provider.created_at,
|
||||
"updated_at": provider.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def model_to_dict(model: AIModel, provider: Optional[AIProvider] = None) -> Dict[str, Any]:
|
||||
provider_obj = provider or getattr(model, "provider", None)
|
||||
provider_data = serialize_provider(provider_obj) if provider_obj else None
|
||||
return {
|
||||
"id": model.id,
|
||||
"provider_id": model.provider_id,
|
||||
"name": model.name,
|
||||
"display_name": model.display_name,
|
||||
"description": model.description,
|
||||
"capabilities": normalize_capabilities(model.capabilities),
|
||||
"context_window": model.context_window,
|
||||
"embedding_dimensions": model.embedding_dimensions,
|
||||
"metadata": model.metadata or {},
|
||||
"created_at": model.created_at,
|
||||
"updated_at": model.updated_at,
|
||||
"provider": provider_data,
|
||||
}
|
||||
|
||||
|
||||
def provider_to_dict(provider: AIProvider, models: Optional[List[AIModel]] = None) -> Dict[str, Any]:
|
||||
data = serialize_provider(provider)
|
||||
if models is not None:
|
||||
data["models"] = [model_to_dict(m, provider=provider) for m in models]
|
||||
return data
|
||||
|
||||
|
||||
class AIProviderService:
|
||||
async def list_providers(self) -> List[Dict[str, Any]]:
|
||||
providers = await AIProvider.all().order_by("id").prefetch_related("models")
|
||||
return [provider_to_dict(p, models=list(p.models)) for p in providers]
|
||||
|
||||
async def get_provider(self, provider_id: int, with_models: bool = False) -> Dict[str, Any]:
|
||||
if with_models:
|
||||
provider = await AIProvider.get(id=provider_id)
|
||||
models = await provider.models.all()
|
||||
return provider_to_dict(provider, models=models)
|
||||
else:
|
||||
provider = await AIProvider.get(id=provider_id)
|
||||
return provider_to_dict(provider)
|
||||
|
||||
async def create_provider(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
data = payload.copy()
|
||||
data.setdefault("extra_config", {})
|
||||
provider = await AIProvider.create(**data)
|
||||
return provider_to_dict(provider)
|
||||
|
||||
async def update_provider(self, provider_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
provider = await AIProvider.get(id=provider_id)
|
||||
for field, value in payload.items():
|
||||
setattr(provider, field, value)
|
||||
await provider.save()
|
||||
return provider_to_dict(provider)
|
||||
|
||||
async def delete_provider(self, provider_id: int) -> None:
|
||||
await AIProvider.filter(id=provider_id).delete()
|
||||
|
||||
async def list_models(self, provider_id: int) -> List[Dict[str, Any]]:
|
||||
models = await AIModel.filter(provider_id=provider_id).order_by("id").prefetch_related("provider")
|
||||
return [model_to_dict(m) for m in models]
|
||||
|
||||
async def create_model(self, provider_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
data = payload.copy()
|
||||
data["provider_id"] = provider_id
|
||||
data["capabilities"] = normalize_capabilities(data.get("capabilities"))
|
||||
embedding_dim = _normalize_embedding_dim(data.pop("embedding_dimensions", None))
|
||||
data = _apply_embedding_dim_to_metadata(data, embedding_dim)
|
||||
model = await AIModel.create(**data)
|
||||
await model.fetch_related("provider")
|
||||
return model_to_dict(model)
|
||||
|
||||
async def update_model(self, model_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model = await AIModel.get(id=model_id)
|
||||
data = payload.copy()
|
||||
if "capabilities" in data:
|
||||
data["capabilities"] = normalize_capabilities(data.get("capabilities"))
|
||||
embedding_dim = None
|
||||
if "embedding_dimensions" in data:
|
||||
embedding_dim = _normalize_embedding_dim(data.pop("embedding_dimensions", None))
|
||||
_apply_embedding_dim_to_metadata(data, embedding_dim, base_metadata=model.metadata)
|
||||
for field, value in data.items():
|
||||
setattr(model, field, value)
|
||||
if embedding_dim is not None or ("embedding_dimensions" in payload and embedding_dim is None):
|
||||
model.embedding_dimensions = embedding_dim
|
||||
await model.save()
|
||||
await model.fetch_related("provider")
|
||||
return model_to_dict(model)
|
||||
|
||||
async def delete_model(self, model_id: int) -> None:
|
||||
await AIModel.filter(id=model_id).delete()
|
||||
|
||||
async def fetch_remote_models(self, provider_id: int) -> List[Dict[str, Any]]:
|
||||
provider = await AIProvider.get(id=provider_id)
|
||||
return await self._get_remote_models(provider)
|
||||
|
||||
async def _get_remote_models(self, provider: AIProvider) -> List[Dict[str, Any]]:
|
||||
if not provider.base_url:
|
||||
raise ValueError("Provider base_url is required for syncing models")
|
||||
|
||||
fmt = (provider.api_format or "").lower()
|
||||
if fmt not in {"openai", "gemini"}:
|
||||
raise ValueError(f"Unsupported api_format '{provider.api_format}' for syncing models")
|
||||
|
||||
if fmt == "openai":
|
||||
return await self._fetch_openai_models(provider)
|
||||
return await self._fetch_gemini_models(provider)
|
||||
|
||||
async def sync_models(self, provider_id: int) -> Dict[str, int]:
|
||||
provider = await AIProvider.get(id=provider_id)
|
||||
remote_models = await self._get_remote_models(provider)
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
for entry in remote_models:
|
||||
defaults = entry.copy()
|
||||
model_id = defaults.pop("name")
|
||||
defaults["capabilities"] = normalize_capabilities(defaults.get("capabilities"))
|
||||
embedding_dim = _normalize_embedding_dim(defaults.pop("embedding_dimensions", None))
|
||||
defaults = _apply_embedding_dim_to_metadata(defaults, embedding_dim)
|
||||
obj, is_created = await AIModel.get_or_create(
|
||||
provider_id=provider.id,
|
||||
name=model_id,
|
||||
defaults=defaults,
|
||||
)
|
||||
if is_created:
|
||||
created += 1
|
||||
continue
|
||||
for field, value in defaults.items():
|
||||
setattr(obj, field, value)
|
||||
if embedding_dim is not None or ("embedding_dimensions" in entry and embedding_dim is None):
|
||||
obj.embedding_dimensions = embedding_dim
|
||||
await obj.save()
|
||||
updated += 1
|
||||
|
||||
return {"created": created, "updated": updated}
|
||||
|
||||
async def get_default_models(self) -> Dict[str, Optional[Dict[str, Any]]]:
|
||||
defaults = await AIDefaultModel.all().prefetch_related("model__provider")
|
||||
result: Dict[str, Optional[Dict[str, Any]]] = {ability: None for ability in ABILITIES}
|
||||
for item in defaults:
|
||||
result[item.ability] = model_to_dict(item.model, provider=item.model.provider) # type: ignore[attr-defined]
|
||||
return result
|
||||
|
||||
async def set_default_models(self, mapping: Dict[str, Optional[int]]) -> Dict[str, Optional[Dict[str, Any]]]:
|
||||
normalized = {ability: mapping.get(ability) for ability in ABILITIES}
|
||||
async with in_transaction() as connection:
|
||||
for ability, model_id in normalized.items():
|
||||
record = await AIDefaultModel.get_or_none(ability=ability)
|
||||
if model_id:
|
||||
try:
|
||||
model = await AIModel.get(id=model_id)
|
||||
except DoesNotExist:
|
||||
raise ValueError(f"Model {model_id} not found")
|
||||
if record:
|
||||
record.model_id = model_id
|
||||
await record.save(using_db=connection)
|
||||
else:
|
||||
await AIDefaultModel.create(ability=ability, model_id=model_id)
|
||||
elif record:
|
||||
await record.delete(using_db=connection)
|
||||
return await self.get_default_models()
|
||||
|
||||
async def get_default_model(self, ability: str) -> Optional[AIModel]:
|
||||
ability_key = ability.lower()
|
||||
if ability_key not in ABILITIES:
|
||||
return None
|
||||
record = await AIDefaultModel.get_or_none(ability=ability_key)
|
||||
if not record:
|
||||
return None
|
||||
model = await AIModel.get_or_none(id=record.model_id)
|
||||
if model:
|
||||
await model.fetch_related("provider")
|
||||
return model
|
||||
|
||||
async def _fetch_openai_models(self, provider: AIProvider) -> List[Dict[str, Any]]:
|
||||
base_url = provider.base_url.rstrip("/")
|
||||
url = f"{base_url}/models"
|
||||
headers = {}
|
||||
if provider.api_key:
|
||||
headers["Authorization"] = f"Bearer {provider.api_key}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
data = payload.get("data", [])
|
||||
entries: List[Dict[str, Any]] = []
|
||||
for item in data:
|
||||
model_id = item.get("id")
|
||||
if not model_id:
|
||||
continue
|
||||
capabilities, embedding_dim = infer_openai_capabilities(model_id)
|
||||
entries.append({
|
||||
"name": model_id,
|
||||
"display_name": item.get("display_name"),
|
||||
"description": item.get("description"),
|
||||
"capabilities": capabilities,
|
||||
"context_window": item.get("context_window"),
|
||||
"embedding_dimensions": embedding_dim,
|
||||
"metadata": item,
|
||||
})
|
||||
return entries
|
||||
|
||||
async def _fetch_gemini_models(self, provider: AIProvider) -> List[Dict[str, Any]]:
|
||||
base_url = provider.base_url.rstrip("/")
|
||||
suffix = "/models"
|
||||
if provider.api_key:
|
||||
suffix += f"?key={provider.api_key}"
|
||||
url = f"{base_url}{suffix}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
data = payload.get("models", [])
|
||||
entries: List[Dict[str, Any]] = []
|
||||
for item in data:
|
||||
model_id = item.get("name")
|
||||
if not model_id:
|
||||
continue
|
||||
methods = item.get("supportedGenerationMethods") or []
|
||||
capabilities = infer_gemini_capabilities(methods)
|
||||
entries.append({
|
||||
"name": model_id,
|
||||
"display_name": item.get("displayName"),
|
||||
"description": item.get("description"),
|
||||
"capabilities": capabilities,
|
||||
"context_window": item.get("inputTokenLimit"),
|
||||
"embedding_dimensions": item.get("embeddingDimensions"),
|
||||
"metadata": item,
|
||||
})
|
||||
return entries
|
||||
160
services/auth.py
160
services/auth.py
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
@@ -10,9 +13,78 @@ from pydantic import BaseModel
|
||||
|
||||
from models.database import UserAccount
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 365
|
||||
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 10
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordResetEntry:
|
||||
user_id: int
|
||||
email: str
|
||||
username: str
|
||||
expires_at: datetime
|
||||
used: bool = False
|
||||
|
||||
|
||||
class PasswordResetStore:
|
||||
_tokens: dict[str, PasswordResetEntry] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def _cleanup(cls):
|
||||
now = _now()
|
||||
for token, record in list(cls._tokens.items()):
|
||||
if record.used or record.expires_at < now:
|
||||
cls._tokens.pop(token, None)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, user: UserAccount) -> str:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user.id:
|
||||
cls._tokens.pop(key, None)
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = _now() + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES)
|
||||
cls._tokens[token] = PasswordResetEntry(
|
||||
user_id=user.id,
|
||||
email=user.email or "",
|
||||
username=user.username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
async def get(cls, token: str) -> PasswordResetEntry | None:
|
||||
async with cls._lock:
|
||||
cls._cleanup()
|
||||
record = cls._tokens.get(token)
|
||||
if not record or record.used:
|
||||
return None
|
||||
return record
|
||||
|
||||
@classmethod
|
||||
async def mark_used(cls, token: str) -> None:
|
||||
async with cls._lock:
|
||||
record = cls._tokens.get(token)
|
||||
if record:
|
||||
record.used = True
|
||||
cls._cleanup()
|
||||
|
||||
@classmethod
|
||||
async def invalidate_user(cls, user_id: int, except_token: str | None = None) -> None:
|
||||
async with cls._lock:
|
||||
for key, record in list(cls._tokens.items()):
|
||||
if record.user_id == user_id and key != except_token:
|
||||
cls._tokens.pop(key, None)
|
||||
cls._cleanup()
|
||||
|
||||
|
||||
async def get_secret_key():
|
||||
@@ -132,6 +204,94 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def _normalize_email(email: str | None) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
async def _send_password_reset_email(user: UserAccount, token: str) -> None:
|
||||
from services.email import EmailService
|
||||
|
||||
app_domain = await ConfigCenter.get("APP_DOMAIN", None)
|
||||
base_url = (app_domain or "http://localhost:5173").rstrip("/")
|
||||
reset_link = f"{base_url}/reset-password?token={token}"
|
||||
await EmailService.enqueue_email(
|
||||
recipients=[user.email],
|
||||
subject="Foxel 密码重置",
|
||||
template="password_reset",
|
||||
context={
|
||||
"username": user.username,
|
||||
"reset_link": reset_link,
|
||||
"expire_minutes": PASSWORD_RESET_TOKEN_EXPIRE_MINUTES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def request_password_reset(email: str) -> bool:
|
||||
normalized = _normalize_email(email)
|
||||
if not normalized:
|
||||
return False
|
||||
user = await UserAccount.get_or_none(email=normalized)
|
||||
if not user or not user.email:
|
||||
return False
|
||||
|
||||
token = await PasswordResetStore.create(user)
|
||||
try:
|
||||
await _send_password_reset_email(user, token)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.error(
|
||||
"auth",
|
||||
f"Failed to enqueue password reset email: {exc}",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="邮件发送失败") from exc
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset requested",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def verify_password_reset_token(token: str) -> UserAccount:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
return user
|
||||
|
||||
|
||||
async def reset_password_with_token(token: str, new_password: str) -> None:
|
||||
record = await PasswordResetStore.get(token)
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
if record.expires_at < _now():
|
||||
await PasswordResetStore.mark_used(token)
|
||||
raise HTTPException(status_code=400, detail="重置链接已过期")
|
||||
|
||||
user = await UserAccount.get_or_none(id=record.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效")
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
await user.save(update_fields=["hashed_password"])
|
||||
await PasswordResetStore.mark_used(token)
|
||||
await PasswordResetStore.invalidate_user(user.id)
|
||||
await LogService.action(
|
||||
"auth",
|
||||
"Password reset via email",
|
||||
details={"user_id": user.id},
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
||||
from dotenv import load_dotenv
|
||||
from models.database import Configuration
|
||||
load_dotenv(dotenv_path=".env")
|
||||
VERSION = "v1.2.2"
|
||||
VERSION = "v1.3.6"
|
||||
|
||||
class ConfigCenter:
|
||||
_cache: Dict[str, Any] = {}
|
||||
|
||||
201
services/email.py
Normal file
201
services/email.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ValidationError
|
||||
|
||||
from services.config import ConfigCenter
|
||||
from services.logging import LogService
|
||||
|
||||
|
||||
class EmailSecurity(str, Enum):
|
||||
NONE = "none"
|
||||
SSL = "ssl"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class EmailConfig(BaseModel):
|
||||
host: str
|
||||
port: int = Field(..., gt=0)
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
sender_email: EmailStr
|
||||
sender_name: Optional[str] = None
|
||||
security: EmailSecurity = EmailSecurity.NONE
|
||||
timeout: float = Field(default=30.0, gt=0.0)
|
||||
|
||||
|
||||
class EmailSendPayload(BaseModel):
|
||||
recipients: List[EmailStr] = Field(..., min_length=1)
|
||||
subject: str = Field(..., min_length=1)
|
||||
template: str = Field(..., min_length=1)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EmailTemplateRenderer:
|
||||
ROOT = Path("templates/email")
|
||||
|
||||
@classmethod
|
||||
def _resolve_path(cls, template_name: str) -> Path:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_\-]+", template_name):
|
||||
raise ValueError("Invalid template name")
|
||||
return cls.ROOT / f"{template_name}.html"
|
||||
|
||||
@classmethod
|
||||
async def list_templates(cls) -> list[str]:
|
||||
cls.ROOT.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(
|
||||
path.stem
|
||||
for path in cls.ROOT.glob("*.html")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, template_name: str) -> str:
|
||||
path = cls._resolve_path(template_name)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Email template '{template_name}' not found")
|
||||
return await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def save(cls, template_name: str, content: str) -> None:
|
||||
path = cls._resolve_path(template_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(path.write_text, content, encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
async def render(cls, template_name: str, context: Dict[str, Any]) -> str:
|
||||
raw = await cls.load(template_name)
|
||||
context = {k: str(v) for k, v in (context or {}).items()}
|
||||
return Template(raw).safe_substitute(context)
|
||||
|
||||
|
||||
class EmailService:
|
||||
CONFIG_KEY = "EMAIL_CONFIG"
|
||||
|
||||
@classmethod
|
||||
async def _load_config(cls) -> EmailConfig:
|
||||
raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
|
||||
if raw_config is None:
|
||||
raise ValueError("Email configuration not found")
|
||||
|
||||
if isinstance(raw_config, str):
|
||||
raw_config = raw_config.strip()
|
||||
data: Any = json.loads(raw_config) if raw_config else {}
|
||||
elif isinstance(raw_config, dict):
|
||||
data = raw_config
|
||||
else:
|
||||
raise ValueError("Invalid email configuration format")
|
||||
|
||||
try:
|
||||
return EmailConfig(**data)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid email configuration: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(html: str) -> str:
|
||||
stripped = re.sub(r"<[^>]+>", " ", html)
|
||||
return " ".join(stripped.split())
|
||||
|
||||
@classmethod
|
||||
async def _deliver(cls, config: EmailConfig, payload: EmailSendPayload, html_body: str):
|
||||
message = EmailMessage()
|
||||
message["Subject"] = payload.subject
|
||||
message["From"] = formataddr((config.sender_name or str(config.sender_email), str(config.sender_email)))
|
||||
message["To"] = ", ".join([str(addr) for addr in payload.recipients])
|
||||
|
||||
plain_body = cls._html_to_text(html_body)
|
||||
message.set_content(plain_body or html_body)
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
await asyncio.to_thread(cls._deliver_sync, config, message)
|
||||
|
||||
@staticmethod
|
||||
def _deliver_sync(config: EmailConfig, message: EmailMessage):
|
||||
if config.security == EmailSecurity.SSL:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(config.host, config.port, timeout=config.timeout)
|
||||
else:
|
||||
smtp = smtplib.SMTP(config.host, config.port, timeout=config.timeout)
|
||||
|
||||
try:
|
||||
if config.security == EmailSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
if config.username and config.password:
|
||||
smtp.login(config.username, config.password)
|
||||
smtp.send_message(message)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def enqueue_email(
|
||||
cls,
|
||||
recipients: List[str],
|
||||
subject: str,
|
||||
template: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
template=template,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
task = await task_queue_service.add_task(
|
||||
"send_email",
|
||||
payload.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(stage="queued", percent=0.0, detail="Waiting to send"),
|
||||
)
|
||||
await LogService.action(
|
||||
"email_service",
|
||||
"Email task enqueued",
|
||||
details={"task_id": task.id, "subject": subject, "template": template},
|
||||
)
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
async def send_from_task(cls, task_id: str, data: Dict[str, Any]):
|
||||
from services.task_queue import TaskProgress, task_queue_service
|
||||
|
||||
payload = EmailSendPayload(**data)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="preparing", percent=10.0, detail="Rendering template"),
|
||||
)
|
||||
|
||||
config = await cls._load_config()
|
||||
html_body = await EmailTemplateRenderer.render(payload.template, payload.context)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="sending", percent=60.0, detail="Sending message"),
|
||||
)
|
||||
|
||||
await cls._deliver(config, payload, html_body)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task_id,
|
||||
TaskProgress(stage="completed", percent=100.0, detail="Email sent"),
|
||||
)
|
||||
await LogService.info(
|
||||
"email_service",
|
||||
"Email sent",
|
||||
details={"task_id": task_id, "subject": payload.subject},
|
||||
)
|
||||
199
services/offline_download.py
Normal file
199
services/offline_download.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.logging import LogService
|
||||
from services.task_queue import Task, task_queue_service, TaskProgress
|
||||
from services.virtual_fs import write_file_stream, stat_file
|
||||
|
||||
|
||||
TEMP_ROOT = Path("data/tmp/offline_downloads")
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
if not path:
|
||||
return "/"
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if len(path) > 1 and path.endswith("/"):
|
||||
path = path.rstrip("/")
|
||||
return path or "/"
|
||||
|
||||
|
||||
async def _path_exists(full_path: str) -> bool:
|
||||
try:
|
||||
await stat_file(full_path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def _split_filename(filename: str) -> tuple[str, str]:
|
||||
if not filename:
|
||||
return "", ""
|
||||
if filename.startswith('.') and filename.count('.') == 1:
|
||||
return filename, ""
|
||||
if '.' not in filename:
|
||||
return filename, ""
|
||||
stem, ext = filename.rsplit('.', 1)
|
||||
return stem, f".{ext}"
|
||||
|
||||
|
||||
async def _allocate_destination(dest_dir: str, filename: str) -> tuple[str, str]:
|
||||
dest_dir = _normalize_path(dest_dir)
|
||||
stem, suffix = _split_filename(filename)
|
||||
candidate = filename
|
||||
if dest_dir == "/":
|
||||
base = ""
|
||||
else:
|
||||
base = dest_dir
|
||||
attempt = 0
|
||||
while await _path_exists(f"{base}/{candidate}" if base else f"/{candidate}"):
|
||||
attempt += 1
|
||||
if stem:
|
||||
candidate = f"{stem} ({attempt}){suffix}"
|
||||
else:
|
||||
candidate = f"file ({attempt}){suffix}" if suffix else f"file ({attempt})"
|
||||
if base:
|
||||
full_path = f"{base}/{candidate}"
|
||||
else:
|
||||
full_path = f"/{candidate}"
|
||||
return full_path, candidate
|
||||
|
||||
|
||||
async def _iter_file(path: Path, chunk_size: int, report_cb) -> AsyncIterator[bytes]:
|
||||
async with aiofiles.open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
await report_cb(len(chunk))
|
||||
yield chunk
|
||||
|
||||
|
||||
async def run_http_download(task: Task):
|
||||
params = task.task_info
|
||||
url = params.get("url")
|
||||
dest_dir = params.get("dest_dir")
|
||||
filename = params.get("filename")
|
||||
|
||||
if not url or not dest_dir or not filename:
|
||||
raise ValueError("Missing required parameters for offline download")
|
||||
|
||||
TEMP_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = TEMP_ROOT / task.id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file = temp_dir / "payload"
|
||||
|
||||
bytes_total: int | None = None
|
||||
bytes_done = 0
|
||||
|
||||
last_update = time.monotonic()
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(
|
||||
stage="downloading",
|
||||
percent=0.0,
|
||||
bytes_total=None,
|
||||
bytes_done=0,
|
||||
detail="HTTP downloading",
|
||||
),
|
||||
)
|
||||
|
||||
async def report_download(delta: int, total: int | None):
|
||||
nonlocal bytes_done, bytes_total, last_update
|
||||
if total is not None:
|
||||
bytes_total = total
|
||||
bytes_done += delta
|
||||
now = time.monotonic()
|
||||
if delta and now - last_update < 0.5:
|
||||
return
|
||||
last_update = now
|
||||
percent = None
|
||||
total_for_display = bytes_total if bytes_total is not None else None
|
||||
if bytes_total:
|
||||
percent = min(100.0, round(bytes_done / bytes_total * 100, 2))
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(
|
||||
stage="downloading",
|
||||
percent=percent,
|
||||
bytes_total=total_for_display,
|
||||
bytes_done=bytes_done,
|
||||
detail="HTTP downloading",
|
||||
),
|
||||
)
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=None, connect=30)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
raise ValueError(f"HTTP {resp.status} for {url}")
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
total_size = int(content_length) if content_length else None
|
||||
bytes_done = 0
|
||||
async with aiofiles.open(temp_file, "wb") as f:
|
||||
async for chunk in resp.content.iter_chunked(512 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
await f.write(chunk)
|
||||
await report_download(len(chunk), total_size)
|
||||
# ensure final update
|
||||
await report_download(0, total_size)
|
||||
|
||||
file_size = os.path.getsize(temp_file)
|
||||
|
||||
bytes_done_transfer = 0
|
||||
|
||||
async def report_transfer(delta: int):
|
||||
nonlocal bytes_done_transfer
|
||||
bytes_done_transfer += delta
|
||||
percent = min(100.0, round(bytes_done_transfer / file_size * 100, 2)) if file_size else None
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(
|
||||
stage="transferring",
|
||||
percent=percent,
|
||||
bytes_total=file_size or None,
|
||||
bytes_done=bytes_done_transfer,
|
||||
detail="Saving to storage",
|
||||
),
|
||||
)
|
||||
|
||||
async def chunk_iter() -> AsyncIterator[bytes]:
|
||||
async for chunk in _iter_file(temp_file, 512 * 1024, report_transfer):
|
||||
yield chunk
|
||||
|
||||
final_path, resolved_name = await _allocate_destination(dest_dir, filename)
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(stage="transferring", percent=0.0, bytes_total=file_size or None, bytes_done=0, detail="Saving to storage"),
|
||||
)
|
||||
|
||||
await write_file_stream(final_path, chunk_iter())
|
||||
|
||||
await task_queue_service.update_progress(
|
||||
task.id,
|
||||
TaskProgress(stage="completed", percent=100.0, bytes_total=file_size or None, bytes_done=file_size, detail="Completed"),
|
||||
)
|
||||
await task_queue_service.update_meta(task.id, {"final_path": final_path, "filename": resolved_name})
|
||||
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
temp_dir.rmdir()
|
||||
except Exception:
|
||||
await LogService.info("offline_download", f"Temp cleanup failed for task {task.id}")
|
||||
|
||||
return final_path
|
||||
@@ -1,33 +1,53 @@
|
||||
import pkgutil
|
||||
import inspect
|
||||
from importlib import import_module
|
||||
from typing import Dict, Callable
|
||||
import pkgutil
|
||||
from importlib import import_module, reload
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from .base import BaseProcessor
|
||||
|
||||
ProcessorFactory = Callable[[], BaseProcessor]
|
||||
TYPE_MAP: Dict[str, ProcessorFactory] = {}
|
||||
CONFIG_SCHEMAS: Dict[str, dict] = {}
|
||||
MODULE_MAP: Dict[str, ModuleType] = {}
|
||||
LAST_DISCOVERY_ERRORS: list[str] = []
|
||||
|
||||
|
||||
def discover_processors(force_reload: bool = False) -> list[str]:
|
||||
"""Discover available processor modules and cache their metadata."""
|
||||
import services.processors # 延迟导入以避免循环
|
||||
|
||||
def discover_processors():
|
||||
import services.processors
|
||||
processors_pkg = services.processors
|
||||
TYPE_MAP.clear()
|
||||
CONFIG_SCHEMAS.clear()
|
||||
MODULE_MAP.clear()
|
||||
|
||||
global LAST_DISCOVERY_ERRORS
|
||||
LAST_DISCOVERY_ERRORS = []
|
||||
|
||||
for modinfo in pkgutil.iter_modules(processors_pkg.__path__):
|
||||
if modinfo.name.startswith("_"):
|
||||
continue
|
||||
|
||||
full_name = f"{processors_pkg.__name__}.{modinfo.name}"
|
||||
try:
|
||||
module = import_module(full_name)
|
||||
except Exception:
|
||||
if force_reload:
|
||||
module = reload(module)
|
||||
except Exception as exc:
|
||||
LAST_DISCOVERY_ERRORS.append(f"Failed to import {full_name}: {exc}")
|
||||
continue
|
||||
|
||||
processor_type = getattr(module, "PROCESSOR_TYPE", None)
|
||||
processor_name = getattr(module, "PROCESSOR_NAME", None)
|
||||
supported_exts = getattr(module, "SUPPORTED_EXTS", None)
|
||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||
factory = getattr(module, "PROCESSOR_FACTORY", None)
|
||||
|
||||
if not processor_type:
|
||||
continue
|
||||
|
||||
if factory is None:
|
||||
for attr in module.__dict__.values():
|
||||
if inspect.isclass(attr) and attr.__name__.endswith("Processor"):
|
||||
@@ -35,31 +55,85 @@ def discover_processors():
|
||||
return lambda: cls()
|
||||
factory = _mk()
|
||||
break
|
||||
|
||||
if not callable(factory):
|
||||
LAST_DISCOVERY_ERRORS.append(f"Processor {full_name} missing factory")
|
||||
continue
|
||||
|
||||
try:
|
||||
sample = factory()
|
||||
except Exception as exc:
|
||||
LAST_DISCOVERY_ERRORS.append(f"Failed to instantiate processor {processor_type}: {exc}")
|
||||
continue
|
||||
|
||||
TYPE_MAP[processor_type] = factory
|
||||
MODULE_MAP[processor_type] = module
|
||||
|
||||
produces_file = getattr(module, "produces_file", None)
|
||||
if produces_file is None and hasattr(factory(), "produces_file"):
|
||||
produces_file = getattr(factory(), "produces_file")
|
||||
if produces_file is None and hasattr(sample, "produces_file"):
|
||||
produces_file = getattr(sample, "produces_file")
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
module_path: Optional[str] = None
|
||||
if module_file:
|
||||
try:
|
||||
module_path = str(Path(module_file).resolve())
|
||||
except Exception:
|
||||
module_path = module_file
|
||||
|
||||
if isinstance(supported_exts, list):
|
||||
normalized_exts = [str(ext) for ext in supported_exts]
|
||||
elif supported_exts:
|
||||
normalized_exts = [str(supported_exts)]
|
||||
else:
|
||||
normalized_exts = []
|
||||
|
||||
if not normalized_exts and hasattr(sample, "supported_exts"):
|
||||
sample_exts = getattr(sample, "supported_exts") or []
|
||||
if isinstance(sample_exts, list):
|
||||
normalized_exts = [str(ext) for ext in sample_exts]
|
||||
|
||||
if isinstance(schema, list):
|
||||
CONFIG_SCHEMAS[processor_type] = {
|
||||
"type": processor_type,
|
||||
"name": processor_name or processor_type,
|
||||
"supported_exts": supported_exts or [],
|
||||
"supported_exts": normalized_exts,
|
||||
"config_schema": schema,
|
||||
"produces_file": produces_file if produces_file is not None else False
|
||||
"produces_file": produces_file if produces_file is not None else False,
|
||||
"module_path": module_path,
|
||||
}
|
||||
|
||||
return LAST_DISCOVERY_ERRORS
|
||||
|
||||
|
||||
def get_config_schemas() -> Dict[str, dict]:
|
||||
return CONFIG_SCHEMAS
|
||||
|
||||
|
||||
def get_config_schema(processor_type: str):
|
||||
return CONFIG_SCHEMAS.get(processor_type)
|
||||
|
||||
|
||||
def get(processor_type: str) -> BaseProcessor:
|
||||
factory = TYPE_MAP.get(processor_type)
|
||||
if factory:
|
||||
return factory()
|
||||
return None
|
||||
|
||||
|
||||
def get_module_path(processor_type: str) -> Optional[str]:
|
||||
meta = CONFIG_SCHEMAS.get(processor_type)
|
||||
if not meta:
|
||||
return None
|
||||
return meta.get("module_path")
|
||||
|
||||
|
||||
def get_last_discovery_errors() -> list[str]:
|
||||
return LAST_DISCOVERY_ERRORS
|
||||
|
||||
|
||||
def reload_processors() -> list[str]:
|
||||
return discover_processors(force_reload=True)
|
||||
|
||||
|
||||
discover_processors()
|
||||
|
||||
@@ -1,14 +1,95 @@
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from fastapi.responses import Response
|
||||
import base64
|
||||
from services.ai import describe_image_base64, get_text_embedding
|
||||
from services.vector_db import VectorDBService
|
||||
import mimetypes
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
from services.ai import describe_image_base64, get_text_embedding, provider_service
|
||||
from services.vector_db import VectorDBService, DEFAULT_VECTOR_DIMENSION
|
||||
from services.logging import LogService
|
||||
from PIL import Image
|
||||
|
||||
|
||||
|
||||
CHUNK_SIZE = 800
|
||||
CHUNK_OVERLAP = 200
|
||||
MAX_IMAGE_EDGE = 1600
|
||||
JPEG_QUALITY = 85
|
||||
|
||||
|
||||
def _chunk_text(content: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[Tuple[int, str, int, int]]:
|
||||
"""按固定窗口拆分文本,返回(chunk_id, chunk_text, start, end)。"""
|
||||
if chunk_size <= 0:
|
||||
chunk_size = CHUNK_SIZE
|
||||
if overlap >= chunk_size:
|
||||
overlap = max(chunk_size // 4, 1)
|
||||
|
||||
chunks: List[Tuple[int, str, int, int]] = []
|
||||
step = chunk_size - overlap
|
||||
idx = 0
|
||||
start = 0
|
||||
length = len(content)
|
||||
|
||||
while start < length:
|
||||
end = min(length, start + chunk_size)
|
||||
chunk = content[start:end].strip()
|
||||
if chunk:
|
||||
chunks.append((idx, chunk, start, end))
|
||||
idx += 1
|
||||
if end >= length:
|
||||
break
|
||||
start += step
|
||||
return chunks
|
||||
|
||||
|
||||
def _guess_mime(path: str) -> str:
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
return mime or "application/octet-stream"
|
||||
|
||||
|
||||
def _chunk_key(path: str, chunk_id: str) -> str:
|
||||
return f"{path}#chunk={chunk_id}"
|
||||
|
||||
|
||||
def _compress_image_for_embedding(input_bytes: bytes) -> Tuple[bytes, Dict[str, Any] | None]:
|
||||
"""压缩图片,降低发送到视觉模型的体积。"""
|
||||
if Image is None:
|
||||
return input_bytes, None
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(input_bytes)) as img:
|
||||
img = img.convert("RGB")
|
||||
width, height = img.size
|
||||
longest_edge = max(width, height)
|
||||
scale = 1.0
|
||||
if longest_edge > MAX_IMAGE_EDGE:
|
||||
scale = MAX_IMAGE_EDGE / float(longest_edge)
|
||||
new_size = (max(int(width * scale), 1), max(int(height * scale), 1))
|
||||
resample_mode = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
|
||||
img = img.resize(new_size, resample=resample_mode)
|
||||
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
compressed = buffer.getvalue()
|
||||
|
||||
if len(compressed) < len(input_bytes):
|
||||
return compressed, {
|
||||
"original_bytes": len(input_bytes),
|
||||
"compressed_bytes": len(compressed),
|
||||
"scaled": scale < 1.0,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
}
|
||||
except Exception: # pragma: no cover - 任意图像处理异常时回退
|
||||
return input_bytes, None
|
||||
|
||||
return input_bytes, None
|
||||
|
||||
|
||||
class VectorIndexProcessor:
|
||||
name = "向量索引"
|
||||
supported_exts = ["jpg", "jpeg", "png", "bmp", "txt", "md"]
|
||||
supported_exts: List[str] = [] # 留空表示不限扩展名
|
||||
config_schema = [
|
||||
{
|
||||
"key": "action", "label": "操作", "type": "select", "required": True, "default": "create",
|
||||
@@ -32,8 +113,9 @@ class VectorIndexProcessor:
|
||||
index_type = config.get("index_type", "vector")
|
||||
vector_db = VectorDBService()
|
||||
collection_name = "vector_collection"
|
||||
|
||||
if action == "destroy":
|
||||
vector_db.delete_vector(collection_name, path)
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
await LogService.info(
|
||||
"processor:vector_index",
|
||||
f"Destroyed {index_type} index for {path}",
|
||||
@@ -41,9 +123,19 @@ class VectorIndexProcessor:
|
||||
)
|
||||
return Response(content=f"文件 {path} 的 {index_type} 索引已销毁", media_type="text/plain")
|
||||
|
||||
if index_type == 'simple':
|
||||
vector_db.ensure_collection(collection_name, vector=False)
|
||||
vector_db.upsert_vector(collection_name, {'path': path})
|
||||
mime_type = _guess_mime(path)
|
||||
|
||||
if index_type == "simple":
|
||||
await vector_db.ensure_collection(collection_name, vector=False)
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
"path": path,
|
||||
"source_path": path,
|
||||
"chunk_id": "filename",
|
||||
"mime": mime_type,
|
||||
"type": "filename",
|
||||
"name": os.path.basename(path),
|
||||
})
|
||||
await LogService.info(
|
||||
"processor:vector_index",
|
||||
f"Created simple index for {path}",
|
||||
@@ -52,35 +144,116 @@ class VectorIndexProcessor:
|
||||
return Response(content=f"文件 {path} 的普通索引已创建", media_type="text/plain")
|
||||
|
||||
file_ext = path.split('.')[-1].lower()
|
||||
description = ""
|
||||
embedding = None
|
||||
details: Dict[str, Any] = {"path": path, "action": "create", "index_type": "vector"}
|
||||
|
||||
embedding_model = await provider_service.get_default_model("embedding")
|
||||
vector_dim = DEFAULT_VECTOR_DIMENSION
|
||||
if embedding_model and getattr(embedding_model, "embedding_dimensions", None):
|
||||
try:
|
||||
vector_dim = int(embedding_model.embedding_dimensions)
|
||||
except (TypeError, ValueError):
|
||||
vector_dim = DEFAULT_VECTOR_DIMENSION
|
||||
if vector_dim <= 0:
|
||||
vector_dim = DEFAULT_VECTOR_DIMENSION
|
||||
|
||||
await vector_db.ensure_collection(collection_name, vector=True, dim=vector_dim)
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
|
||||
if file_ext in ["jpg", "jpeg", "png", "bmp"]:
|
||||
base64_image = base64.b64encode(input_bytes).decode("utf-8")
|
||||
processed_bytes, compression = _compress_image_for_embedding(input_bytes)
|
||||
base64_image = base64.b64encode(processed_bytes).decode("utf-8")
|
||||
description = await describe_image_base64(base64_image)
|
||||
embedding = await get_text_embedding(description)
|
||||
log_message = f"Indexed image {path}"
|
||||
response_message = f"图片已索引,描述:{description}"
|
||||
elif file_ext in ["txt", "md"]:
|
||||
text = input_bytes.decode("utf-8")
|
||||
embedding = await get_text_embedding(text)
|
||||
description = text[:100] + "..." if len(text) > 100 else text
|
||||
log_message = f"Indexed text file {path}"
|
||||
response_message = f"文本文件已索引"
|
||||
|
||||
if embedding is None:
|
||||
return Response(content="不支持的文件类型", status_code=400)
|
||||
image_mime = "image/jpeg" if compression else mime_type
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
"path": _chunk_key(path, "image"),
|
||||
"source_path": path,
|
||||
"chunk_id": "image",
|
||||
"embedding": embedding,
|
||||
"text": description,
|
||||
"mime": image_mime,
|
||||
"type": "image",
|
||||
})
|
||||
details["description"] = description
|
||||
if compression:
|
||||
details["image_compression"] = compression
|
||||
await LogService.info(
|
||||
"processor:vector_index",
|
||||
f"Indexed image {path}",
|
||||
details=details,
|
||||
)
|
||||
return Response(content=f"图片已索引,描述:{description}", media_type="text/plain")
|
||||
|
||||
vector_db.ensure_collection(collection_name, vector=True)
|
||||
vector_db.upsert_vector(
|
||||
collection_name, {'path': path, 'embedding': embedding})
|
||||
|
||||
if file_ext in ["txt", "md"]:
|
||||
try:
|
||||
text = input_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return Response(content="文本文件解码失败", status_code=400)
|
||||
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
"path": _chunk_key(path, "0"),
|
||||
"source_path": path,
|
||||
"chunk_id": "0",
|
||||
"embedding": await get_text_embedding(text or path),
|
||||
"text": text,
|
||||
"mime": mime_type,
|
||||
"type": "text",
|
||||
"start_offset": 0,
|
||||
"end_offset": len(text),
|
||||
})
|
||||
details["chunks"] = 1
|
||||
await LogService.info(
|
||||
"processor:vector_index",
|
||||
f"Indexed text file {path}",
|
||||
details=details,
|
||||
)
|
||||
return Response(content="文本文件已索引", media_type="text/plain")
|
||||
|
||||
chunk_count = 0
|
||||
for chunk_id, chunk_text, start, end in chunks:
|
||||
embedding = await get_text_embedding(chunk_text)
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
"path": _chunk_key(path, str(chunk_id)),
|
||||
"source_path": path,
|
||||
"chunk_id": str(chunk_id),
|
||||
"embedding": embedding,
|
||||
"text": chunk_text,
|
||||
"mime": mime_type,
|
||||
"type": "text",
|
||||
"start_offset": start,
|
||||
"end_offset": end,
|
||||
})
|
||||
chunk_count += 1
|
||||
|
||||
details["chunks"] = chunk_count
|
||||
sample = chunks[0][1]
|
||||
details["sample"] = sample[:120]
|
||||
await LogService.info(
|
||||
"processor:vector_index",
|
||||
f"Indexed text file {path}",
|
||||
details=details,
|
||||
)
|
||||
return Response(content="文本文件已索引", media_type="text/plain")
|
||||
|
||||
# 其他类型暂未支持向量索引,回退为文件名索引
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
"path": _chunk_key(path, "fallback"),
|
||||
"source_path": path,
|
||||
"chunk_id": "filename",
|
||||
"mime": mime_type,
|
||||
"type": "filename",
|
||||
"name": os.path.basename(path),
|
||||
"embedding": [0.0] * vector_dim,
|
||||
})
|
||||
await LogService.info(
|
||||
"processor:vector_index",
|
||||
log_message,
|
||||
details={"path": path, "description": description, "action": "create", "index_type": "vector"},
|
||||
f"File type fallback to simple index for {path}",
|
||||
details={"path": path, "action": "create", "index_type": "simple", "original_type": file_ext},
|
||||
)
|
||||
return Response(content=response_message, media_type="text/plain")
|
||||
return Response(content="暂不支持该类型的向量索引,已创建文件名索引", media_type="text/plain")
|
||||
|
||||
|
||||
PROCESSOR_TYPE = "vector_index"
|
||||
|
||||
@@ -90,6 +90,16 @@ class ShareService:
|
||||
raise HTTPException(status_code=404, detail="分享链接不存在")
|
||||
await share.delete()
|
||||
|
||||
@staticmethod
|
||||
async def delete_expired_shares(user: UserAccount) -> int:
|
||||
"""
|
||||
删除当前用户所有已过期的分享链接,返回删除数量。
|
||||
条件:expires_at 非空 且 小于等于当前时间(UTC)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
deleted_count = await ShareLink.filter(user=user, expires_at__lte=now).delete()
|
||||
return deleted_count
|
||||
|
||||
@staticmethod
|
||||
async def get_shared_item_details(share: ShareLink, sub_path: str = ""):
|
||||
"""
|
||||
@@ -122,4 +132,4 @@ class ShareService:
|
||||
raise e
|
||||
|
||||
|
||||
share_service = ShareService()
|
||||
share_service = ShareService()
|
||||
|
||||
@@ -13,6 +13,14 @@ class TaskStatus(str, Enum):
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class TaskProgress(BaseModel):
|
||||
stage: str | None = None
|
||||
percent: float | None = None
|
||||
bytes_total: int | None = None
|
||||
bytes_done: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||
name: str
|
||||
@@ -20,13 +28,20 @@ class Task(BaseModel):
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
task_info: Dict[str, Any] = {}
|
||||
progress: TaskProgress | None = None
|
||||
meta: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
class TaskQueueService:
|
||||
def __init__(self):
|
||||
self._queue = asyncio.Queue()
|
||||
self._queue: asyncio.Queue[Task | object] = asyncio.Queue()
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
self._worker_task: asyncio.Task | None = None
|
||||
self._worker_tasks: list[asyncio.Task] = []
|
||||
self._concurrency: int = 1
|
||||
self._worker_seq: int = 0
|
||||
|
||||
async def add_task(self, name: str, task_info: Dict[str, Any]) -> Task:
|
||||
task = Task(name=name, task_info=task_info)
|
||||
@@ -41,6 +56,21 @@ class TaskQueueService:
|
||||
def get_all_tasks(self) -> list[Task]:
|
||||
return list(self._tasks.values())
|
||||
|
||||
async def update_progress(self, task_id: str, progress: TaskProgress | Dict[str, Any]):
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
if isinstance(progress, TaskProgress):
|
||||
task.progress = progress
|
||||
else:
|
||||
task.progress = TaskProgress(**progress)
|
||||
|
||||
async def update_meta(self, task_id: str, meta: Dict[str, Any]):
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
task.meta = (task.meta or {}) | meta
|
||||
|
||||
async def _execute_task(self, task: Task):
|
||||
from services.virtual_fs import process_file
|
||||
|
||||
@@ -54,10 +84,11 @@ class TaskQueueService:
|
||||
path=params["path"],
|
||||
processor_type=params["processor_type"],
|
||||
config=params["config"],
|
||||
save_to=params["save_to"]
|
||||
save_to=params.get("save_to"),
|
||||
overwrite=params.get("overwrite", False),
|
||||
)
|
||||
task.result = result
|
||||
elif task.name == "automation_task":
|
||||
elif task.name == "automation_task" or self._is_processor_task(task.name):
|
||||
from models.database import AutomationTask
|
||||
from services.processors.registry import get as get_processor
|
||||
from services.virtual_fs import read_file, write_file
|
||||
@@ -66,9 +97,21 @@ class TaskQueueService:
|
||||
auto_task = await AutomationTask.get(id=params["task_id"])
|
||||
path = params["path"]
|
||||
|
||||
processor = get_processor(auto_task.processor_type)
|
||||
processor_type = auto_task.processor_type if task.name == "automation_task" else task.name
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
raise ValueError(f"Processor {auto_task.processor_type} not found for task {auto_task.id}")
|
||||
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
|
||||
|
||||
if processor_type != auto_task.processor_type:
|
||||
await LogService.warning(
|
||||
"task_queue",
|
||||
"Processor type mismatch; falling back to stored type",
|
||||
{"task_id": auto_task.id, "expected": auto_task.processor_type, "got": processor_type},
|
||||
)
|
||||
processor_type = auto_task.processor_type
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
raise ValueError(f"Processor {processor_type} not found for task {auto_task.id}")
|
||||
|
||||
file_content = await read_file(path)
|
||||
result = await processor.process(file_content, path, auto_task.processor_config)
|
||||
@@ -77,6 +120,20 @@ class TaskQueueService:
|
||||
if save_to and getattr(processor, "produces_file", False):
|
||||
await write_file(save_to, result)
|
||||
task.result = "Automation task completed"
|
||||
elif task.name == "offline_http_download":
|
||||
from services.offline_download import run_http_download
|
||||
|
||||
result_path = await run_http_download(task)
|
||||
task.result = {"path": result_path}
|
||||
elif task.name == "cross_mount_transfer":
|
||||
from services.virtual_fs import run_cross_mount_transfer_task
|
||||
|
||||
result = await run_cross_mount_transfer_task(task)
|
||||
task.result = result
|
||||
elif task.name == "send_email":
|
||||
from services.email import EmailService
|
||||
await EmailService.send_from_task(task.id, task.task_info)
|
||||
task.result = "Email sent"
|
||||
else:
|
||||
raise ValueError(f"Unknown task name: {task.name}")
|
||||
|
||||
@@ -88,35 +145,88 @@ class TaskQueueService:
|
||||
task.error = str(e)
|
||||
await LogService.error("task_queue", f"Task {task.name} ({task.id}) failed: {e}", {"task_id": task.id, "name": task.name})
|
||||
|
||||
async def worker(self):
|
||||
await LogService.info("task_queue", "Task worker started")
|
||||
while True:
|
||||
try:
|
||||
task = await self._queue.get()
|
||||
await self._execute_task(task)
|
||||
except asyncio.CancelledError:
|
||||
await LogService.info("task_queue", "Task worker stopped")
|
||||
break
|
||||
except Exception as e:
|
||||
await LogService.error("task_queue", f"Error in task worker: {e}", exc_info=True)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
def _cleanup_workers(self):
|
||||
self._worker_tasks = [task for task in self._worker_tasks if not task.done()]
|
||||
|
||||
async def start_worker(self):
|
||||
if self._worker_task is None or self._worker_task.done():
|
||||
self._worker_task = asyncio.create_task(self.worker())
|
||||
await LogService.info("task_queue", "Task worker created.")
|
||||
def _is_processor_task(self, task_name: str) -> bool:
|
||||
try:
|
||||
from services.processors.registry import get as get_processor
|
||||
|
||||
return get_processor(task_name) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _ensure_worker_count(self):
|
||||
self._cleanup_workers()
|
||||
current = len(self._worker_tasks)
|
||||
if current < self._concurrency:
|
||||
for _ in range(self._concurrency - current):
|
||||
self._worker_seq += 1
|
||||
worker_id = self._worker_seq
|
||||
worker_task = asyncio.create_task(self._worker_loop(worker_id))
|
||||
self._worker_tasks.append(worker_task)
|
||||
await LogService.info("task_queue", "Task workers adjusted", {"active_workers": len(self._worker_tasks), "target": self._concurrency})
|
||||
elif current > self._concurrency:
|
||||
for _ in range(current - self._concurrency):
|
||||
await self._queue.put(_SENTINEL)
|
||||
await LogService.info("task_queue", "Task workers scaling down", {"active_workers": len(self._worker_tasks), "target": self._concurrency})
|
||||
|
||||
async def _worker_loop(self, worker_id: int):
|
||||
current_task = asyncio.current_task()
|
||||
await LogService.info("task_queue", f"Worker {worker_id} started")
|
||||
try:
|
||||
while True:
|
||||
job = await self._queue.get()
|
||||
if job is _SENTINEL:
|
||||
self._queue.task_done()
|
||||
break
|
||||
try:
|
||||
await self._execute_task(job)
|
||||
except Exception as e:
|
||||
await LogService.error(
|
||||
"task_queue",
|
||||
f"Error executing task {job.id}: {e}",
|
||||
{"task_id": job.id, "name": job.name},
|
||||
)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
finally:
|
||||
if current_task in self._worker_tasks:
|
||||
self._worker_tasks.remove(current_task) # type: ignore[arg-type]
|
||||
await LogService.info("task_queue", f"Worker {worker_id} stopped")
|
||||
|
||||
async def start_worker(self, concurrency: int | None = None):
|
||||
if concurrency is None:
|
||||
from services.config import ConfigCenter
|
||||
|
||||
stored_value = await ConfigCenter.get("TASK_QUEUE_CONCURRENCY", self._concurrency)
|
||||
try:
|
||||
concurrency = int(stored_value)
|
||||
except (TypeError, ValueError):
|
||||
concurrency = self._concurrency
|
||||
await self.set_concurrency(concurrency)
|
||||
|
||||
async def set_concurrency(self, value: int):
|
||||
value = max(1, int(value))
|
||||
if value != self._concurrency:
|
||||
self._concurrency = value
|
||||
await self._ensure_worker_count()
|
||||
|
||||
async def stop_worker(self):
|
||||
if self._worker_task and not self._worker_task.done():
|
||||
self._worker_task.cancel()
|
||||
try:
|
||||
await self._worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._worker_task = None
|
||||
await LogService.info("task_queue", "Task worker has been stopped.")
|
||||
self._cleanup_workers()
|
||||
for _ in range(len(self._worker_tasks)):
|
||||
await self._queue.put(_SENTINEL)
|
||||
if self._worker_tasks:
|
||||
await asyncio.gather(*self._worker_tasks, return_exceptions=True)
|
||||
self._worker_tasks.clear()
|
||||
await LogService.info("task_queue", "Task workers have been stopped.")
|
||||
|
||||
def get_concurrency(self) -> int:
|
||||
return self._concurrency
|
||||
|
||||
def get_active_worker_count(self) -> int:
|
||||
self._cleanup_workers()
|
||||
return len(self._worker_tasks)
|
||||
|
||||
|
||||
task_queue_service = TaskQueueService()
|
||||
task_queue_service = TaskQueueService()
|
||||
|
||||
@@ -25,11 +25,11 @@ class TaskService:
|
||||
|
||||
async def execute(self, task: AutomationTask, path: str):
|
||||
await task_queue_service.add_task(
|
||||
"automation_task",
|
||||
task.processor_type,
|
||||
{
|
||||
"task_id": task.id,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
task_service = TaskService()
|
||||
task_service = TaskService()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import io
|
||||
import hashlib
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from fastapi import HTTPException
|
||||
@@ -8,7 +12,10 @@ from fastapi import HTTPException
|
||||
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
||||
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||
MAX_SOURCE_SIZE = 200 * 1024 * 1024
|
||||
VIDEO_EXT = {"mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "mpg", "mpeg", "3gp"}
|
||||
MAX_IMAGE_SOURCE_SIZE = 200 * 1024 * 1024
|
||||
VIDEO_RANGE_LIMIT = 16 * 1024 * 1024 # 16MB
|
||||
VIDEO_INITIAL_CHUNK = 4 * 1024 * 1024
|
||||
CACHE_ROOT = Path('data/.thumb_cache')
|
||||
|
||||
|
||||
@@ -26,6 +33,13 @@ def is_raw_filename(name: str) -> bool:
|
||||
return parts[1].lower() in RAW_EXT
|
||||
|
||||
|
||||
def is_video_filename(name: str) -> bool:
|
||||
parts = name.rsplit('.', 1)
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
return parts[1].lower() in VIDEO_EXT
|
||||
|
||||
|
||||
def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str:
|
||||
raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
|
||||
return hashlib.sha1(raw).hexdigest()
|
||||
@@ -40,6 +54,30 @@ def _ensure_cache_dir(p: Path):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _image_to_webp(im, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||
from PIL import Image
|
||||
if im.mode not in ("RGB", "RGBA"):
|
||||
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
|
||||
if fit == 'cover':
|
||||
im_ratio = im.width / im.height
|
||||
target_ratio = w / h
|
||||
if im_ratio > target_ratio:
|
||||
new_h = h
|
||||
new_w = int(h * im_ratio)
|
||||
else:
|
||||
new_w = w
|
||||
new_h = int(w / im_ratio)
|
||||
im = im.resize((new_w, new_h))
|
||||
left = max(0, (im.width - w)//2)
|
||||
top = max(0, (im.height - h)//2)
|
||||
im = im.crop((left, top, left + w, top + h))
|
||||
else:
|
||||
im.thumbnail((w, h))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, 'WEBP', quality=80)
|
||||
return buf.getvalue(), 'image/webp'
|
||||
|
||||
|
||||
def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False) -> Tuple[bytes, str]:
|
||||
from PIL import Image
|
||||
if is_raw:
|
||||
@@ -64,35 +102,172 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
|
||||
else:
|
||||
im = Image.open(io.BytesIO(data))
|
||||
|
||||
if im.mode not in ("RGB", "RGBA"):
|
||||
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
|
||||
if fit == 'cover':
|
||||
im_ratio = im.width / im.height
|
||||
target_ratio = w / h
|
||||
if im_ratio > target_ratio:
|
||||
new_h = h
|
||||
new_w = int(h * im_ratio)
|
||||
else:
|
||||
new_w = w
|
||||
new_h = int(w / im_ratio)
|
||||
im = im.resize((new_w, new_h))
|
||||
left = max(0, (im.width - w)//2)
|
||||
top = max(0, (im.height - h)//2)
|
||||
im = im.crop((left, top, left + w, top + h))
|
||||
else:
|
||||
im.thumbnail((w, h))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, 'WEBP', quality=80)
|
||||
return buf.getvalue(), 'image/webp'
|
||||
return _image_to_webp(im, w, h, fit)
|
||||
|
||||
|
||||
async def _collect_response_bytes(response, limit: int) -> bytes:
|
||||
if response is None:
|
||||
return b""
|
||||
|
||||
try:
|
||||
if isinstance(response, (bytes, bytearray)):
|
||||
return bytes(response[:limit])
|
||||
|
||||
body = getattr(response, "body", None)
|
||||
if body is not None:
|
||||
return bytes(body[:limit])
|
||||
|
||||
iterator = getattr(response, "body_iterator", None)
|
||||
if iterator is not None:
|
||||
data = bytearray()
|
||||
async for chunk in iterator:
|
||||
if not chunk:
|
||||
continue
|
||||
need = limit - len(data)
|
||||
if need <= 0:
|
||||
break
|
||||
data.extend(chunk[:need])
|
||||
if len(data) >= limit:
|
||||
break
|
||||
return bytes(data)
|
||||
|
||||
if hasattr(response, "__aiter__"):
|
||||
data = bytearray()
|
||||
async for chunk in response:
|
||||
if not chunk:
|
||||
continue
|
||||
need = limit - len(data)
|
||||
if need <= 0:
|
||||
break
|
||||
data.extend(chunk[:need])
|
||||
if len(data) >= limit:
|
||||
break
|
||||
return bytes(data)
|
||||
finally:
|
||||
close_func = getattr(response, "close", None)
|
||||
if callable(close_func):
|
||||
result = close_func()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
async def _read_range_slice(adapter, root: str, rel: str, start: int, end: int) -> bytes:
|
||||
read_range = getattr(adapter, "read_file_range", None)
|
||||
if callable(read_range):
|
||||
try:
|
||||
return await read_range(root, rel, start, end)
|
||||
except TypeError:
|
||||
return await read_range(root, rel, start, end=end)
|
||||
|
||||
stream_impl = getattr(adapter, "stream_file", None)
|
||||
if callable(stream_impl):
|
||||
range_header = f"bytes={start}-{end}"
|
||||
response = await stream_impl(root, rel, range_header)
|
||||
expected = end - start + 1
|
||||
return await _collect_response_bytes(response, expected)
|
||||
|
||||
read_file = getattr(adapter, "read_file", None)
|
||||
if callable(read_file) and start == 0:
|
||||
data = await read_file(root, rel)
|
||||
slice_end = end + 1
|
||||
return data[:slice_end]
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
async def _read_video_prefix(adapter, root: str, rel: str, size: int, limit: int = VIDEO_RANGE_LIMIT) -> bytes:
|
||||
chunk_size = min(VIDEO_INITIAL_CHUNK, limit)
|
||||
offset = 0
|
||||
collected = bytearray()
|
||||
|
||||
while len(collected) < limit:
|
||||
end = offset + chunk_size - 1
|
||||
data = await _read_range_slice(adapter, root, rel, offset, end)
|
||||
if not data:
|
||||
break
|
||||
collected.extend(data)
|
||||
if len(data) < chunk_size:
|
||||
break
|
||||
offset += len(data)
|
||||
remaining = limit - len(collected)
|
||||
if remaining <= 0:
|
||||
break
|
||||
chunk_size = min(chunk_size * 2, remaining)
|
||||
|
||||
if not collected and size <= limit:
|
||||
read_file = getattr(adapter, "read_file", None)
|
||||
if callable(read_file):
|
||||
blob = await read_file(root, rel)
|
||||
if blob:
|
||||
return bytes(blob[:limit])
|
||||
|
||||
return bytes(collected[:limit])
|
||||
|
||||
|
||||
async def _run_ffmpeg_extract_frame(src_path: str, dst_path: str):
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", src_path,
|
||||
"-frames:v", "1",
|
||||
dst_path,
|
||||
]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("未找到 ffmpeg,可执行文件需要在 PATH 中") from e
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
message = stderr.decode().strip() or stdout.decode().strip() or "ffmpeg 执行失败"
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
async def _generate_video_thumb(video_bytes: bytes, rel: str, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||
from PIL import Image
|
||||
|
||||
suffix = Path(rel).suffix or ".mp4"
|
||||
src_tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
src_path = src_tmp.name
|
||||
try:
|
||||
src_tmp.write(video_bytes)
|
||||
src_tmp.flush()
|
||||
finally:
|
||||
src_tmp.close()
|
||||
|
||||
dst_tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||
dst_path = dst_tmp.name
|
||||
dst_tmp.close()
|
||||
|
||||
try:
|
||||
await _run_ffmpeg_extract_frame(src_path, dst_path)
|
||||
with Image.open(dst_path) as im:
|
||||
im.load()
|
||||
return _image_to_webp(im, w, h, fit)
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
Path(src_path).unlink()
|
||||
with suppress(FileNotFoundError):
|
||||
Path(dst_path).unlink()
|
||||
|
||||
|
||||
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
||||
stat = await adapter.stat_file(root, rel)
|
||||
if stat['size'] > MAX_SOURCE_SIZE:
|
||||
size = int(stat.get('size') or 0)
|
||||
is_video = is_video_filename(rel)
|
||||
if not is_video and size > MAX_IMAGE_SOURCE_SIZE:
|
||||
raise HTTPException(400, detail="Image too large for thumbnail")
|
||||
|
||||
key = _cache_key(adapter_id, rel, stat['size'], int(
|
||||
stat['mtime']), w, h, fit)
|
||||
key = _cache_key(adapter_id, rel, size, int(
|
||||
stat.get('mtime', 0)), w, h, fit)
|
||||
path = _cache_path(key)
|
||||
if path.exists():
|
||||
return path.read_bytes(), 'image/webp', key
|
||||
@@ -119,14 +294,33 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
|
||||
thumb_bytes, mime = None, None
|
||||
|
||||
if not thumb_bytes:
|
||||
read_data = await adapter.read_file(root, rel)
|
||||
try:
|
||||
thumb_bytes, mime = generate_thumb(
|
||||
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
500, detail=f"Thumbnail generation failed: {e}")
|
||||
if is_video:
|
||||
try:
|
||||
video_bytes = await _read_video_prefix(adapter, root, rel, size)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Video prefix read failed: {e}")
|
||||
raise HTTPException(500, detail=f"Video read failed: {e}")
|
||||
|
||||
if not video_bytes:
|
||||
raise HTTPException(500, detail="Unable to read video data for thumbnail")
|
||||
|
||||
try:
|
||||
thumb_bytes, mime = await _generate_video_thumb(video_bytes, rel, w, h, fit)
|
||||
except Exception as e:
|
||||
print(f"Video thumbnail generation failed: {e}")
|
||||
raise HTTPException(
|
||||
500, detail=f"Video thumbnail generation failed: {e}")
|
||||
else:
|
||||
read_data = await adapter.read_file(root, rel)
|
||||
try:
|
||||
thumb_bytes, mime = generate_thumb(
|
||||
read_data, w, h, fit, is_raw=is_raw_filename(rel))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
500, detail=f"Thumbnail generation failed: {e}")
|
||||
|
||||
if thumb_bytes:
|
||||
path.write_bytes(thumb_bytes)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
|
||||
|
||||
|
||||
class VectorDBService:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not cls._instance:
|
||||
cls._instance = super(VectorDBService, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, 'client'):
|
||||
self.client = MilvusClient("data/db/milvus.db")
|
||||
|
||||
def ensure_collection(self, collection_name, vector: bool = True):
|
||||
if self.client.has_collection(collection_name):
|
||||
return
|
||||
if vector:
|
||||
fields = [
|
||||
FieldSchema(name="path", dtype=DataType.VARCHAR,
|
||||
max_length=512, is_primary=True, auto_id=False),
|
||||
FieldSchema(name="embedding",
|
||||
dtype=DataType.FLOAT_VECTOR, dim=4096)
|
||||
]
|
||||
schema = CollectionSchema(
|
||||
fields, description="Image vector collection")
|
||||
self.client.create_collection(collection_name, schema=schema)
|
||||
index_params = MilvusClient.prepare_index_params()
|
||||
index_params.add_index(
|
||||
field_name="embedding",
|
||||
index_type="IVF_FLAT",
|
||||
index_name="vector_index",
|
||||
metric_type="COSINE",
|
||||
params={
|
||||
"nlist": 64,
|
||||
}
|
||||
)
|
||||
self.client.create_index(
|
||||
collection_name,
|
||||
index_params=index_params
|
||||
)
|
||||
else:
|
||||
fields = [
|
||||
FieldSchema(name="path", dtype=DataType.VARCHAR,
|
||||
max_length=512, is_primary=True, auto_id=False),
|
||||
]
|
||||
schema = CollectionSchema(fields, description="Simple file index")
|
||||
self.client.create_collection(collection_name, schema=schema)
|
||||
|
||||
def upsert_vector(self, collection_name, data):
|
||||
self.client.upsert(collection_name, data)
|
||||
|
||||
def delete_vector(self, collection_name, path: str):
|
||||
self.client.delete(collection_name, ids=[path])
|
||||
|
||||
def search_vectors(self, collection_name, query_embedding, top_k=5):
|
||||
search_params = {"metric_type": "COSINE"}
|
||||
results = self.client.search(
|
||||
collection_name,
|
||||
data=[query_embedding],
|
||||
anns_field="embedding",
|
||||
search_params=search_params,
|
||||
limit=top_k,
|
||||
output_fields=["path"]
|
||||
)
|
||||
print(results)
|
||||
return results
|
||||
|
||||
def search_by_path(self, collection_name, query_path, top_k=20):
|
||||
results = self.client.query(
|
||||
collection_name,
|
||||
filter=f"path like '%{query_path}%'",
|
||||
limit=top_k,
|
||||
output_fields=["path"]
|
||||
)
|
||||
return [[{'id': r['path'], 'distance': 1.0, 'entity': {'path': r['path']}} for r in results]]
|
||||
|
||||
def clear_all_data(self):
|
||||
"""清空所有集合的内容"""
|
||||
collections = self.client.list_collections()
|
||||
for collection_name in collections:
|
||||
self.client.drop_collection(collection_name)
|
||||
11
services/vector_db/__init__.py
Normal file
11
services/vector_db/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .service import VectorDBService, DEFAULT_VECTOR_DIMENSION
|
||||
from .providers import list_providers, get_provider_entry
|
||||
from .config_manager import VectorDBConfigManager
|
||||
|
||||
__all__ = [
|
||||
"VectorDBService",
|
||||
"DEFAULT_VECTOR_DIMENSION",
|
||||
"list_providers",
|
||||
"get_provider_entry",
|
||||
"VectorDBConfigManager",
|
||||
]
|
||||
43
services/vector_db/config_manager.py
Normal file
43
services/vector_db/config_manager.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from services.config import ConfigCenter
|
||||
|
||||
|
||||
class VectorDBConfigManager:
|
||||
TYPE_KEY = "VECTOR_DB_TYPE"
|
||||
CONFIG_KEY = "VECTOR_DB_CONFIG"
|
||||
DEFAULT_TYPE = "milvus_lite"
|
||||
|
||||
@classmethod
|
||||
async def load_config(cls) -> Tuple[str, Dict[str, Any]]:
|
||||
raw_type = await ConfigCenter.get(cls.TYPE_KEY, cls.DEFAULT_TYPE)
|
||||
provider_type = str(raw_type or cls.DEFAULT_TYPE)
|
||||
|
||||
raw_config = await ConfigCenter.get(cls.CONFIG_KEY)
|
||||
config_dict: Dict[str, Any] = {}
|
||||
if isinstance(raw_config, str) and raw_config:
|
||||
try:
|
||||
config_dict = json.loads(raw_config)
|
||||
except json.JSONDecodeError:
|
||||
config_dict = {}
|
||||
elif isinstance(raw_config, dict):
|
||||
config_dict = raw_config
|
||||
return provider_type, config_dict
|
||||
|
||||
@classmethod
|
||||
async def save_config(cls, provider_type: str, config: Dict[str, Any]) -> None:
|
||||
await ConfigCenter.set(cls.TYPE_KEY, provider_type)
|
||||
await ConfigCenter.set(cls.CONFIG_KEY, json.dumps(config or {}))
|
||||
|
||||
@classmethod
|
||||
async def get_type(cls) -> str:
|
||||
provider_type, _ = await cls.load_config()
|
||||
return provider_type
|
||||
|
||||
@classmethod
|
||||
async def get_config(cls) -> Dict[str, Any]:
|
||||
_, config = await cls.load_config()
|
||||
return config
|
||||
56
services/vector_db/providers/__init__.py
Normal file
56
services/vector_db/providers/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Type
|
||||
|
||||
from .base import BaseVectorProvider
|
||||
from .milvus_lite import MilvusLiteProvider
|
||||
from .milvus_server import MilvusServerProvider
|
||||
from .qdrant import QdrantProvider
|
||||
|
||||
_PROVIDER_REGISTRY: Dict[str, Dict[str, object]] = {
|
||||
MilvusLiteProvider.type: {
|
||||
"class": MilvusLiteProvider,
|
||||
"label": MilvusLiteProvider.label,
|
||||
"description": MilvusLiteProvider.description,
|
||||
"enabled": MilvusLiteProvider.enabled,
|
||||
"config_schema": MilvusLiteProvider.config_schema,
|
||||
},
|
||||
MilvusServerProvider.type: {
|
||||
"class": MilvusServerProvider,
|
||||
"label": MilvusServerProvider.label,
|
||||
"description": MilvusServerProvider.description,
|
||||
"enabled": MilvusServerProvider.enabled,
|
||||
"config_schema": MilvusServerProvider.config_schema,
|
||||
},
|
||||
QdrantProvider.type: {
|
||||
"class": QdrantProvider,
|
||||
"label": QdrantProvider.label,
|
||||
"description": QdrantProvider.description,
|
||||
"enabled": QdrantProvider.enabled,
|
||||
"config_schema": QdrantProvider.config_schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_providers() -> List[Dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"type": type_key,
|
||||
"label": meta["label"],
|
||||
"description": meta.get("description"),
|
||||
"enabled": meta.get("enabled", True),
|
||||
"config_schema": meta.get("config_schema", []),
|
||||
}
|
||||
for type_key, meta in _PROVIDER_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
def get_provider_entry(provider_type: str) -> Dict[str, object] | None:
|
||||
return _PROVIDER_REGISTRY.get(provider_type)
|
||||
|
||||
|
||||
def get_provider_class(provider_type: str) -> Type[BaseVectorProvider] | None:
|
||||
entry = get_provider_entry(provider_type)
|
||||
if not entry:
|
||||
return None
|
||||
return entry.get("class") # type: ignore[return-value]
|
||||
41
services/vector_db/providers/base.py
Normal file
41
services/vector_db/providers/base.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class BaseVectorProvider:
|
||||
"""向量数据库提供者基础类,所有实际实现需继承该类"""
|
||||
|
||||
type: str = ""
|
||||
label: str = ""
|
||||
description: str | None = None
|
||||
enabled: bool = True
|
||||
config_schema: List[Dict[str, Any]] = []
|
||||
|
||||
def __init__(self, config: Dict[str, Any] | None = None):
|
||||
self.config = config or {}
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""执行初始化逻辑,例如建立连接"""
|
||||
raise NotImplementedError
|
||||
|
||||
def ensure_collection(self, collection_name: str, vector: bool, dim: int) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_vector(self, collection_name: str, path: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def search_vectors(self, collection_name: str, query_embedding, top_k: int):
|
||||
raise NotImplementedError
|
||||
|
||||
def search_by_path(self, collection_name: str, query_path: str, top_k: int):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_all_stats(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def clear_all_data(self) -> None:
|
||||
raise NotImplementedError
|
||||
278
services/vector_db/providers/milvus_lite.py
Normal file
278
services/vector_db/providers/milvus_lite.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
|
||||
|
||||
from .base import BaseVectorProvider
|
||||
|
||||
|
||||
class MilvusLiteProvider(BaseVectorProvider):
|
||||
type = "milvus_lite"
|
||||
label = "Milvus Lite"
|
||||
description = "Embedded Milvus Lite (local file storage)."
|
||||
enabled = True
|
||||
config_schema: List[Dict[str, Any]] = [
|
||||
{
|
||||
"key": "db_path",
|
||||
"label": "Database file path",
|
||||
"type": "text",
|
||||
"default": "data/db/milvus.db",
|
||||
"required": False,
|
||||
}
|
||||
]
|
||||
|
||||
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")
|
||||
self.client: MilvusClient | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
try:
|
||||
self.client = 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
|
||||
|
||||
def _get_client(self) -> MilvusClient:
|
||||
if not self.client:
|
||||
raise RuntimeError("Milvus Lite client is not initialized")
|
||||
return self.client
|
||||
|
||||
@staticmethod
|
||||
def _extract_hit_payload(hit: Any) -> tuple[Any, Any, Dict[str, Any]]:
|
||||
hit_id = getattr(hit, "id", None)
|
||||
distance = getattr(hit, "distance", None)
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
raw: Dict[str, Any] | None = None
|
||||
if hasattr(hit, "entity"):
|
||||
raw_entity = getattr(hit, "entity")
|
||||
if hasattr(raw_entity, "to_dict"):
|
||||
raw = dict(raw_entity.to_dict())
|
||||
else:
|
||||
raw = dict(raw_entity)
|
||||
elif isinstance(hit, dict):
|
||||
raw = dict(hit)
|
||||
|
||||
if raw:
|
||||
hit_id = hit_id or raw.get("id")
|
||||
distance = distance if distance is not None else raw.get("distance")
|
||||
inner = raw.get("entity")
|
||||
if isinstance(inner, dict):
|
||||
payload = dict(inner)
|
||||
else:
|
||||
payload = {k: v for k, v in raw.items() if k not in {"id", "distance", "entity"}}
|
||||
|
||||
payload.setdefault("path", payload.get("source_path"))
|
||||
payload.setdefault("source_path", payload.get("path"))
|
||||
return hit_id, distance, payload
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def ensure_collection(self, collection_name: str, vector: bool, dim: int) -> None:
|
||||
client = self._get_client()
|
||||
if client.has_collection(collection_name):
|
||||
return
|
||||
common_fields = [
|
||||
FieldSchema(name="path", dtype=DataType.VARCHAR, max_length=512, is_primary=True, auto_id=False),
|
||||
FieldSchema(name="source_path", dtype=DataType.VARCHAR, max_length=512, is_primary=False, auto_id=False),
|
||||
]
|
||||
|
||||
if vector:
|
||||
vector_dim = dim if isinstance(dim, int) and dim > 0 else 0
|
||||
if vector_dim <= 0:
|
||||
vector_dim = 4096
|
||||
fields = [
|
||||
*common_fields,
|
||||
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=vector_dim),
|
||||
]
|
||||
schema = CollectionSchema(fields, description="Vector collection", enable_dynamic_field=True)
|
||||
client.create_collection(collection_name, schema=schema)
|
||||
index_params = MilvusClient.prepare_index_params()
|
||||
index_params.add_index(
|
||||
field_name="embedding",
|
||||
index_type="IVF_FLAT",
|
||||
index_name="vector_index",
|
||||
metric_type="COSINE",
|
||||
params={"nlist": 64},
|
||||
)
|
||||
client.create_index(collection_name, index_params=index_params)
|
||||
else:
|
||||
schema = CollectionSchema(common_fields, description="Simple file index", enable_dynamic_field=True)
|
||||
client.create_collection(collection_name, schema=schema)
|
||||
|
||||
def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
|
||||
payload = dict(data)
|
||||
payload.setdefault("source_path", payload.get("path"))
|
||||
payload.setdefault("vector_id", payload.get("path"))
|
||||
self._get_client().upsert(collection_name, data=[payload])
|
||||
|
||||
def delete_vector(self, collection_name: str, path: str) -> None:
|
||||
client = self._get_client()
|
||||
escaped = path.replace('"', '\\"')
|
||||
client.delete(collection_name, filter=f'source_path == "{escaped}"')
|
||||
|
||||
def search_vectors(self, collection_name: str, query_embedding, top_k: int):
|
||||
search_params = {"metric_type": "COSINE"}
|
||||
output_fields = [
|
||||
"path",
|
||||
"source_path",
|
||||
"chunk_id",
|
||||
"mime",
|
||||
"text",
|
||||
"start_offset",
|
||||
"end_offset",
|
||||
"type",
|
||||
"name",
|
||||
]
|
||||
raw_results = self._get_client().search(
|
||||
collection_name,
|
||||
data=[query_embedding],
|
||||
anns_field="embedding",
|
||||
search_params=search_params,
|
||||
limit=top_k,
|
||||
output_fields=output_fields,
|
||||
)
|
||||
formatted: List[List[Dict[str, Any]]] = []
|
||||
for hits in raw_results:
|
||||
bucket: List[Dict[str, Any]] = []
|
||||
for hit in hits:
|
||||
hit_id, distance, entity = self._extract_hit_payload(hit)
|
||||
bucket.append({
|
||||
"id": hit_id,
|
||||
"distance": distance,
|
||||
"entity": entity,
|
||||
})
|
||||
formatted.append(bucket)
|
||||
return formatted
|
||||
|
||||
def search_by_path(self, collection_name: str, query_path: str, top_k: int):
|
||||
if query_path:
|
||||
escaped = query_path.replace('"', '\\"')
|
||||
filter_expr = f'source_path like "%{escaped}%"'
|
||||
else:
|
||||
filter_expr = "source_path like '%%'"
|
||||
results = self._get_client().query(
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
limit=top_k,
|
||||
output_fields=[
|
||||
"path",
|
||||
"source_path",
|
||||
"chunk_id",
|
||||
"mime",
|
||||
"text",
|
||||
"start_offset",
|
||||
"end_offset",
|
||||
"type",
|
||||
"name",
|
||||
],
|
||||
)
|
||||
formatted = []
|
||||
for row in results:
|
||||
entity = dict(row)
|
||||
entity.setdefault("path", entity.get("source_path"))
|
||||
formatted.append({
|
||||
"id": entity.get("path"),
|
||||
"distance": 1.0,
|
||||
"entity": entity,
|
||||
})
|
||||
return [formatted]
|
||||
|
||||
def get_all_stats(self) -> Dict[str, Any]:
|
||||
client = self._get_client()
|
||||
try:
|
||||
collection_names = client.list_collections()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to list collections: {exc}") from exc
|
||||
|
||||
collections: List[Dict[str, Any]] = []
|
||||
total_vectors = 0
|
||||
total_estimated_memory = 0
|
||||
|
||||
for name in collection_names:
|
||||
try:
|
||||
stats = client.get_collection_stats(name) or {}
|
||||
except Exception:
|
||||
stats = {}
|
||||
row_count = self._to_int(stats.get("row_count"))
|
||||
total_vectors += row_count
|
||||
|
||||
dimension: Optional[int] = None
|
||||
is_vector_collection = False
|
||||
try:
|
||||
description = client.describe_collection(name)
|
||||
except Exception:
|
||||
description = None
|
||||
|
||||
if description:
|
||||
for field in description.get("fields", []):
|
||||
if field.get("type") == DataType.FLOAT_VECTOR:
|
||||
params = field.get("params") or {}
|
||||
dimension = self._to_int(params.get("dim")) or 4096
|
||||
is_vector_collection = True
|
||||
break
|
||||
|
||||
estimated_memory = 0
|
||||
if is_vector_collection and dimension:
|
||||
estimated_memory = row_count * dimension * 4
|
||||
total_estimated_memory += estimated_memory
|
||||
|
||||
indexes: List[Dict[str, Any]] = []
|
||||
try:
|
||||
index_names = client.list_indexes(name) or []
|
||||
except Exception:
|
||||
index_names = []
|
||||
|
||||
for index_name in index_names:
|
||||
try:
|
||||
detail = client.describe_index(name, index_name) or {}
|
||||
except Exception:
|
||||
detail = {}
|
||||
indexes.append(
|
||||
{
|
||||
"index_name": index_name,
|
||||
"index_type": detail.get("index_type"),
|
||||
"metric_type": detail.get("metric_type"),
|
||||
"indexed_rows": self._to_int(detail.get("indexed_rows")),
|
||||
"pending_index_rows": self._to_int(detail.get("pending_index_rows")),
|
||||
"state": detail.get("state"),
|
||||
}
|
||||
)
|
||||
|
||||
collections.append(
|
||||
{
|
||||
"name": name,
|
||||
"row_count": row_count,
|
||||
"dimension": dimension if is_vector_collection else None,
|
||||
"estimated_memory_bytes": estimated_memory,
|
||||
"is_vector_collection": is_vector_collection,
|
||||
"indexes": indexes,
|
||||
}
|
||||
)
|
||||
|
||||
db_file_size = None
|
||||
try:
|
||||
if self.db_path.exists():
|
||||
db_file_size = self.db_path.stat().st_size
|
||||
except OSError:
|
||||
db_file_size = None
|
||||
|
||||
return {
|
||||
"collections": collections,
|
||||
"collection_count": len(collections),
|
||||
"total_vectors": total_vectors,
|
||||
"estimated_total_memory_bytes": total_estimated_memory,
|
||||
"db_file_size_bytes": db_file_size,
|
||||
}
|
||||
|
||||
def clear_all_data(self) -> None:
|
||||
client = self._get_client()
|
||||
for collection_name in client.list_collections():
|
||||
client.drop_collection(collection_name)
|
||||
278
services/vector_db/providers/milvus_server.py
Normal file
278
services/vector_db/providers/milvus_server.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
|
||||
|
||||
from .base import BaseVectorProvider
|
||||
|
||||
|
||||
class MilvusServerProvider(BaseVectorProvider):
|
||||
type = "milvus_server"
|
||||
label = "Milvus Server"
|
||||
description = "Remote Milvus instance accessed via URI."
|
||||
enabled = True
|
||||
config_schema: List[Dict[str, Any]] = [
|
||||
{
|
||||
"key": "uri",
|
||||
"label": "Server URI",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"placeholder": "http://localhost:19530",
|
||||
},
|
||||
{
|
||||
"key": "token",
|
||||
"label": "Token",
|
||||
"type": "password",
|
||||
"required": False,
|
||||
"placeholder": "user:password",
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(self, config: Dict[str, Any] | None = None):
|
||||
super().__init__(config)
|
||||
self.client: MilvusClient | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
uri = self.config.get("uri")
|
||||
if not uri:
|
||||
raise RuntimeError("Milvus Server URI is required")
|
||||
try:
|
||||
self.client = MilvusClient(uri=uri, token=self.config.get("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
|
||||
|
||||
def _get_client(self) -> MilvusClient:
|
||||
if not self.client:
|
||||
raise RuntimeError("Milvus Server client is not initialized")
|
||||
return self.client
|
||||
|
||||
@staticmethod
|
||||
def _extract_hit_payload(hit: Any) -> tuple[Any, Any, Dict[str, Any]]:
|
||||
hit_id = getattr(hit, "id", None)
|
||||
distance = getattr(hit, "distance", None)
|
||||
payload: Dict[str, Any] = {}
|
||||
|
||||
raw: Dict[str, Any] | None = None
|
||||
if hasattr(hit, "entity"):
|
||||
raw_entity = getattr(hit, "entity")
|
||||
if hasattr(raw_entity, "to_dict"):
|
||||
raw = dict(raw_entity.to_dict())
|
||||
else:
|
||||
raw = dict(raw_entity)
|
||||
elif isinstance(hit, dict):
|
||||
raw = dict(hit)
|
||||
|
||||
if raw:
|
||||
hit_id = hit_id or raw.get("id")
|
||||
distance = distance if distance is not None else raw.get("distance")
|
||||
inner = raw.get("entity")
|
||||
if isinstance(inner, dict):
|
||||
payload = dict(inner)
|
||||
else:
|
||||
payload = {k: v for k, v in raw.items() if k not in {"id", "distance", "entity"}}
|
||||
|
||||
payload.setdefault("path", payload.get("source_path"))
|
||||
payload.setdefault("source_path", payload.get("path"))
|
||||
return hit_id, distance, payload
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def ensure_collection(self, collection_name: str, vector: bool, dim: int) -> None:
|
||||
client = self._get_client()
|
||||
if client.has_collection(collection_name):
|
||||
return
|
||||
common_fields = [
|
||||
FieldSchema(name="path", dtype=DataType.VARCHAR, max_length=512, is_primary=True, auto_id=False),
|
||||
FieldSchema(name="source_path", dtype=DataType.VARCHAR, max_length=512, is_primary=False, auto_id=False),
|
||||
]
|
||||
if vector:
|
||||
vector_dim = dim if isinstance(dim, int) and dim > 0 else 0
|
||||
if vector_dim <= 0:
|
||||
vector_dim = 4096
|
||||
fields = [
|
||||
*common_fields,
|
||||
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=vector_dim),
|
||||
]
|
||||
schema = CollectionSchema(fields, description="Vector collection", enable_dynamic_field=True)
|
||||
client.create_collection(collection_name, schema=schema)
|
||||
index_params = MilvusClient.prepare_index_params()
|
||||
index_params.add_index(
|
||||
field_name="embedding",
|
||||
index_type="IVF_FLAT",
|
||||
index_name="vector_index",
|
||||
metric_type="COSINE",
|
||||
params={"nlist": 64},
|
||||
)
|
||||
client.create_index(collection_name, index_params=index_params)
|
||||
else:
|
||||
schema = CollectionSchema(common_fields, description="Simple file index", enable_dynamic_field=True)
|
||||
client.create_collection(collection_name, schema=schema)
|
||||
|
||||
def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
|
||||
payload = dict(data)
|
||||
payload.setdefault("source_path", payload.get("path"))
|
||||
payload.setdefault("vector_id", payload.get("path"))
|
||||
self._get_client().upsert(collection_name, data=[payload])
|
||||
|
||||
def delete_vector(self, collection_name: str, path: str) -> None:
|
||||
client = self._get_client()
|
||||
escaped = path.replace('"', '\\"')
|
||||
client.delete(collection_name, filter=f'source_path == "{escaped}"')
|
||||
|
||||
def search_vectors(self, collection_name: str, query_embedding, top_k: int):
|
||||
search_params = {"metric_type": "COSINE"}
|
||||
output_fields = [
|
||||
"path",
|
||||
"source_path",
|
||||
"chunk_id",
|
||||
"mime",
|
||||
"text",
|
||||
"start_offset",
|
||||
"end_offset",
|
||||
"type",
|
||||
"name",
|
||||
]
|
||||
raw_results = self._get_client().search(
|
||||
collection_name,
|
||||
data=[query_embedding],
|
||||
anns_field="embedding",
|
||||
search_params=search_params,
|
||||
limit=top_k,
|
||||
output_fields=output_fields,
|
||||
)
|
||||
formatted: List[List[Dict[str, Any]]] = []
|
||||
for hits in raw_results:
|
||||
bucket: List[Dict[str, Any]] = []
|
||||
for hit in hits:
|
||||
hit_id, distance, entity = self._extract_hit_payload(hit)
|
||||
bucket.append({
|
||||
"id": hit_id,
|
||||
"distance": distance,
|
||||
"entity": entity,
|
||||
})
|
||||
formatted.append(bucket)
|
||||
return formatted
|
||||
|
||||
def search_by_path(self, collection_name: str, query_path: str, top_k: int):
|
||||
if query_path:
|
||||
escaped = query_path.replace('"', '\\"')
|
||||
filter_expr = f'source_path like "%{escaped}%"'
|
||||
else:
|
||||
filter_expr = "source_path like '%%'"
|
||||
results = self._get_client().query(
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
limit=top_k,
|
||||
output_fields=[
|
||||
"path",
|
||||
"source_path",
|
||||
"chunk_id",
|
||||
"mime",
|
||||
"text",
|
||||
"start_offset",
|
||||
"end_offset",
|
||||
"type",
|
||||
"name",
|
||||
],
|
||||
)
|
||||
formatted = []
|
||||
for row in results:
|
||||
entity = dict(row)
|
||||
entity.setdefault("path", entity.get("source_path"))
|
||||
formatted.append({
|
||||
"id": entity.get("path"),
|
||||
"distance": 1.0,
|
||||
"entity": entity,
|
||||
})
|
||||
return [formatted]
|
||||
|
||||
def get_all_stats(self) -> Dict[str, Any]:
|
||||
client = self._get_client()
|
||||
try:
|
||||
collection_names = client.list_collections()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to list collections: {exc}") from exc
|
||||
|
||||
collections: List[Dict[str, Any]] = []
|
||||
total_vectors = 0
|
||||
total_estimated_memory = 0
|
||||
|
||||
for name in collection_names:
|
||||
try:
|
||||
stats = client.get_collection_stats(name) or {}
|
||||
except Exception:
|
||||
stats = {}
|
||||
row_count = self._to_int(stats.get("row_count"))
|
||||
total_vectors += row_count
|
||||
|
||||
dimension: Optional[int] = None
|
||||
is_vector_collection = False
|
||||
try:
|
||||
description = client.describe_collection(name)
|
||||
except Exception:
|
||||
description = None
|
||||
|
||||
if description:
|
||||
for field in description.get("fields", []):
|
||||
if field.get("type") == DataType.FLOAT_VECTOR:
|
||||
params = field.get("params") or {}
|
||||
dimension = self._to_int(params.get("dim")) or 4096
|
||||
is_vector_collection = True
|
||||
break
|
||||
|
||||
estimated_memory = 0
|
||||
if is_vector_collection and dimension:
|
||||
estimated_memory = row_count * dimension * 4
|
||||
total_estimated_memory += estimated_memory
|
||||
|
||||
indexes: List[Dict[str, Any]] = []
|
||||
try:
|
||||
index_names = client.list_indexes(name) or []
|
||||
except Exception:
|
||||
index_names = []
|
||||
|
||||
for index_name in index_names:
|
||||
try:
|
||||
detail = client.describe_index(name, index_name) or {}
|
||||
except Exception:
|
||||
detail = {}
|
||||
indexes.append(
|
||||
{
|
||||
"index_name": index_name,
|
||||
"index_type": detail.get("index_type"),
|
||||
"metric_type": detail.get("metric_type"),
|
||||
"indexed_rows": self._to_int(detail.get("indexed_rows")),
|
||||
"pending_index_rows": self._to_int(detail.get("pending_index_rows")),
|
||||
"state": detail.get("state"),
|
||||
}
|
||||
)
|
||||
|
||||
collections.append(
|
||||
{
|
||||
"name": name,
|
||||
"row_count": row_count,
|
||||
"dimension": dimension if is_vector_collection else None,
|
||||
"estimated_memory_bytes": estimated_memory,
|
||||
"is_vector_collection": is_vector_collection,
|
||||
"indexes": indexes,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"collections": collections,
|
||||
"collection_count": len(collections),
|
||||
"total_vectors": total_vectors,
|
||||
"estimated_total_memory_bytes": total_estimated_memory,
|
||||
"db_file_size_bytes": None,
|
||||
}
|
||||
|
||||
def clear_all_data(self) -> None:
|
||||
client = self._get_client()
|
||||
for collection_name in client.list_collections():
|
||||
client.drop_collection(collection_name)
|
||||
275
services/vector_db/providers/qdrant.py
Normal file
275
services/vector_db/providers/qdrant.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qmodels
|
||||
|
||||
from .base import BaseVectorProvider
|
||||
|
||||
|
||||
class QdrantProvider(BaseVectorProvider):
|
||||
type = "qdrant"
|
||||
label = "Qdrant"
|
||||
description = "Qdrant vector database (HTTP API)."
|
||||
enabled = True
|
||||
config_schema: List[Dict[str, Any]] = [
|
||||
{
|
||||
"key": "url",
|
||||
"label": "Server URL",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"placeholder": "http://localhost:6333",
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"type": "password",
|
||||
"required": False,
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(self, config: Dict[str, Any] | None = None):
|
||||
super().__init__(config)
|
||||
self.client: Optional[QdrantClient] = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
url = (self.config.get("url") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("Qdrant URL is required")
|
||||
|
||||
api_key = (self.config.get("api_key") or None) or None
|
||||
try:
|
||||
client = QdrantClient(url=url, api_key=api_key)
|
||||
# 简单连通性校验
|
||||
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
|
||||
|
||||
def _get_client(self) -> QdrantClient:
|
||||
if not self.client:
|
||||
raise RuntimeError("Qdrant client is not initialized")
|
||||
return self.client
|
||||
|
||||
@staticmethod
|
||||
def _vector_params(vector: bool, dim: int) -> qmodels.VectorParams:
|
||||
size = dim if vector and isinstance(dim, int) and dim > 0 else 1
|
||||
return qmodels.VectorParams(size=size, distance=qmodels.Distance.COSINE)
|
||||
|
||||
def _ensure_payload_indexes(self, client: QdrantClient, collection_name: str) -> None:
|
||||
for field in ("path", "source_path"):
|
||||
try:
|
||||
client.create_payload_index(
|
||||
collection_name=collection_name,
|
||||
field_name=field,
|
||||
field_schema="keyword",
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - 依赖外部服务
|
||||
message = str(exc).lower()
|
||||
if "already exists" in message or "index exists" in message:
|
||||
continue
|
||||
# 旧版本 qdrant 可能返回带状态码的异常,这里容忍重复创建
|
||||
raise
|
||||
|
||||
def ensure_collection(self, collection_name: str, vector: bool, dim: int) -> None:
|
||||
client = self._get_client()
|
||||
try:
|
||||
exists = client.collection_exists(collection_name)
|
||||
except Exception as exc: # pragma: no cover - 依赖外部服务
|
||||
raise RuntimeError(f"Failed to check Qdrant collection '{collection_name}': {exc}") from exc
|
||||
|
||||
if exists:
|
||||
try:
|
||||
self._ensure_payload_indexes(client, collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
vectors_config = self._vector_params(vector, dim)
|
||||
try:
|
||||
client.create_collection(collection_name=collection_name, vectors_config=vectors_config)
|
||||
except Exception as exc: # pragma: no cover
|
||||
if "already exists" in str(exc).lower():
|
||||
try:
|
||||
self._ensure_payload_indexes(client, collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
raise RuntimeError(f"Failed to create Qdrant collection '{collection_name}': {exc}") from exc
|
||||
|
||||
try:
|
||||
self._ensure_payload_indexes(client, collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _point_id(uid: str) -> str:
|
||||
return str(uuid5(NAMESPACE_URL, uid))
|
||||
|
||||
def _prepare_point(self, data: Dict[str, Any]) -> qmodels.PointStruct:
|
||||
uid = data.get("path")
|
||||
if not uid:
|
||||
raise ValueError("Qdrant upsert requires 'path' in data")
|
||||
|
||||
embedding = data.get("embedding")
|
||||
if embedding is None:
|
||||
vector = [0.0]
|
||||
else:
|
||||
vector = [float(x) for x in embedding]
|
||||
|
||||
payload = {k: v for k, v in data.items() if k != "embedding"}
|
||||
payload.setdefault("vector_id", uid)
|
||||
source_path = payload.get("source_path") or payload.get("path")
|
||||
payload["path"] = source_path
|
||||
return qmodels.PointStruct(id=self._point_id(str(uid)), vector=vector, payload=payload)
|
||||
|
||||
def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
|
||||
client = self._get_client()
|
||||
point = self._prepare_point(data)
|
||||
client.upsert(collection_name=collection_name, wait=True, points=[point])
|
||||
|
||||
def delete_vector(self, collection_name: str, path: str) -> None:
|
||||
client = self._get_client()
|
||||
condition = qmodels.FieldCondition(
|
||||
key="path",
|
||||
match=qmodels.MatchValue(value=path),
|
||||
)
|
||||
flt = qmodels.Filter(must=[condition])
|
||||
selector = qmodels.FilterSelector(filter=flt)
|
||||
client.delete(collection_name=collection_name, points_selector=selector, wait=True)
|
||||
|
||||
def _format_search_results(self, points: Sequence[qmodels.ScoredPoint]):
|
||||
return [
|
||||
{
|
||||
"id": point.id,
|
||||
"distance": point.score,
|
||||
"entity": point.payload or {},
|
||||
}
|
||||
for point in points
|
||||
]
|
||||
|
||||
def search_vectors(self, collection_name: str, query_embedding, top_k: int):
|
||||
client = self._get_client()
|
||||
vector = [float(x) for x in query_embedding]
|
||||
points = client.search(
|
||||
collection_name=collection_name,
|
||||
query_vector=vector,
|
||||
limit=top_k,
|
||||
with_payload=True,
|
||||
)
|
||||
return [self._format_search_results(points)]
|
||||
|
||||
def search_by_path(self, collection_name: str, query_path: str, top_k: int):
|
||||
client = self._get_client()
|
||||
results: List[Dict[str, Any]] = []
|
||||
offset: Optional[str | int] = None
|
||||
remaining = max(top_k, 1)
|
||||
|
||||
while len(results) < top_k:
|
||||
batch_size = min(max(remaining * 2, 10), 200)
|
||||
records, next_offset = client.scroll(
|
||||
collection_name=collection_name,
|
||||
limit=batch_size,
|
||||
offset=offset,
|
||||
with_payload=True,
|
||||
)
|
||||
if not records:
|
||||
break
|
||||
|
||||
for record in records:
|
||||
payload = record.payload or {}
|
||||
path = payload.get("path")
|
||||
if query_path and path and query_path not in path:
|
||||
continue
|
||||
results.append({"id": record.id, "distance": 1.0, "entity": payload})
|
||||
if len(results) >= top_k:
|
||||
break
|
||||
|
||||
if next_offset is None or len(results) >= top_k:
|
||||
break
|
||||
offset = next_offset
|
||||
remaining = top_k - len(results)
|
||||
|
||||
return [results]
|
||||
|
||||
def _extract_vector_config(self, vectors) -> Optional[qmodels.VectorParams]:
|
||||
if isinstance(vectors, qmodels.VectorParams):
|
||||
return vectors
|
||||
if isinstance(vectors, dict):
|
||||
for value in vectors.values():
|
||||
if isinstance(value, qmodels.VectorParams):
|
||||
return value
|
||||
return None
|
||||
|
||||
def get_all_stats(self) -> Dict[str, Any]:
|
||||
client = self._get_client()
|
||||
try:
|
||||
response = client.get_collections()
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(f"Failed to list Qdrant collections: {exc}") from exc
|
||||
|
||||
collections: List[Dict[str, Any]] = []
|
||||
total_vectors = 0
|
||||
total_estimated_memory = 0
|
||||
|
||||
for description in response.collections or []:
|
||||
name = description.name
|
||||
try:
|
||||
info = client.get_collection(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
row_count = int(info.points_count or 0)
|
||||
total_vectors += row_count
|
||||
|
||||
vector_params = self._extract_vector_config(info.config.params.vectors if info.config and info.config.params else None)
|
||||
dimension = int(vector_params.size) if vector_params and vector_params.size else None
|
||||
estimated_memory = row_count * dimension * 4 if dimension else 0
|
||||
total_estimated_memory += estimated_memory
|
||||
distance = str(vector_params.distance) if vector_params and vector_params.distance else None
|
||||
|
||||
indexed_rows = int(info.indexed_vectors_count or 0)
|
||||
pending_rows = max(row_count - indexed_rows, 0)
|
||||
|
||||
collections.append(
|
||||
{
|
||||
"name": name,
|
||||
"row_count": row_count,
|
||||
"dimension": dimension,
|
||||
"estimated_memory_bytes": estimated_memory,
|
||||
"is_vector_collection": dimension is not None and dimension > 1,
|
||||
"indexes": [
|
||||
{
|
||||
"index_name": "hnsw",
|
||||
"index_type": "HNSW",
|
||||
"metric_type": distance,
|
||||
"indexed_rows": indexed_rows,
|
||||
"pending_index_rows": pending_rows,
|
||||
"state": info.status,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"collections": collections,
|
||||
"collection_count": len(collections),
|
||||
"total_vectors": total_vectors,
|
||||
"estimated_total_memory_bytes": total_estimated_memory,
|
||||
"db_file_size_bytes": None,
|
||||
}
|
||||
|
||||
def clear_all_data(self) -> None:
|
||||
client = self._get_client()
|
||||
try:
|
||||
response = client.get_collections()
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(f"Failed to list Qdrant collections: {exc}") from exc
|
||||
|
||||
for description in response.collections or []:
|
||||
try:
|
||||
client.delete_collection(description.name)
|
||||
except Exception:
|
||||
continue
|
||||
99
services/vector_db/service.py
Normal file
99
services/vector_db/service.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .config_manager import VectorDBConfigManager
|
||||
from .providers import get_provider_class, get_provider_entry
|
||||
from .providers.base import BaseVectorProvider
|
||||
|
||||
DEFAULT_VECTOR_DIMENSION = 4096
|
||||
|
||||
|
||||
class VectorDBService:
|
||||
_instance: "VectorDBService" | None = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, "_provider"):
|
||||
self._provider: Optional[BaseVectorProvider] = None
|
||||
self._provider_type: Optional[str] = None
|
||||
self._provider_config: Dict[str, Any] | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure_provider(self) -> BaseVectorProvider:
|
||||
if self._provider is None:
|
||||
await self.reload()
|
||||
assert self._provider is not None # for type checker
|
||||
return self._provider
|
||||
|
||||
async def reload(self) -> BaseVectorProvider:
|
||||
async with self._lock:
|
||||
provider_type, provider_config = await VectorDBConfigManager.load_config()
|
||||
normalized_config = dict(provider_config or {})
|
||||
if (
|
||||
self._provider
|
||||
and self._provider_type == provider_type
|
||||
and self._provider_config == normalized_config
|
||||
):
|
||||
return self._provider
|
||||
|
||||
entry = get_provider_entry(provider_type)
|
||||
if not entry:
|
||||
raise RuntimeError(f"Unknown vector database provider: {provider_type}")
|
||||
if not entry.get("enabled", True):
|
||||
raise RuntimeError(f"Vector database provider '{provider_type}' is disabled")
|
||||
|
||||
provider_cls = get_provider_class(provider_type)
|
||||
if not provider_cls:
|
||||
raise RuntimeError(f"Provider class not found for '{provider_type}'")
|
||||
|
||||
provider = provider_cls(provider_config)
|
||||
await provider.initialize()
|
||||
|
||||
self._provider = provider
|
||||
self._provider_type = provider_type
|
||||
self._provider_config = normalized_config
|
||||
return provider
|
||||
|
||||
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)
|
||||
|
||||
async def upsert_vector(self, collection_name: str, data: Dict[str, Any]) -> None:
|
||||
provider = await self._ensure_provider()
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
async def get_all_stats(self) -> Dict[str, Any]:
|
||||
provider = await self._ensure_provider()
|
||||
return provider.get_all_stats()
|
||||
|
||||
async def clear_all_data(self) -> None:
|
||||
provider = await self._ensure_provider()
|
||||
provider.clear_all_data()
|
||||
|
||||
async def current_provider(self) -> Dict[str, Any]:
|
||||
provider_type, provider_config = await VectorDBConfigManager.load_config()
|
||||
entry = get_provider_entry(provider_type) or {}
|
||||
return {
|
||||
"type": provider_type,
|
||||
"config": provider_config,
|
||||
"label": entry.get("label"),
|
||||
"enabled": entry.get("enabled", True),
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import Dict, Tuple, Any, Union, AsyncIterator
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Tuple, Any, Union, AsyncIterator, List, TYPE_CHECKING
|
||||
from fastapi import HTTPException
|
||||
import mimetypes
|
||||
from fastapi.responses import Response
|
||||
@@ -6,15 +8,50 @@ import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import aiofiles
|
||||
|
||||
from models import StorageAdapter
|
||||
from .adapters.registry import runtime_registry
|
||||
from api.response import page
|
||||
from .thumbnail import is_image_filename, is_raw_filename
|
||||
from .thumbnail import is_image_filename, is_raw_filename, is_video_filename
|
||||
from services.processors.registry import get as get_processor
|
||||
from services.tasks import task_service
|
||||
from services.logging import LogService
|
||||
from services.config import ConfigCenter
|
||||
from services.vector_db import VectorDBService
|
||||
|
||||
|
||||
CROSS_TRANSFER_TEMP_ROOT = Path("data/tmp/cross_transfer")
|
||||
DIRECT_REDIRECT_CONFIG_KEY = "enable_direct_download_307"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.task_queue import Task
|
||||
|
||||
|
||||
def _build_absolute_path(mount_path: str, rel_path: str) -> str:
|
||||
rel_norm = rel_path.lstrip('/')
|
||||
mount_norm = mount_path.rstrip('/')
|
||||
if not mount_norm:
|
||||
return '/' + rel_norm if rel_norm else '/'
|
||||
return f"{mount_norm}/{rel_norm}" if rel_norm else mount_norm
|
||||
|
||||
|
||||
def _join_rel(base: str, name: str) -> str:
|
||||
if not base:
|
||||
return name.lstrip('/')
|
||||
if not name:
|
||||
return base
|
||||
return f"{base.rstrip('/')}/{name.lstrip('/')}"
|
||||
|
||||
|
||||
def _parent_rel(rel: str) -> str:
|
||||
if not rel:
|
||||
return ''
|
||||
if '/' not in rel:
|
||||
return ''
|
||||
return rel.rsplit('/', 1)[0]
|
||||
|
||||
|
||||
async def resolve_adapter_by_path(path: str) -> Tuple[StorageAdapter, str]:
|
||||
@@ -52,6 +89,31 @@ async def resolve_adapter_and_rel(path: str):
|
||||
return adapter_instance, adapter_model, effective_root, rel
|
||||
|
||||
|
||||
async def maybe_redirect_download(adapter_instance, adapter_model, root: str, rel: str):
|
||||
"""若适配器启用了 307 直链,尝试构造重定向响应。"""
|
||||
if not rel or rel.endswith('/'):
|
||||
return None
|
||||
|
||||
config = getattr(adapter_model, "config", {}) or {}
|
||||
if not config.get(DIRECT_REDIRECT_CONFIG_KEY):
|
||||
return None
|
||||
|
||||
handler = getattr(adapter_instance, "get_direct_download_response", None)
|
||||
if not callable(handler):
|
||||
return None
|
||||
|
||||
try:
|
||||
response = await handler(root, rel)
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_method(adapter: Any, method: str):
|
||||
func = getattr(adapter, method, None)
|
||||
if not callable(func):
|
||||
@@ -59,11 +121,29 @@ async def _ensure_method(adapter: Any, method: str):
|
||||
return func
|
||||
|
||||
|
||||
async def path_is_directory(path: str) -> bool:
|
||||
"""判断给定路径是否为目录。"""
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
rel = rel.rstrip('/')
|
||||
if rel == '':
|
||||
return True
|
||||
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)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, detail="Path not found")
|
||||
if isinstance(info, dict):
|
||||
return bool(info.get("is_dir"))
|
||||
return False
|
||||
|
||||
|
||||
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Dict:
|
||||
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
|
||||
adapters = await StorageAdapter.filter(enabled=True)
|
||||
|
||||
child_mount_entries = []
|
||||
child_mount_entries: List[str] = []
|
||||
norm_prefix = norm.rstrip('/')
|
||||
for a in adapters:
|
||||
if a.path == norm:
|
||||
@@ -74,6 +154,28 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
||||
child_mount_entries.append(tail)
|
||||
child_mount_entries = sorted(set(child_mount_entries))
|
||||
|
||||
sort_field = sort_by.lower()
|
||||
reverse = sort_order.lower() == "desc"
|
||||
|
||||
def build_sort_key(item: Dict) -> Tuple:
|
||||
key = (not bool(item.get("is_dir")),)
|
||||
if sort_field == "name":
|
||||
key += (str(item.get("name", "")).lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (int(item.get("size", 0)),)
|
||||
elif sort_field == "mtime":
|
||||
key += (int(item.get("mtime", 0)),)
|
||||
else:
|
||||
key += (str(item.get("name", "")).lower(),)
|
||||
return key
|
||||
|
||||
def annotate_entry(entry: Dict) -> None:
|
||||
if not entry.get("is_dir"):
|
||||
name = entry.get("name", "")
|
||||
entry["has_thumbnail"] = bool(is_image_filename(name) or is_video_filename(name))
|
||||
else:
|
||||
entry["has_thumbnail"] = False
|
||||
|
||||
try:
|
||||
adapter_model, rel = await resolve_adapter_by_path(norm)
|
||||
adapter_instance = runtime_registry.get(adapter_model.id)
|
||||
@@ -93,57 +195,57 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
|
||||
effective_root = ''
|
||||
rel = ''
|
||||
|
||||
adapter_entries = []
|
||||
adapter_entries_page: List[Dict] = []
|
||||
adapter_entries_for_merge: List[Dict] = []
|
||||
adapter_total = 0
|
||||
covered = set()
|
||||
|
||||
if adapter_model and adapter_instance:
|
||||
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||
try:
|
||||
adapter_entries, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
||||
adapter_entries_page, adapter_total = await list_dir(effective_root, rel, page_num, page_size, sort_by, sort_order)
|
||||
except NotADirectoryError:
|
||||
raise HTTPException(400, detail="Not a directory")
|
||||
|
||||
for item in adapter_entries:
|
||||
adapter_entries_for_merge = adapter_entries_page
|
||||
|
||||
# 存在挂载节点且适配器结果被分页时,补齐完整列表以便合并排序
|
||||
if child_mount_entries and adapter_total > len(adapter_entries_page):
|
||||
full_page_size = adapter_total
|
||||
if full_page_size > 0:
|
||||
adapter_entries_for_merge, adapter_total = await list_dir(
|
||||
effective_root, rel, 1, full_page_size, sort_by, sort_order
|
||||
)
|
||||
else:
|
||||
adapter_entries_for_merge = adapter_entries_page
|
||||
|
||||
for item in adapter_entries_for_merge:
|
||||
covered.add(item["name"])
|
||||
|
||||
mount_entries = []
|
||||
for name in child_mount_entries:
|
||||
if name not in covered:
|
||||
mount_entries.append({"name": name, "is_dir": True,
|
||||
"size": 0, "mtime": 0, "type": "mount", "is_image": False})
|
||||
"size": 0, "mtime": 0, "type": "mount", "has_thumbnail": False})
|
||||
|
||||
for ent in adapter_entries:
|
||||
if not ent.get('is_dir'):
|
||||
ent['is_image'] = is_image_filename(ent['name'])
|
||||
else:
|
||||
ent['is_image'] = False
|
||||
|
||||
all_entries = adapter_entries + mount_entries
|
||||
|
||||
if mount_entries:
|
||||
reverse = sort_order.lower() == "desc"
|
||||
def get_sort_key(item):
|
||||
key = (not item.get("is_dir"),)
|
||||
sort_field = sort_by.lower()
|
||||
if sort_field == "name":
|
||||
key += (item["name"].lower(),)
|
||||
elif sort_field == "size":
|
||||
key += (item.get("size", 0),)
|
||||
elif sort_field == "mtime":
|
||||
key += (item.get("mtime", 0),)
|
||||
else:
|
||||
key += (item["name"].lower(),)
|
||||
return key
|
||||
all_entries.sort(key=get_sort_key, reverse=reverse)
|
||||
|
||||
total_entries = adapter_total + len(mount_entries)
|
||||
for ent in adapter_entries_for_merge:
|
||||
annotate_entry(ent)
|
||||
combined_entries = adapter_entries_for_merge + [
|
||||
{**ent, "has_thumbnail": False} for ent in mount_entries
|
||||
]
|
||||
combined_entries.sort(key=build_sort_key, reverse=reverse)
|
||||
|
||||
total_entries = len(combined_entries)
|
||||
start_idx = (page_num - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_entries = all_entries[start_idx:end_idx]
|
||||
page_entries = combined_entries[start_idx:end_idx]
|
||||
return page(page_entries, total_entries, page_num, page_size)
|
||||
|
||||
return page(adapter_entries, adapter_total, page_num, page_size)
|
||||
|
||||
annotate_entry_list = adapter_entries_page or []
|
||||
for ent in annotate_entry_list:
|
||||
annotate_entry(ent)
|
||||
return page(adapter_entries_page, adapter_total, page_num, page_size)
|
||||
|
||||
|
||||
async def read_file(path: str) -> Union[bytes, Any]:
|
||||
@@ -205,7 +307,12 @@ async def write_file_stream(path: str, data_iter: AsyncIterator[bytes], overwrit
|
||||
async def make_dir(path: str):
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel:
|
||||
raise HTTPException(400, detail="Cannot create root")
|
||||
await LogService.info(
|
||||
"virtual_fs",
|
||||
f"Ignored create-root request for {path}",
|
||||
details={"path": path, "reason": "root directory already exists"},
|
||||
)
|
||||
return
|
||||
mkdir_func = await _ensure_method(adapter_instance, "mkdir")
|
||||
await mkdir_func(root, rel)
|
||||
await LogService.action("virtual_fs", f"Created directory {path}", details={"path": path})
|
||||
@@ -221,22 +328,40 @@ async def delete_path(path: str):
|
||||
await LogService.action("virtual_fs", f"Deleted {path}", details={"path": path})
|
||||
|
||||
|
||||
async def move_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
||||
async def move_path(
|
||||
src: str,
|
||||
dst: str,
|
||||
overwrite: bool = False,
|
||||
return_debug: bool = True,
|
||||
allow_cross: bool = False,
|
||||
):
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
"overwrite": overwrite,
|
||||
"operation": "move",
|
||||
"queued": False,
|
||||
}
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot move or rename mount root")
|
||||
if not rel_d:
|
||||
raise HTTPException(400, detail="Invalid destination")
|
||||
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
if not allow_cross:
|
||||
raise HTTPException(400, detail="Cross-adapter move not supported")
|
||||
queue_info = await _enqueue_cross_mount_transfer(
|
||||
operation="move",
|
||||
src=src,
|
||||
dst=dst,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
debug_info.update(queue_info)
|
||||
return debug_info if return_debug else None
|
||||
|
||||
exists_func = getattr(adapter_s, "exists", None)
|
||||
stat_func = getattr(adapter_s, "stat_path", None)
|
||||
delete_func = await _ensure_method(adapter_s, "delete")
|
||||
@@ -366,7 +491,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug:
|
||||
|
||||
|
||||
async def stream_file(path: str, range_header: str | None):
|
||||
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
|
||||
adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(path)
|
||||
if not rel or rel.endswith('/'):
|
||||
raise HTTPException(400, detail="Path is a directory")
|
||||
if is_raw_filename(rel):
|
||||
@@ -399,6 +524,10 @@ async def stream_file(path: str, range_header: str | None):
|
||||
except Exception as e:
|
||||
raise HTTPException(500, detail=f"RAW file processing failed: {e}")
|
||||
|
||||
redirect_response = await maybe_redirect_download(adapter_instance, adapter_model, root, rel)
|
||||
if redirect_response is not None:
|
||||
return redirect_response
|
||||
|
||||
stream_impl = getattr(adapter_instance, "stream_file", None)
|
||||
if callable(stream_impl):
|
||||
return await stream_impl(root, rel, range_header)
|
||||
@@ -407,30 +536,117 @@ async def stream_file(path: str, range_header: str | None):
|
||||
return Response(content=data, media_type=mime or "application/octet-stream")
|
||||
|
||||
|
||||
async def _gather_vector_index(full_path: str, limit: int = 20):
|
||||
"""查询与文件相关的索引信息。失败时返回 None。"""
|
||||
vector_db = VectorDBService()
|
||||
try:
|
||||
raw_results = await vector_db.search_by_path("vector_collection", full_path, max(limit * 2, 20))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
matched = []
|
||||
if raw_results:
|
||||
buckets = raw_results if isinstance(raw_results, list) else [raw_results]
|
||||
for bucket in buckets:
|
||||
if not bucket:
|
||||
continue
|
||||
for record in bucket:
|
||||
entity = dict((record or {}).get("entity") or {})
|
||||
source_path = entity.get("source_path") or entity.get("path") or ""
|
||||
if source_path != full_path:
|
||||
continue
|
||||
entry = {
|
||||
"chunk_id": str(entity.get("chunk_id")) if entity.get("chunk_id") is not None else None,
|
||||
"type": entity.get("type"),
|
||||
"mime": entity.get("mime"),
|
||||
"name": entity.get("name"),
|
||||
"start_offset": entity.get("start_offset"),
|
||||
"end_offset": entity.get("end_offset"),
|
||||
"vector_id": entity.get("vector_id"),
|
||||
}
|
||||
text = entity.get("text") or entity.get("description")
|
||||
if text:
|
||||
preview_limit = 400
|
||||
entry["preview"] = text[:preview_limit]
|
||||
entry["preview_truncated"] = len(text) > preview_limit
|
||||
matched.append(entry)
|
||||
|
||||
if not matched:
|
||||
return {"total": 0, "entries": [], "by_type": {}, "has_more": False}
|
||||
|
||||
type_counts: Dict[str, int] = {}
|
||||
for item in matched:
|
||||
key = item.get("type") or "unknown"
|
||||
type_counts[key] = type_counts.get(key, 0) + 1
|
||||
|
||||
has_more = len(matched) > limit
|
||||
return {
|
||||
"total": len(matched),
|
||||
"entries": matched[:limit],
|
||||
"by_type": type_counts,
|
||||
"has_more": has_more,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
async def stat_file(path: str):
|
||||
adapter_instance, _, root, rel = await 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")
|
||||
return await stat_func(root, rel)
|
||||
info = await stat_func(root, rel)
|
||||
|
||||
if isinstance(info, dict):
|
||||
info.setdefault("path", path)
|
||||
try:
|
||||
is_dir = bool(info.get("is_dir"))
|
||||
except Exception:
|
||||
is_dir = False
|
||||
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:
|
||||
vector_index = await _gather_vector_index(path)
|
||||
if vector_index is not None:
|
||||
info["vector_index"] = vector_index
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: bool = True):
|
||||
async def copy_path(
|
||||
src: str,
|
||||
dst: str,
|
||||
overwrite: bool = False,
|
||||
return_debug: bool = True,
|
||||
allow_cross: bool = False,
|
||||
):
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
debug_info = {
|
||||
"src": src, "dst": dst,
|
||||
"rel_s": rel_s, "rel_d": rel_d,
|
||||
"root_s": root_s, "root_d": root_d,
|
||||
"overwrite": overwrite
|
||||
"overwrite": overwrite,
|
||||
"operation": "copy",
|
||||
"queued": False,
|
||||
}
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||
if not rel_s:
|
||||
raise HTTPException(400, detail="Cannot copy mount root")
|
||||
if not rel_d:
|
||||
raise HTTPException(400, detail="Invalid destination")
|
||||
|
||||
if adapter_model_s.id != adapter_model_d.id:
|
||||
if not allow_cross:
|
||||
raise HTTPException(400, detail="Cross-adapter copy not supported")
|
||||
queue_info = await _enqueue_cross_mount_transfer(
|
||||
operation="copy",
|
||||
src=src,
|
||||
dst=dst,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
debug_info.update(queue_info)
|
||||
return debug_info if return_debug else None
|
||||
|
||||
exists_func = getattr(adapter_s, "exists", None)
|
||||
stat_func = getattr(adapter_s, "stat_path", None)
|
||||
delete_func = getattr(adapter_s, "delete", None)
|
||||
@@ -476,28 +692,424 @@ async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: b
|
||||
return debug_info if return_debug else None
|
||||
|
||||
|
||||
async def process_file(path: str, processor_type: str, config: dict, save_to: str = None):
|
||||
"""
|
||||
使用指定处理器处理文件,并可选择保存到新路径
|
||||
:param path: 源文件路径
|
||||
:param processor_type: 处理器类型
|
||||
:param config: 处理器配置
|
||||
:param save_to: 保存路径(可选),不指定则只返回处理结果
|
||||
:return: 处理后的文件内容或保存结果
|
||||
"""
|
||||
data = await read_file(path)
|
||||
async def _enqueue_cross_mount_transfer(operation: str, src: str, dst: str, overwrite: bool) -> Dict[str, Any]:
|
||||
if operation not in {"move", "copy"}:
|
||||
raise HTTPException(400, detail="Unsupported transfer operation")
|
||||
|
||||
adapter_s, adapter_model_s, _, _ = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
if adapter_model_s.id == adapter_model_d.id:
|
||||
raise HTTPException(400, detail="Cross-adapter transfer requested but adapters are identical")
|
||||
|
||||
dst_exists = False
|
||||
exists_func = getattr(adapter_d, "exists", None)
|
||||
if callable(exists_func):
|
||||
dst_exists = await exists_func(root_d, rel_d)
|
||||
else:
|
||||
try:
|
||||
await stat_file(dst)
|
||||
dst_exists = True
|
||||
except FileNotFoundError:
|
||||
dst_exists = False
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
dst_exists = False
|
||||
else:
|
||||
raise
|
||||
|
||||
if dst_exists and not overwrite:
|
||||
raise HTTPException(409, detail="Destination already exists")
|
||||
|
||||
payload = {
|
||||
"operation": operation,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"overwrite": overwrite,
|
||||
}
|
||||
|
||||
from services.task_queue import task_queue_service
|
||||
|
||||
task = await task_queue_service.add_task("cross_mount_transfer", payload)
|
||||
return {
|
||||
"queued": True,
|
||||
"task_id": task.id,
|
||||
"task_name": "cross_mount_transfer",
|
||||
"dst_exists": dst_exists,
|
||||
"cross_adapter": True,
|
||||
}
|
||||
|
||||
|
||||
async def run_cross_mount_transfer_task(task: "Task") -> Dict[str, Any]:
|
||||
from services.task_queue import task_queue_service
|
||||
|
||||
params = task.task_info or {}
|
||||
operation = params.get("operation")
|
||||
src = params.get("src")
|
||||
dst = params.get("dst")
|
||||
overwrite = bool(params.get("overwrite", False))
|
||||
|
||||
if operation not in {"move", "copy"}:
|
||||
raise ValueError(f"Unsupported cross mount operation: {operation}")
|
||||
if not src or not dst:
|
||||
raise ValueError("Missing src or dst for cross mount transfer")
|
||||
|
||||
adapter_s, adapter_model_s, root_s, rel_s = await resolve_adapter_and_rel(src)
|
||||
adapter_d, adapter_model_d, root_d, rel_d = await resolve_adapter_and_rel(dst)
|
||||
|
||||
await task_queue_service.update_meta(task.id, {
|
||||
"operation": operation,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
})
|
||||
|
||||
if adapter_model_s.id == adapter_model_d.id:
|
||||
if operation == "move":
|
||||
await move_path(src, dst, overwrite=overwrite, return_debug=False, allow_cross=False)
|
||||
else:
|
||||
await copy_path(src, dst, overwrite=overwrite, return_debug=False, allow_cross=False)
|
||||
return {
|
||||
"mode": "direct",
|
||||
"operation": operation,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"files": 0,
|
||||
"bytes": 0,
|
||||
}
|
||||
|
||||
if not rel_s:
|
||||
raise ValueError("Cannot transfer mount root")
|
||||
if not rel_d:
|
||||
raise ValueError("Invalid destination")
|
||||
|
||||
dst_exists = False
|
||||
exists_func = getattr(adapter_d, "exists", None)
|
||||
if callable(exists_func):
|
||||
dst_exists = await exists_func(root_d, rel_d)
|
||||
else:
|
||||
try:
|
||||
await stat_file(dst)
|
||||
dst_exists = True
|
||||
except FileNotFoundError:
|
||||
dst_exists = False
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
|
||||
if dst_exists and not overwrite:
|
||||
raise ValueError("Destination already exists")
|
||||
if dst_exists and overwrite:
|
||||
await delete_path(dst)
|
||||
|
||||
try:
|
||||
src_stat = await stat_file(src)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
raise FileNotFoundError(src) from exc
|
||||
raise
|
||||
|
||||
src_is_dir = bool(src_stat.get("is_dir"))
|
||||
|
||||
files_to_transfer: List[Dict[str, Any]] = []
|
||||
dirs_to_create: List[str] = []
|
||||
|
||||
await task_queue_service.update_progress(task.id, {
|
||||
"stage": "preparing",
|
||||
"percent": 0.0,
|
||||
"detail": "Collecting source entries",
|
||||
})
|
||||
|
||||
if src_is_dir:
|
||||
if rel_d:
|
||||
dirs_to_create.append(rel_d)
|
||||
list_dir = await _ensure_method(adapter_s, "list_dir")
|
||||
stack: List[Tuple[str, str, str]] = [(rel_s, rel_d, '')]
|
||||
page_size = 200
|
||||
|
||||
while stack:
|
||||
current_rel, current_dst_rel, current_relative = stack.pop()
|
||||
page = 1
|
||||
while True:
|
||||
entries, total = await list_dir(root_s, current_rel, page, page_size, "name", "asc")
|
||||
if not entries and (total or 0) == 0:
|
||||
break
|
||||
for entry in entries:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
child_rel = _join_rel(current_rel, name)
|
||||
child_dst_rel = _join_rel(current_dst_rel, name)
|
||||
child_relative = _join_rel(current_relative, name)
|
||||
if entry.get("is_dir"):
|
||||
dirs_to_create.append(child_dst_rel)
|
||||
stack.append((child_rel, child_dst_rel, child_relative))
|
||||
else:
|
||||
files_to_transfer.append({
|
||||
"src_rel": child_rel,
|
||||
"dst_rel": child_dst_rel,
|
||||
"relative_rel": child_relative or name,
|
||||
"size": entry.get("size"),
|
||||
"name": name,
|
||||
})
|
||||
if total is None or page * page_size >= (total or 0):
|
||||
break
|
||||
page += 1
|
||||
else:
|
||||
relative_rel = rel_s or (src_stat.get("name") or "file")
|
||||
files_to_transfer.append({
|
||||
"src_rel": rel_s,
|
||||
"dst_rel": rel_d,
|
||||
"relative_rel": relative_rel,
|
||||
"size": src_stat.get("size"),
|
||||
"name": src_stat.get("name") or rel_s.split('/')[-1],
|
||||
})
|
||||
parent_dir = _parent_rel(rel_d)
|
||||
if parent_dir:
|
||||
dirs_to_create.append(parent_dir)
|
||||
|
||||
CROSS_TRANSFER_TEMP_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = CROSS_TRANSFER_TEMP_ROOT / task.id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bytes_downloaded = 0
|
||||
total_dynamic_bytes = sum((f["size"] or 0) for f in files_to_transfer)
|
||||
|
||||
try:
|
||||
for job in files_to_transfer:
|
||||
src_abs = _build_absolute_path(adapter_model_s.path, job["src_rel"])
|
||||
data = await read_file(src_abs)
|
||||
temp_path = temp_dir / job["relative_rel"]
|
||||
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.open(temp_path, "wb") as f:
|
||||
await f.write(data)
|
||||
actual_size = len(data)
|
||||
job["temp_path"] = temp_path
|
||||
prev_size = job.get("size") or 0
|
||||
if prev_size <= 0:
|
||||
total_dynamic_bytes += actual_size
|
||||
job_size = actual_size
|
||||
else:
|
||||
job_size = prev_size
|
||||
job["size"] = job_size
|
||||
bytes_downloaded += actual_size
|
||||
percent = None
|
||||
total_for_percent = total_dynamic_bytes if total_dynamic_bytes else bytes_downloaded
|
||||
if total_for_percent:
|
||||
percent = min(100.0, round(bytes_downloaded / total_for_percent * 100, 2))
|
||||
await task_queue_service.update_progress(task.id, {
|
||||
"stage": "downloading",
|
||||
"percent": percent,
|
||||
"bytes_done": bytes_downloaded,
|
||||
"bytes_total": total_dynamic_bytes or None,
|
||||
"detail": f"Downloaded {job['name']}",
|
||||
})
|
||||
|
||||
mkdir_func = await _ensure_method(adapter_d, "mkdir")
|
||||
ensured_dirs: set[str] = set()
|
||||
|
||||
async def ensure_dir(rel_path: str):
|
||||
if not rel_path or rel_path in ensured_dirs:
|
||||
return
|
||||
parent = _parent_rel(rel_path)
|
||||
if parent:
|
||||
await ensure_dir(parent)
|
||||
try:
|
||||
await mkdir_func(root_d, rel_path)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except HTTPException as exc:
|
||||
if exc.status_code not in {409, 400}:
|
||||
raise
|
||||
except Exception:
|
||||
# Assume directory already exists
|
||||
pass
|
||||
ensured_dirs.add(rel_path)
|
||||
|
||||
for dir_rel in sorted({d for d in dirs_to_create if d}, key=lambda x: x.count('/')):
|
||||
await ensure_dir(dir_rel)
|
||||
|
||||
uploaded_bytes = 0
|
||||
total_bytes = sum((f["size"] or 0) for f in files_to_transfer)
|
||||
|
||||
async def iter_temp_file(path: Path, chunk_size: int = 512 * 1024):
|
||||
async with aiofiles.open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
for job in files_to_transfer:
|
||||
parent_dir = _parent_rel(job["dst_rel"])
|
||||
if parent_dir:
|
||||
await ensure_dir(parent_dir)
|
||||
dst_abs = _build_absolute_path(adapter_model_d.path, job["dst_rel"])
|
||||
temp_path: Path = job["temp_path"]
|
||||
await write_file_stream(dst_abs, iter_temp_file(temp_path), overwrite=overwrite)
|
||||
uploaded_bytes += job["size"] or 0
|
||||
percent = None
|
||||
if total_bytes:
|
||||
percent = min(100.0, round(uploaded_bytes / total_bytes * 100, 2))
|
||||
await task_queue_service.update_progress(task.id, {
|
||||
"stage": "uploading",
|
||||
"percent": percent,
|
||||
"bytes_done": uploaded_bytes,
|
||||
"bytes_total": total_bytes or None,
|
||||
"detail": f"Uploaded {job['name']}",
|
||||
})
|
||||
|
||||
if operation == "move":
|
||||
await delete_path(src)
|
||||
|
||||
await task_queue_service.update_progress(task.id, {
|
||||
"stage": "completed",
|
||||
"percent": 100.0,
|
||||
"bytes_done": total_bytes,
|
||||
"bytes_total": total_bytes,
|
||||
"detail": "Completed",
|
||||
})
|
||||
|
||||
await task_queue_service.update_meta(task.id, {
|
||||
"files": len(files_to_transfer),
|
||||
"directories": len({d for d in dirs_to_create if d}),
|
||||
"bytes": total_bytes,
|
||||
"operation": operation,
|
||||
})
|
||||
|
||||
await LogService.action(
|
||||
"virtual_fs",
|
||||
f"Cross-adapter {operation} from {src} to {dst}",
|
||||
details={
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"operation": operation,
|
||||
"files": len(files_to_transfer),
|
||||
"bytes": total_bytes,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"mode": "cross",
|
||||
"operation": operation,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"files": len(files_to_transfer),
|
||||
"bytes": total_bytes,
|
||||
}
|
||||
|
||||
finally:
|
||||
try:
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception:
|
||||
await LogService.info(
|
||||
"virtual_fs",
|
||||
"Failed to cleanup cross transfer temp dir",
|
||||
details={"task_id": task.id, "temp_dir": str(temp_dir)},
|
||||
)
|
||||
async def process_file(
|
||||
path: str,
|
||||
processor_type: str,
|
||||
config: dict,
|
||||
save_to: str | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> Any:
|
||||
"""处理指定路径(文件或目录)。目录会递归处理其下所有文件。"""
|
||||
|
||||
processor = get_processor(processor_type)
|
||||
if not processor:
|
||||
raise HTTPException(
|
||||
400, detail=f"Processor {processor_type} not found")
|
||||
result = await processor.process(data, path, config)
|
||||
if save_to and getattr(processor, "produces_file", False):
|
||||
raise HTTPException(400, detail=f"Processor {processor_type} not found")
|
||||
|
||||
actual_is_dir = await path_is_directory(path)
|
||||
|
||||
supported_exts = getattr(processor, "supported_exts", None) or []
|
||||
allowed_exts = {
|
||||
str(ext).lower().lstrip('.')
|
||||
for ext in supported_exts
|
||||
if isinstance(ext, str)
|
||||
}
|
||||
|
||||
def matches_extension(rel_path: str) -> bool:
|
||||
if not allowed_exts:
|
||||
return True
|
||||
if '.' not in rel_path:
|
||||
return '' in allowed_exts
|
||||
ext = rel_path.rsplit('.', 1)[-1].lower()
|
||||
return ext in allowed_exts or f'.{ext}' in allowed_exts
|
||||
|
||||
def coerce_result_bytes(result: Any) -> bytes:
|
||||
if isinstance(result, Response):
|
||||
result_bytes = result.body
|
||||
else:
|
||||
result_bytes = result
|
||||
await write_file(save_to, result_bytes)
|
||||
return {"saved_to": save_to}
|
||||
return result.body
|
||||
if isinstance(result, (bytes, bytearray)):
|
||||
return bytes(result)
|
||||
if isinstance(result, str):
|
||||
return result.encode('utf-8')
|
||||
raise HTTPException(500, detail="Processor must return bytes/Response when produces_file=True")
|
||||
|
||||
def build_absolute_path(mount_path: str, rel_path: str) -> str:
|
||||
rel_norm = rel_path.lstrip('/')
|
||||
mount_norm = mount_path.rstrip('/')
|
||||
if not mount_norm:
|
||||
return '/' + rel_norm if rel_norm else '/'
|
||||
return f"{mount_norm}/{rel_norm}" if rel_norm else mount_norm
|
||||
|
||||
if actual_is_dir:
|
||||
if save_to:
|
||||
raise HTTPException(400, detail="Directory processing does not support custom save_to path")
|
||||
if not overwrite:
|
||||
raise HTTPException(400, detail="Directory processing requires overwrite")
|
||||
|
||||
adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(path)
|
||||
rel = rel.rstrip('/')
|
||||
list_dir = await _ensure_method(adapter_instance, "list_dir")
|
||||
processed_count = 0
|
||||
stack: List[str] = [rel]
|
||||
page_size = 200
|
||||
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
page = 1
|
||||
while True:
|
||||
entries, total = await list_dir(root, current, page, page_size, "name", "asc")
|
||||
if not entries and (total or 0) == 0:
|
||||
break
|
||||
|
||||
for entry in entries:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
child_rel = f"{current}/{name}" if current else name
|
||||
if entry.get("is_dir"):
|
||||
stack.append(child_rel)
|
||||
continue
|
||||
if not matches_extension(child_rel):
|
||||
continue
|
||||
absolute_path = build_absolute_path(adapter_model.path, child_rel)
|
||||
data = await read_file(absolute_path)
|
||||
result = await processor.process(data, absolute_path, config)
|
||||
if getattr(processor, "produces_file", False):
|
||||
result_bytes = coerce_result_bytes(result)
|
||||
await write_file(absolute_path, result_bytes)
|
||||
processed_count += 1
|
||||
|
||||
if total is None or page * page_size >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return {"processed_files": processed_count}
|
||||
|
||||
# 单文件处理
|
||||
data = await read_file(path)
|
||||
result = await processor.process(data, path, config)
|
||||
|
||||
target_path = save_to
|
||||
if overwrite and not target_path:
|
||||
target_path = path
|
||||
|
||||
if target_path and getattr(processor, "produces_file", False):
|
||||
result_bytes = coerce_result_bytes(result)
|
||||
await write_file(target_path, result_bytes)
|
||||
return {"saved_to": target_path}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
102
templates/email/password_reset.html
Normal file
102
templates/email/password_reset.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 密码重置</title>
|
||||
<style>
|
||||
body {
|
||||
background: #f4f7fb;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(120deg, #4f46e5, #7c3aed);
|
||||
padding: 32px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
}
|
||||
.content p {
|
||||
margin: 16px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
margin: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: linear-gradient(120deg, #6366f1, #8b5cf6);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.32);
|
||||
}
|
||||
.info-box {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid rgba(107, 114, 128, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.footer a {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>重置你的 Foxel 密码</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>你好,${username}。</p>
|
||||
<p>我们收到了重置你 Foxel 帐号密码的请求。请点击下方按钮完成密码重置操作:</p>
|
||||
<div class="cta">
|
||||
<a href="${reset_link}" target="_blank" rel="noopener">重置密码</a>
|
||||
</div>
|
||||
<p>如果按钮无法点击,你也可以复制下面的链接到浏览器打开:</p>
|
||||
<div class="info-box">
|
||||
<div style="word-break: break-all;">${reset_link}</div>
|
||||
</div>
|
||||
<p>该链接在 ${expire_minutes} 分钟内有效。若你未发起此请求,请忽略本邮件,你的密码不会发生变化。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div>此邮件由 Foxel 系统自动发送,请勿直接回复。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
97
templates/email/test.html
Normal file
97
templates/email/test.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foxel 邮件配置测试</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
background: linear-gradient(135deg, #eef2ff, #e0f2fe);
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.banner {
|
||||
background: linear-gradient(120deg, #1d4ed8, #6366f1);
|
||||
padding: 36px;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.banner h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cta-box {
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.08));
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.cta-box strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.footer {
|
||||
padding: 24px 32px;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="banner">
|
||||
<h1>Foxel 邮件服务已连通</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="badge">Mail Delivery Test</div>
|
||||
<p>你好,${username}!</p>
|
||||
<p>
|
||||
这是一封来自 <strong>Foxel</strong> 的测试邮件。如果你能够正常阅读到这段内容,说明系统已经成功与配置的邮箱服务建立连接。
|
||||
</p>
|
||||
<div class="cta-box">
|
||||
<strong>接下来可以做什么?</strong>
|
||||
<ul style="margin: 0; padding-left: 18px; line-height: 1.7;">
|
||||
<li>继续完善系统通知、密码重置等业务功能</li>
|
||||
<li>在后台页面中自定义更精美的邮件模板</li>
|
||||
<li>保持发送凭据安全,避免泄露</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
本邮件由 Foxel 系统自动发送,请勿直接回复。如非本人操作,请忽略此邮件。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
web/public/icon/claude-color.svg
Normal file
1
web/public/icon/claude-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
web/public/icon/deepseek-color.svg
Normal file
1
web/public/icon/deepseek-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
web/public/icon/gemini-color.svg
Normal file
1
web/public/icon/gemini-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
web/public/icon/openai.svg
Normal file
1
web/public/icon/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
web/public/icon/siliconcloud-color.svg
Normal file
1
web/public/icon/siliconcloud-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -5,12 +5,10 @@ import { status as getStatus } from './api/config.ts';
|
||||
import type { SystemStatus } from './api/config.ts';
|
||||
import { SystemContext } from './contexts/SystemContext.tsx';
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx';
|
||||
import { Spin, ConfigProvider } from 'antd';
|
||||
import { Spin } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
import { I18nProvider, useI18n } from './i18n';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import { I18nProvider } from './i18n';
|
||||
|
||||
function AppInner() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
@@ -20,9 +18,14 @@ function AppInner() {
|
||||
const status = await getStatus();
|
||||
setStatus(status);
|
||||
document.title = status.title;
|
||||
const favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
|
||||
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.logo;
|
||||
favicon.href = status.favicon || status.logo;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check initialization status:", error);
|
||||
@@ -39,26 +42,21 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
const { lang } = useI18n();
|
||||
const locale = lang === 'zh' ? zhCN : enUS;
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={locale}>
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
</ConfigProvider>
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface AdapterItem {
|
||||
export interface AdapterTypeField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'password' | 'number';
|
||||
type: 'string' | 'password' | 'number' | 'boolean';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
default?: any;
|
||||
|
||||
89
web/src/api/aiProviders.ts
Normal file
89
web/src/api/aiProviders.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import request from './client';
|
||||
|
||||
export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools';
|
||||
|
||||
export interface AIProviderPayload {
|
||||
name: string;
|
||||
identifier: string;
|
||||
provider_type?: string | null;
|
||||
api_format: 'openai' | 'gemini';
|
||||
base_url?: string | null;
|
||||
api_key?: string | null;
|
||||
logo_url?: string | null;
|
||||
extra_config?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AIProvider extends Omit<AIProviderPayload, 'extra_config'> {
|
||||
id: number;
|
||||
extra_config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
models?: AIModel[];
|
||||
}
|
||||
|
||||
export interface AIModelPayload {
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
description?: string | null;
|
||||
capabilities?: AIAbility[];
|
||||
context_window?: number | null;
|
||||
embedding_dimensions?: number | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AIModel extends Omit<AIModelPayload, 'metadata'> {
|
||||
id: number;
|
||||
provider_id: number;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
provider?: AIProvider;
|
||||
}
|
||||
|
||||
export type AIDefaultAssignments = Partial<Record<AIAbility, number | null>>;
|
||||
export type AIDefaultModels = Partial<Record<AIAbility, AIModel | null>>;
|
||||
|
||||
export async function fetchProviders() {
|
||||
const data = await request<{ providers: AIProvider[] }>('/ai/providers');
|
||||
return data.providers;
|
||||
}
|
||||
|
||||
export async function createProvider(payload: AIProviderPayload) {
|
||||
return request<AIProvider>('/ai/providers', { method: 'POST', json: payload });
|
||||
}
|
||||
|
||||
export async function updateProvider(id: number, payload: Partial<AIProviderPayload>) {
|
||||
return request<AIProvider>(`/ai/providers/${id}`, { method: 'PUT', json: payload });
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: number) {
|
||||
await request(`/ai/providers/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function syncProviderModels(id: number) {
|
||||
return request<{ created: number; updated: number }>(`/ai/providers/${id}/sync-models`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchRemoteModels(providerId: number) {
|
||||
return request<{ models: AIModelPayload[] }>(`/ai/providers/${providerId}/remote-models`);
|
||||
}
|
||||
|
||||
export async function createModel(providerId: number, payload: AIModelPayload) {
|
||||
return request<AIModel>(`/ai/providers/${providerId}/models`, { method: 'POST', json: payload });
|
||||
}
|
||||
|
||||
export async function updateModel(modelId: number, payload: Partial<AIModelPayload>) {
|
||||
return request<AIModel>(`/ai/models/${modelId}`, { method: 'PUT', json: payload });
|
||||
}
|
||||
|
||||
export async function deleteModel(modelId: number) {
|
||||
await request(`/ai/models/${modelId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchDefaults() {
|
||||
return request<AIDefaultModels>('/ai/defaults');
|
||||
}
|
||||
|
||||
export async function updateDefaults(payload: AIDefaultAssignments) {
|
||||
return request<AIDefaultModels>('/ai/defaults', { method: 'PUT', json: payload });
|
||||
}
|
||||
@@ -17,6 +17,30 @@ export interface AuthResponse {
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
gravatar_url: string;
|
||||
}
|
||||
|
||||
export interface UpdateMePayload {
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
old_password?: string;
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -42,4 +66,30 @@ export const authApi = {
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
me: async () => {
|
||||
return await request<MeResponse>('/auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
updateMe: async (payload: UpdateMePayload) => {
|
||||
return await request<MeResponse>('/auth/me', {
|
||||
method: 'PUT',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||
return await request('/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
verifyPasswordResetToken: async (token: string) => {
|
||||
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||
},
|
||||
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||
return await request('/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,4 +73,5 @@ async function request<T = any>(url: string, options: RequestOptions = {}): Prom
|
||||
export { vfsApi, type VfsEntry, type DirListing } from './vfs';
|
||||
export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta } from './adapters';
|
||||
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share';
|
||||
export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
|
||||
export default request;
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SystemStatus {
|
||||
version: string;
|
||||
title: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
is_initialized: boolean;
|
||||
app_domain?: string;
|
||||
file_domain?: string;
|
||||
|
||||
41
web/src/api/email.ts
Normal file
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './client';
|
||||
|
||||
export interface EmailTestPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
template?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||
return request<{ task_id: string }>('/email/test', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
template: 'test',
|
||||
context: {},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmailTemplates() {
|
||||
return request<{ templates: string[] }>('/email/templates');
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(name: string) {
|
||||
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(name: string, content: string) {
|
||||
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
json: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
json: { context },
|
||||
});
|
||||
}
|
||||
35
web/src/api/offlineDownloads.ts
Normal file
35
web/src/api/offlineDownloads.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import request from './client';
|
||||
|
||||
export interface TaskProgress {
|
||||
stage?: string | null;
|
||||
percent?: number | null;
|
||||
bytes_total?: number | null;
|
||||
bytes_done?: number | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface OfflineDownloadTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed';
|
||||
result?: any;
|
||||
error?: string | null;
|
||||
task_info: Record<string, any>;
|
||||
progress?: TaskProgress | null;
|
||||
meta?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface OfflineDownloadCreate {
|
||||
url: string;
|
||||
dest_dir: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export const offlineDownloadsApi = {
|
||||
create: (payload: OfflineDownloadCreate) => request<{ task_id: string }>('/offline-downloads/', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
}),
|
||||
list: () => request<OfflineDownloadTask[]>('/offline-downloads/'),
|
||||
detail: (taskId: string) => request<OfflineDownloadTask>(`/offline-downloads/${taskId}`),
|
||||
};
|
||||
@@ -15,7 +15,8 @@ export interface ProcessorTypeMeta {
|
||||
name: string;
|
||||
supported_exts: string[];
|
||||
config_schema: ProcessorTypeField[];
|
||||
produces_file:boolean;
|
||||
produces_file: boolean;
|
||||
module_path?: string | null;
|
||||
}
|
||||
|
||||
export const processorsApi = {
|
||||
@@ -29,11 +30,33 @@ export const processorsApi = {
|
||||
save_to?: string;
|
||||
overwrite?: boolean;
|
||||
}) =>
|
||||
request<any>('/processors/process', {
|
||||
request<{ task_id: string }>('/processors/process', {
|
||||
method: 'POST',
|
||||
json: params,
|
||||
}),
|
||||
processDirectory: (params: {
|
||||
path: string;
|
||||
processor_type: string;
|
||||
config: any;
|
||||
overwrite: boolean;
|
||||
max_depth?: number | null;
|
||||
suffix?: string | null;
|
||||
}) =>
|
||||
request<{ task_ids: string[]; scheduled: number }>('/processors/process-directory', {
|
||||
method: 'POST',
|
||||
json: params,
|
||||
}),
|
||||
getSource: (type: string) =>
|
||||
request<{ source: string; module_path: string }>('/processors/source/' + encodeURIComponent(type), {
|
||||
method: 'GET',
|
||||
}),
|
||||
updateSource: (type: string, source: string) =>
|
||||
request<boolean>('/processors/source/' + encodeURIComponent(type), {
|
||||
method: 'PUT',
|
||||
json: { source },
|
||||
}),
|
||||
reload: () =>
|
||||
request<boolean>('/processors/reload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -23,10 +23,15 @@ export interface ShareCreatePayload {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ClearExpiredResult {
|
||||
deleted_count: number;
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
create: (payload: ShareCreatePayload) => request<ShareInfoWithPassword>('/shares', { method: 'POST', json: payload }),
|
||||
list: () => request<ShareInfo[]>('/shares'),
|
||||
remove: (shareId: number) => request<void>(`/shares/${shareId}`, { method: 'DELETE' }),
|
||||
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
|
||||
get: (token: string) => request<ShareInfo>(`/s/${token}`),
|
||||
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
|
||||
listDir: (token: string, path: string = '/', password?: string) => {
|
||||
@@ -40,4 +45,4 @@ export const shareApi = {
|
||||
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
|
||||
return password ? `${url}&password=${encodeURIComponent(password)}` : url;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import request from './client';
|
||||
import type { TaskProgress } from './offlineDownloads';
|
||||
|
||||
export interface AutomationTask {
|
||||
id: number;
|
||||
@@ -21,6 +22,17 @@ export interface QueuedTask {
|
||||
result?: any;
|
||||
error?: string;
|
||||
task_info: Record<string, any>;
|
||||
progress?: TaskProgress | null;
|
||||
meta?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface TaskQueueSettings {
|
||||
concurrency: number;
|
||||
active_workers: number;
|
||||
}
|
||||
|
||||
export interface TaskQueueSettingsUpdate {
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
@@ -29,4 +41,6 @@ export const tasksApi = {
|
||||
update: (id: number, payload: AutomationTaskUpdate) => request<AutomationTask>(`/tasks/${id}`, { method: 'PUT', json: payload }),
|
||||
remove: (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
getQueue: () => request<QueuedTask[]>('/tasks/queue'),
|
||||
};
|
||||
getQueueSettings: () => request<TaskQueueSettings>('/tasks/queue/settings'),
|
||||
updateQueueSettings: (payload: TaskQueueSettingsUpdate) => request<TaskQueueSettings>('/tasks/queue/settings', { method: 'POST', json: payload }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
import client from './client';
|
||||
|
||||
export interface VectorDBIndexInfo {
|
||||
index_name: string;
|
||||
index_type?: string;
|
||||
metric_type?: string;
|
||||
indexed_rows: number;
|
||||
pending_index_rows: number;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface VectorDBCollectionStats {
|
||||
name: string;
|
||||
row_count: number;
|
||||
dimension: number | null;
|
||||
estimated_memory_bytes: number;
|
||||
is_vector_collection: boolean;
|
||||
indexes: VectorDBIndexInfo[];
|
||||
}
|
||||
|
||||
export interface VectorDBStats {
|
||||
collections: VectorDBCollectionStats[];
|
||||
collection_count: number;
|
||||
total_vectors: number;
|
||||
estimated_total_memory_bytes: number;
|
||||
db_file_size_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface VectorDBProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password';
|
||||
required?: boolean;
|
||||
default?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface VectorDBProviderMeta {
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
config_schema: VectorDBProviderField[];
|
||||
}
|
||||
|
||||
export interface VectorDBCurrentConfig {
|
||||
type: string;
|
||||
config: Record<string, string>;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVectorDBConfigResponse {
|
||||
config: VectorDBCurrentConfig;
|
||||
stats: VectorDBStats;
|
||||
}
|
||||
|
||||
export const vectorDBApi = {
|
||||
getProviders: () => client<VectorDBProviderMeta[]>('/vector-db/providers', { method: 'GET' }),
|
||||
getConfig: () => client<VectorDBCurrentConfig>('/vector-db/config', { method: 'GET' }),
|
||||
getStats: () => client<VectorDBStats>('/vector-db/stats', { method: 'GET' }),
|
||||
updateConfig: (payload: { type: string; config: Record<string, string> }) =>
|
||||
client<UpdateVectorDBConfigResponse>('/vector-db/config', { method: 'POST', json: payload }),
|
||||
clearAll: () => client('/vector-db/clear-all', { method: 'POST' }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface VfsEntry {
|
||||
size: number;
|
||||
mtime: number;
|
||||
type?: string;
|
||||
is_image?: boolean;
|
||||
has_thumbnail?: boolean;
|
||||
}
|
||||
|
||||
export interface DirListing {
|
||||
@@ -21,9 +21,29 @@ export interface DirListing {
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
id: number;
|
||||
id: string;
|
||||
path: string;
|
||||
score: number;
|
||||
chunk_id?: string;
|
||||
snippet?: string;
|
||||
mime?: string;
|
||||
source_type?: string;
|
||||
start_offset?: number;
|
||||
end_offset?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SearchPagination {
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: SearchResultItem[];
|
||||
query: string;
|
||||
mode?: string;
|
||||
pagination?: SearchPagination;
|
||||
}
|
||||
|
||||
export const vfsApi = {
|
||||
@@ -50,7 +70,18 @@ export const vfsApi = {
|
||||
},
|
||||
mkdir: (path: string) => request('/fs/mkdir', { method: 'POST', json: { path } }),
|
||||
deletePath: (path: string) => request(`/fs/${encodeURI(path.replace(/^\/+/, ''))}`, { method: 'DELETE' }),
|
||||
move: (src: string, dst: string) => request('/fs/move', { method: 'POST', json: { src, dst } }),
|
||||
move: (src: string, dst: string, options?: { overwrite?: boolean }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
|
||||
const query = params.toString();
|
||||
return request(`/fs/move${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
|
||||
},
|
||||
copy: (src: string, dst: string, options?: { overwrite?: boolean }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
|
||||
const query = params.toString();
|
||||
return request(`/fs/copy${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
|
||||
},
|
||||
rename: (src: string, dst: string) => request('/fs/rename', { method: 'POST', json: { src, dst } }),
|
||||
thumb: (path: string, w=256, h=256, fit='cover') =>
|
||||
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
||||
@@ -94,6 +125,20 @@ export const vfsApi = {
|
||||
xhr.send(fd);
|
||||
});
|
||||
},
|
||||
searchFiles: (q: string, top_k: number = 10, mode: 'vector' | 'filename' = 'vector') =>
|
||||
request<{ items: SearchResultItem[]; query: string }>(`/search?q=${encodeURIComponent(q)}&top_k=${top_k}&mode=${mode}`),
|
||||
searchFiles: (
|
||||
q: string,
|
||||
top_k: number = 10,
|
||||
mode: 'vector' | 'filename' = 'vector',
|
||||
page?: number,
|
||||
page_size?: number,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
top_k: String(top_k),
|
||||
mode,
|
||||
});
|
||||
if (page !== undefined) params.set('page', String(page));
|
||||
if (page_size !== undefined) params.set('page_size', String(page_size));
|
||||
return request<SearchResponse>(`/search?${params.toString()}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { Space, Button } from 'antd';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import type { AppDescriptor, AppComponentProps } from './types';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface AppWindowItem {
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
maximized: boolean;
|
||||
minimized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -187,9 +188,11 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
));
|
||||
};
|
||||
|
||||
const visibleWindows = windows.filter(w => !w.minimized);
|
||||
|
||||
return (
|
||||
<>
|
||||
{windows.map((w, idx) => {
|
||||
{visibleWindows.map((w, idx) => {
|
||||
const AppComp = w.app.component as React.FC<AppComponentProps>;
|
||||
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
||||
if (!useSystemWindow) {
|
||||
@@ -291,6 +294,21 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
{w.app.name} - {w.entry.name}
|
||||
</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"
|
||||
|
||||
@@ -1,394 +1,654 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileOutlined,
|
||||
DatabaseOutlined,
|
||||
ExpandOutlined,
|
||||
BgColorsOutlined,
|
||||
ClockCircleOutlined,
|
||||
FolderOutlined,
|
||||
AimOutlined,
|
||||
BulbOutlined,
|
||||
ThunderboltOutlined,
|
||||
AlertOutlined,
|
||||
CameraOutlined,
|
||||
ApiOutlined,
|
||||
FieldTimeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { API_BASE_URL, vfsApi, type VfsEntry } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Typography, Button, Tooltip } from 'antd';
|
||||
import { ZoomInOutlined, ZoomOutOutlined, ReloadOutlined, CompressOutlined, CloseOutlined, RotateRightOutlined } from '@ant-design/icons';
|
||||
import { ImageCanvas } from './components/ImageCanvas';
|
||||
import { ViewerControls } from './components/ViewerControls';
|
||||
import { Filmstrip } from './components/Filmstrip';
|
||||
import { InfoPanel } from './components/InfoPanel';
|
||||
import type { HistogramData, RgbColor, InfoItem } from './components/types';
|
||||
import { viewerStyles } from './styles';
|
||||
|
||||
interface ExplorerSnapshot {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: { page: number; page_size: number; total: number };
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FileStat {
|
||||
name?: string;
|
||||
is_dir?: boolean;
|
||||
size?: number;
|
||||
mtime?: number;
|
||||
mode?: number;
|
||||
path?: string;
|
||||
type?: string;
|
||||
exif?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'foxel:file-explorer-page': CustomEvent<ExplorerSnapshot>;
|
||||
}
|
||||
}
|
||||
type ExplorerAwareWindow = Window & { __FOXEL_LAST_EXPLORER_PAGE__?: ExplorerSnapshot };
|
||||
|
||||
const DEFAULT_TONE: RgbColor = { r: 28, g: 32, b: 46 };
|
||||
|
||||
const isImageEntry = (ent: VfsEntry) => {
|
||||
if (ent.is_dir) return false;
|
||||
const maybe = ent as VfsEntry & { has_thumbnail?: boolean };
|
||||
if (typeof maybe.has_thumbnail === 'boolean' && maybe.has_thumbnail) return true;
|
||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return false;
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
|
||||
};
|
||||
|
||||
const buildThumbUrl = (fullPath: string, w = 180, h = 120) => {
|
||||
const base = API_BASE_URL.replace(/\/+$/, '');
|
||||
const clean = fullPath.replace(/^\/+/, '');
|
||||
return `${base}/fs/thumb/${encodeURI(clean)}?w=${w}&h=${h}&fit=cover`;
|
||||
};
|
||||
|
||||
const getDirectory = (fullPath: string) => {
|
||||
const path = fullPath.startsWith('/') ? fullPath : `/${fullPath}`;
|
||||
const idx = path.lastIndexOf('/');
|
||||
if (idx <= 0) return '/';
|
||||
return path.slice(0, idx) || '/';
|
||||
};
|
||||
|
||||
const joinPath = (dir: string, name: string) => {
|
||||
if (dir === '/' || dir === '') return `/${name}`;
|
||||
return `${dir.replace(/\/$/, '')}/${name}`;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const parseNumberish = (raw: unknown): number | null => {
|
||||
if (typeof raw === 'number') return raw;
|
||||
if (typeof raw !== 'string') return null;
|
||||
if (raw.includes('/')) {
|
||||
const [a, b] = raw.split('/').map(v => Number(v));
|
||||
if (!Number.isNaN(a) && !Number.isNaN(b) && b !== 0) return a / b;
|
||||
}
|
||||
const val = Number(raw);
|
||||
return Number.isNaN(val) ? null : val;
|
||||
};
|
||||
|
||||
const humanFileSize = (size: number | undefined) => {
|
||||
if (typeof size !== 'number') return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = size;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const readExplorerSnapshot = (dir: string): ExplorerSnapshot | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const snap = (window as ExplorerAwareWindow).__FOXEL_LAST_EXPLORER_PAGE__;
|
||||
if (!snap) return null;
|
||||
const snapshotPath = snap.path === '' ? '/' : snap.path;
|
||||
const normalizedSnap = snapshotPath.endsWith('/') && snapshotPath !== '/' ? snapshotPath.slice(0, -1) : snapshotPath;
|
||||
const normalizedTarget = dir.endsWith('/') && dir !== '/' ? dir.slice(0, -1) : dir;
|
||||
if (normalizedSnap !== normalizedTarget) return null;
|
||||
return snap;
|
||||
};
|
||||
|
||||
const formatDateTime = (ts?: number) => {
|
||||
if (!ts) return '-';
|
||||
try {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const clampChannel = (value: number) => Math.max(0, Math.min(255, value));
|
||||
|
||||
const mixColor = (base: RgbColor, target: RgbColor, ratio: number): RgbColor => ({
|
||||
r: clampChannel(base.r * (1 - ratio) + target.r * ratio),
|
||||
g: clampChannel(base.g * (1 - ratio) + target.g * ratio),
|
||||
b: clampChannel(base.b * (1 - ratio) + target.b * ratio),
|
||||
});
|
||||
|
||||
const rgbToRgba = (color: RgbColor, alpha: number) => `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${alpha})`;
|
||||
|
||||
const computeImageStats = (img: HTMLImageElement): { histogram: HistogramData | null; dominantColor: RgbColor | null } => {
|
||||
try {
|
||||
const maxSide = 720;
|
||||
const naturalWidth = img.naturalWidth || 1;
|
||||
const naturalHeight = img.naturalHeight || 1;
|
||||
const ratio = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight));
|
||||
const width = Math.max(1, Math.floor(naturalWidth * ratio));
|
||||
const height = Math.max(1, Math.floor(naturalHeight * ratio));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return { histogram: null, dominantColor: null };
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const { data } = ctx.getImageData(0, 0, width, height);
|
||||
const r = new Array(256).fill(0);
|
||||
const g = new Array(256).fill(0);
|
||||
const b = new Array(256).fill(0);
|
||||
let rTotal = 0;
|
||||
let gTotal = 0;
|
||||
let bTotal = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r[data[i]] += 1;
|
||||
g[data[i + 1]] += 1;
|
||||
b[data[i + 2]] += 1;
|
||||
rTotal += data[i];
|
||||
gTotal += data[i + 1];
|
||||
bTotal += data[i + 2];
|
||||
count += 1;
|
||||
}
|
||||
const histogram: HistogramData = { r, g, b };
|
||||
if (count === 0) return { histogram, dominantColor: null };
|
||||
const dominantColor: RgbColor = {
|
||||
r: rTotal / count,
|
||||
g: gTotal / count,
|
||||
b: bTotal / count,
|
||||
};
|
||||
return { histogram, dominantColor };
|
||||
} catch {
|
||||
return { histogram: null, dominantColor: null };
|
||||
}
|
||||
};
|
||||
|
||||
export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
|
||||
const [url, setUrl] = useState<string>();
|
||||
const normalizedInitialPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
const [activeEntry, setActiveEntry] = useState<VfsEntry>(entry);
|
||||
const [activePath, setActivePath] = useState<string>(normalizedInitialPath);
|
||||
const [imageUrl, setImageUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [stat, setStat] = useState<FileStat | null>(null);
|
||||
const [histogram, setHistogram] = useState<HistogramData | null>(null);
|
||||
const [dominantColor, setDominantColor] = useState<RgbColor | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [rotate, setRotate] = useState(0);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [filmstrip, setFilmstrip] = useState<VfsEntry[]>([]);
|
||||
const [pageInfo, setPageInfo] = useState<{ page: number; total: number; pageSize: number } | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPointer = useRef<{ x: number; y: number } | null>(null);
|
||||
const lastDistance = useRef<number | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const dragPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pinchDistanceRef = useRef<number | null>(null);
|
||||
const transitionRef = useRef(false);
|
||||
const filmstripRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const directory = useMemo(() => getDirectory(activePath), [activePath]);
|
||||
|
||||
const baseTone = useMemo<RgbColor>(() => dominantColor ?? DEFAULT_TONE, [dominantColor]);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
const light = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.18);
|
||||
const shadow = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.62);
|
||||
return {
|
||||
...viewerStyles.container,
|
||||
background: `linear-gradient(135deg, ${rgbToRgba(light, 0.78)} 0%, ${rgbToRgba(baseTone, 0.86)} 48%, ${rgbToRgba(shadow, 0.96)} 100%)`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const mainBackdropStyle = useMemo(() => {
|
||||
const glow = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.32);
|
||||
const shade = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.7);
|
||||
return {
|
||||
...viewerStyles.mainBackdrop,
|
||||
background: `radial-gradient(circle at 18% 22%, ${rgbToRgba(glow, 0.38)}, ${rgbToRgba(shade, 0.94)} 68%)`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const viewerStyle = useMemo(() => {
|
||||
const surface = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.45);
|
||||
const edge = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.65);
|
||||
return {
|
||||
...viewerStyles.viewer,
|
||||
background: `linear-gradient(145deg, ${rgbToRgba(surface, 0.7)} 0%, ${rgbToRgba(edge, 0.92)} 100%)`,
|
||||
backdropFilter: 'blur(28px)',
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const controlsStyle = useMemo(() => {
|
||||
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.52);
|
||||
return {
|
||||
...viewerStyles.controls,
|
||||
background: rgbToRgba(tone, 0.74),
|
||||
backdropFilter: 'blur(18px)',
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const filmstripShellStyle = useMemo(() => {
|
||||
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.56);
|
||||
return {
|
||||
...viewerStyles.filmstripShell,
|
||||
background: rgbToRgba(tone, 0.7),
|
||||
backdropFilter: 'blur(22px)',
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const getThumbUrl = useCallback((item: VfsEntry) => {
|
||||
const full = joinPath(directory, item.name);
|
||||
return buildThumbUrl(full, 160, 120);
|
||||
}, [directory]);
|
||||
|
||||
const sidePanelStyle = useMemo(() => {
|
||||
const panel = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.6);
|
||||
const border = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.1), 0.28);
|
||||
return {
|
||||
...viewerStyles.sidePanel,
|
||||
background: rgbToRgba(panel, 0.8),
|
||||
backdropFilter: 'blur(28px)',
|
||||
borderLeft: `1px solid ${border}`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const histogramCardStyle = useMemo(() => {
|
||||
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.55);
|
||||
const stroke = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.12), 0.2);
|
||||
return {
|
||||
...viewerStyles.histogramCard,
|
||||
background: rgbToRgba(tone, 0.58),
|
||||
border: `1px solid ${stroke}`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
setActiveEntry(entry);
|
||||
setActivePath(normalized);
|
||||
}, [entry, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true); setErr(undefined);
|
||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||
.then(res => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setHistogram(null);
|
||||
setDominantColor(null);
|
||||
const cleaned = activePath.replace(/^\/+/, '');
|
||||
Promise.all([
|
||||
vfsApi.getTempLinkToken(cleaned),
|
||||
vfsApi.stat(activePath) as Promise<FileStat>,
|
||||
])
|
||||
.then(([token, metadata]) => {
|
||||
if (cancelled) return;
|
||||
const publicUrl = vfsApi.getTempPublicUrl(res.token);
|
||||
setUrl(publicUrl);
|
||||
setImageUrl(vfsApi.getTempPublicUrl(token.token));
|
||||
setStat(metadata);
|
||||
setScale(1);
|
||||
setRotate(0);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
})
|
||||
.catch(e => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => { cancelled = true; };
|
||||
}, [filePath]);
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activePath]);
|
||||
|
||||
const refreshFilmstrip = useCallback((dir: string) => {
|
||||
const snap = readExplorerSnapshot(dir);
|
||||
if (snap) {
|
||||
const images = snap.entries.filter(isImageEntry);
|
||||
const ensured = images.some(item => item.name === activeEntry.name) ? images : [...images, activeEntry];
|
||||
setFilmstrip(ensured);
|
||||
if (snap.pagination) {
|
||||
setPageInfo({
|
||||
page: snap.pagination.page,
|
||||
pageSize: snap.pagination.page_size,
|
||||
total: snap.pagination.total,
|
||||
});
|
||||
} else {
|
||||
setPageInfo(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFilmstrip([activeEntry]);
|
||||
setPageInfo(null);
|
||||
}, [activeEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshFilmstrip(directory);
|
||||
}, [directory, refreshFilmstrip]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => refreshFilmstrip(directory);
|
||||
window.addEventListener('foxel:file-explorer-page', handler);
|
||||
return () => window.removeEventListener('foxel:file-explorer-page', handler);
|
||||
}, [directory, refreshFilmstrip]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = filmstripRefs.current[activeEntry.name];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
}, [activeEntry, filmstrip]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
switchRelative(1);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
switchRelative(-1);
|
||||
} else if ((e.key === '+' || e.key === '=') && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
zoom(1.15);
|
||||
} else if ((e.key === '-' || e.key === '_') && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
zoom(0.85);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', keyHandler);
|
||||
return () => window.removeEventListener('keydown', keyHandler);
|
||||
});
|
||||
|
||||
const zoom = useCallback((factor: number) => {
|
||||
setScale(prev => {
|
||||
const next = clamp(prev * factor, 0.08, 10);
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 120);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rotateImage = () => {
|
||||
setRotate(prev => {
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 180);
|
||||
return (prev + 90) % 360;
|
||||
});
|
||||
};
|
||||
|
||||
const resetView = () => {
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 160);
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
}, [url]);
|
||||
};
|
||||
|
||||
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
|
||||
const applyOffset = (next: { x: number; y: number }) => {
|
||||
setOffset(next);
|
||||
const fitToScreen = () => {
|
||||
resetView();
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
setScale(prev => {
|
||||
const factor = e.deltaY < 0 ? 1.12 : 0.88;
|
||||
const next = clamp(prev * factor, 0.08, 10);
|
||||
const ratio = next / prev;
|
||||
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 120);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
transitionRef.current = false;
|
||||
dragPointRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !lastPointer.current) return;
|
||||
if (!isDragging || !dragPointRef.current) return;
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - lastPointer.current.x;
|
||||
const dy = e.clientY - lastPointer.current.y;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
applyOffset({ x: offset.x + dx, y: offset.y + dy });
|
||||
const dx = e.clientX - dragPointRef.current.x;
|
||||
const dy = e.clientY - dragPointRef.current.y;
|
||||
dragPointRef.current = { x: e.clientX, y: e.clientY };
|
||||
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDragging(false);
|
||||
lastPointer.current = null;
|
||||
dragPointRef.current = null;
|
||||
};
|
||||
|
||||
const dist = (t1: React.Touch, t2: React.Touch) => Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
dragPointRef.current = { x: t.clientX, y: t.clientY };
|
||||
} else if (e.touches.length === 2) {
|
||||
pinchDistanceRef.current = dist(e.touches[0], e.touches[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1 && dragPointRef.current) {
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - dragPointRef.current.x;
|
||||
const dy = t.clientY - dragPointRef.current.y;
|
||||
dragPointRef.current = { x: t.clientX, y: t.clientY };
|
||||
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
|
||||
} else if (e.touches.length === 2 && pinchDistanceRef.current) {
|
||||
const dNow = dist(e.touches[0], e.touches[1]);
|
||||
const ratio = dNow / pinchDistanceRef.current;
|
||||
pinchDistanceRef.current = dNow;
|
||||
setScale(prev => clamp(prev * ratio, 0.08, 10));
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
pinchDistanceRef.current = null;
|
||||
dragPointRef.current = null;
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const cont = containerRef.current;
|
||||
const img = imgRef.current;
|
||||
if (!cont || !img) return;
|
||||
const rect = cont.getBoundingClientRect();
|
||||
const next = scale > 1.4 ? 1 : 2.2;
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
setScale(next);
|
||||
return;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const nextScale = scale > 1.5 ? 1 : 2.5;
|
||||
const ratio = nextScale / scale;
|
||||
const nextOffset = { x: offset.x - cx * (ratio - 1), y: offset.y - cy * (ratio - 1) };
|
||||
setScale(nextScale);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 200);
|
||||
applyOffset(nextOffset);
|
||||
const ratio = next / scale;
|
||||
setScale(next);
|
||||
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY;
|
||||
const zoomFactor = delta > 0 ? 1.12 : 0.88;
|
||||
const cont = containerRef.current;
|
||||
if (!cont) return;
|
||||
const rect = cont.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const nextScale = clamp(scale * zoomFactor, 0.5, 5);
|
||||
const ratio = nextScale / scale;
|
||||
const nextOffset = { x: offset.x - cx * (ratio - 1), y: offset.y - cy * (ratio - 1) };
|
||||
setScale(nextScale);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 120);
|
||||
applyOffset(nextOffset);
|
||||
const handleImageLoaded = () => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
const stats = computeImageStats(img);
|
||||
setHistogram(stats.histogram);
|
||||
setDominantColor(stats.dominantColor);
|
||||
};
|
||||
|
||||
const getTouchDistance = (t1: { clientX: number; clientY: number }, t2: { clientX: number; clientY: number }) =>
|
||||
Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
lastPointer.current = { x: t.clientX, y: t.clientY };
|
||||
} else if (e.touches.length === 2) {
|
||||
lastDistance.current = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
}
|
||||
transitionRef.current = false;
|
||||
};
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1 && lastPointer.current) {
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - lastPointer.current.x;
|
||||
const dy = t.clientY - lastPointer.current.y;
|
||||
lastPointer.current = { x: t.clientX, y: t.clientY };
|
||||
applyOffset({ x: offset.x + dx, y: offset.y + dy });
|
||||
} else if (e.touches.length === 2 && lastDistance.current) {
|
||||
const d = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
const ratio = d / lastDistance.current;
|
||||
const nextScale = clamp(scale * ratio, 0.5, 5);
|
||||
setScale(nextScale);
|
||||
lastDistance.current = d;
|
||||
}
|
||||
};
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 0) {
|
||||
lastPointer.current = null;
|
||||
lastDistance.current = null;
|
||||
}
|
||||
};
|
||||
const doZoom = (factor: number) => {
|
||||
const nextScale = clamp(scale * factor, 0.5, 5);
|
||||
setScale(nextScale);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 120);
|
||||
applyOffset(offset);
|
||||
};
|
||||
const resetView = () => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 150);
|
||||
};
|
||||
const fitToContainer = () => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 150);
|
||||
};
|
||||
const doRotate = () => {
|
||||
setRotate(r => (r + 90) % 360);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 180);
|
||||
const switchEntry = (target: VfsEntry) => {
|
||||
const nextPath = joinPath(directory, target.name);
|
||||
setActiveEntry(target);
|
||||
setActivePath(nextPath);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
}}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (err) {
|
||||
return (
|
||||
<div style={{
|
||||
color: 'var(--ant-color-error, #f5222d)',
|
||||
padding: 16,
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
}}>
|
||||
加载失败: {err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!url) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
}}>
|
||||
无内容
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const switchRelative = (step: number) => {
|
||||
if (filmstrip.length <= 1) return;
|
||||
const currentIndex = filmstrip.findIndex(item => item.name === activeEntry.name);
|
||||
if (currentIndex === -1) return;
|
||||
const target = filmstrip[(currentIndex + step + filmstrip.length) % filmstrip.length];
|
||||
if (target) switchEntry(target);
|
||||
};
|
||||
|
||||
const scaleLabel = `${(scale * 100).toFixed(scale >= 1 ? 0 : 1)}%`;
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
|
||||
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
|
||||
cursor: isDragging ? 'grabbing' : scale > 1 ? 'grab' : 'zoom-in',
|
||||
willChange: 'transform',
|
||||
};
|
||||
|
||||
const controlsNode = (
|
||||
<ViewerControls
|
||||
style={controlsStyle}
|
||||
onPrev={() => switchRelative(-1)}
|
||||
onNext={() => switchRelative(1)}
|
||||
onZoomIn={() => zoom(1.18)}
|
||||
onZoomOut={() => zoom(0.82)}
|
||||
onRotate={rotateImage}
|
||||
onReset={resetView}
|
||||
onFit={fitToScreen}
|
||||
disableSwitch={filmstrip.length <= 1}
|
||||
/>
|
||||
);
|
||||
|
||||
const exif = (stat?.exif ?? {}) as Record<string, unknown>;
|
||||
const infoIconStyle: React.CSSProperties = { fontSize: 15, color: 'rgba(255,255,255,0.62)' };
|
||||
const exifValue = (key: string): string | number | null => {
|
||||
const value = exif[key];
|
||||
if (typeof value === 'string' || typeof value === 'number') return value;
|
||||
return null;
|
||||
};
|
||||
const focalLength = (() => {
|
||||
const v = parseNumberish(exifValue('37386') ?? exifValue('37377'));
|
||||
return v ? `${v.toFixed(1)} mm` : null;
|
||||
})();
|
||||
const aperture = (() => {
|
||||
const v = parseNumberish(exifValue('33437') ?? exifValue('37378'));
|
||||
return v ? `f/${v.toFixed(1)}` : null;
|
||||
})();
|
||||
const exposure = (() => {
|
||||
const v = parseNumberish(exifValue('33434'));
|
||||
if (!v) return null;
|
||||
if (v >= 1) return `${v.toFixed(1)} s`;
|
||||
const denom = Math.max(1, Math.round(1 / v));
|
||||
return `1/${denom}`;
|
||||
})();
|
||||
const isoValue = exifValue('34855') ?? exifValue('34864');
|
||||
const width = parseNumberish(exifValue('40962'));
|
||||
const height = parseNumberish(exifValue('40963'));
|
||||
const colorSpace = exifValue('40961');
|
||||
const cameraMake = exifValue('271');
|
||||
const cameraModel = exifValue('272');
|
||||
const lensModel = exifValue('42036');
|
||||
const captureTime = exifValue('36867') ?? exifValue('36868') ?? exifValue('306');
|
||||
|
||||
const basicList: InfoItem[] = [
|
||||
{ label: '文件名', value: activeEntry.name, icon: <FileOutlined style={infoIconStyle} /> },
|
||||
{ label: '文件大小', value: humanFileSize(stat?.size), icon: <DatabaseOutlined style={infoIconStyle} /> },
|
||||
{ label: '分辨率', value: width && height ? `${width} × ${height}` : null, icon: <ExpandOutlined style={infoIconStyle} /> },
|
||||
{ label: '颜色空间', value: colorSpace ?? null, icon: <BgColorsOutlined style={infoIconStyle} /> },
|
||||
{ label: '修改时间', value: stat?.mtime ? formatDateTime(stat.mtime) : null, icon: <ClockCircleOutlined style={infoIconStyle} /> },
|
||||
{ label: '路径', value: typeof stat?.path === 'string' ? stat.path : activePath, icon: <FolderOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
const shootingList: InfoItem[] = [
|
||||
{ label: '焦距', value: focalLength, icon: <AimOutlined style={infoIconStyle} /> },
|
||||
{ label: '光圈', value: aperture, icon: <BulbOutlined style={infoIconStyle} /> },
|
||||
{ label: '快门', value: exposure, icon: <ThunderboltOutlined style={infoIconStyle} /> },
|
||||
{ label: 'ISO', value: isoValue != null ? isoValue.toString() : null, icon: <AlertOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
const deviceList: InfoItem[] = [
|
||||
{
|
||||
label: '相机',
|
||||
value: cameraModel ? `${cameraMake ? `${cameraMake} ` : ''}${cameraModel}` : (cameraMake ?? null),
|
||||
icon: <CameraOutlined style={infoIconStyle} />,
|
||||
},
|
||||
{ label: '镜头', value: lensModel ?? null, icon: <ApiOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
const miscList: InfoItem[] = [
|
||||
{ label: '拍摄时间', value: captureTime, icon: <FieldTimeOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onWheel={onWheel}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onMouseDown={onMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
{/* 顶部栏:文件名和关闭按钮 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 32,
|
||||
left: 32,
|
||||
right: 32,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
color: '#fff',
|
||||
margin: 0,
|
||||
fontSize: 15,
|
||||
background: 'rgba(0,0,0,0.32)',
|
||||
padding: '7px 18px',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
maxWidth: '60vw',
|
||||
textAlign: 'left',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
ellipsis
|
||||
>
|
||||
{entry.name} <span style={{ opacity: 0.7, fontSize: 13 }}>({(entry.size / 1024).toFixed(1)} KB)</span>
|
||||
</Typography.Paragraph>
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
type="text"
|
||||
onClick={() => onRequestClose && onRequestClose()}
|
||||
icon={<CloseOutlined />}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
<div style={containerStyle}>
|
||||
<section style={viewerStyles.main}>
|
||||
<div style={mainBackdropStyle} />
|
||||
<div style={viewerStyles.mainContent}>
|
||||
<ImageCanvas
|
||||
containerRef={containerRef}
|
||||
imageRef={imageRef}
|
||||
viewerStyle={viewerStyle}
|
||||
controls={controlsNode}
|
||||
scaleLabel={scaleLabel}
|
||||
imageStyle={imageStyle}
|
||||
loading={loading}
|
||||
error={error}
|
||||
imageUrl={imageUrl}
|
||||
activeEntry={activeEntry}
|
||||
onRequestClose={onRequestClose}
|
||||
onImageLoad={handleImageLoaded}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={stopDragging}
|
||||
onMouseUp={stopDragging}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 图片居中显示 */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={url}
|
||||
alt={entry.name}
|
||||
draggable={false}
|
||||
onDragStart={e => e.preventDefault()}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
|
||||
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
|
||||
maxWidth: '80vw',
|
||||
maxHeight: '80vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 8px 40px 0 rgba(0,0,0,0.45)',
|
||||
cursor: isDragging ? 'grabbing' : (scale > 1 ? 'grab' : 'zoom-in'),
|
||||
willChange: 'transform'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 操作按钮组 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: 18,
|
||||
zIndex: 80
|
||||
}}>
|
||||
<Tooltip title="缩小">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ZoomOutOutlined style={{ fontSize: 22 }} />}
|
||||
onClick={() => doZoom(0.8)}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="放大">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ZoomInOutlined style={{ fontSize: 22 }} />}
|
||||
onClick={() => doZoom(1.25)}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="旋转">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<RotateRightOutlined style={{ fontSize: 20 }} />}
|
||||
onClick={doRotate}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重置">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ReloadOutlined style={{ fontSize: 20 }} />}
|
||||
onClick={resetView}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="适应窗口">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<CompressOutlined style={{ fontSize: 20 }} />}
|
||||
onClick={fitToContainer}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Filmstrip
|
||||
shellStyle={filmstripShellStyle}
|
||||
listStyle={viewerStyles.filmstrip}
|
||||
entries={filmstrip}
|
||||
activeEntry={activeEntry}
|
||||
onSelect={switchEntry}
|
||||
filmstripRefs={filmstripRefs}
|
||||
pageInfo={pageInfo}
|
||||
getThumbUrl={getThumbUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<InfoPanel
|
||||
style={sidePanelStyle}
|
||||
histogramCardStyle={histogramCardStyle}
|
||||
title={activeEntry.name}
|
||||
captureTime={captureTime ?? null}
|
||||
basicList={basicList}
|
||||
shootingList={shootingList}
|
||||
deviceList={deviceList}
|
||||
miscList={miscList}
|
||||
histogram={histogram}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
94
web/src/apps/ImageViewer/components/Filmstrip.tsx
Normal file
94
web/src/apps/ImageViewer/components/Filmstrip.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface PageInfo {
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface FilmstripProps {
|
||||
shellStyle: React.CSSProperties;
|
||||
listStyle: React.CSSProperties;
|
||||
entries: VfsEntry[];
|
||||
activeEntry: VfsEntry;
|
||||
onSelect: (entry: VfsEntry) => void;
|
||||
filmstripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
|
||||
pageInfo: PageInfo | null;
|
||||
getThumbUrl: (entry: VfsEntry) => string;
|
||||
}
|
||||
|
||||
export const Filmstrip: React.FC<FilmstripProps> = ({
|
||||
shellStyle,
|
||||
listStyle,
|
||||
entries,
|
||||
activeEntry,
|
||||
onSelect,
|
||||
filmstripRefs,
|
||||
pageInfo,
|
||||
getThumbUrl,
|
||||
}) => (
|
||||
<div style={shellStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
|
||||
胶片带 · {entries.length} 张
|
||||
</Typography.Text>
|
||||
{pageInfo && (
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12 }}>
|
||||
第 {pageInfo.page} 页 / 共 {Math.max(1, Math.ceil(pageInfo.total / pageInfo.pageSize))} 页
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={listStyle}>
|
||||
{entries.map(item => {
|
||||
const active = item.name === activeEntry.name;
|
||||
return (
|
||||
<div
|
||||
key={`${item.name}-${item.mtime ?? ''}`}
|
||||
ref={el => { filmstripRefs.current[item.name] = el; }}
|
||||
onClick={() => onSelect(item)}
|
||||
style={{
|
||||
width: 84,
|
||||
height: 64,
|
||||
overflow: 'hidden',
|
||||
border: active ? '2px solid #4e9bff' : '2px solid transparent',
|
||||
boxShadow: active ? '0 0 0 4px rgba(78,155,255,0.28)' : '0 10px 28px rgba(0,0,0,0.45)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getThumbUrl(item)}
|
||||
alt={item.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: active ? 'saturate(1)' : 'saturate(0.65)' }}
|
||||
/>
|
||||
{active && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 6,
|
||||
right: 6,
|
||||
padding: '2px 4px',
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && (
|
||||
<div style={{ color: 'rgba(255,255,255,0.45)' }}>暂无图片</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
99
web/src/apps/ImageViewer/components/ImageCanvas.tsx
Normal file
99
web/src/apps/ImageViewer/components/ImageCanvas.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Spin, Typography, Tooltip, Button } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { viewerStyles } from '../styles';
|
||||
|
||||
interface ImageCanvasProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
imageRef: React.RefObject<HTMLImageElement | null>;
|
||||
viewerStyle: React.CSSProperties;
|
||||
controls: React.ReactNode;
|
||||
scaleLabel: string;
|
||||
imageStyle: React.CSSProperties;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
imageUrl?: string;
|
||||
activeEntry: VfsEntry;
|
||||
onRequestClose: () => void;
|
||||
onImageLoad: () => void;
|
||||
onWheel: React.WheelEventHandler<HTMLDivElement>;
|
||||
onMouseDown: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseUp: React.MouseEventHandler<HTMLDivElement>;
|
||||
onDoubleClick: React.MouseEventHandler<HTMLDivElement>;
|
||||
onTouchStart: React.TouchEventHandler<HTMLDivElement>;
|
||||
onTouchMove: React.TouchEventHandler<HTMLDivElement>;
|
||||
onTouchEnd: React.TouchEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ImageCanvas: React.FC<ImageCanvasProps> = ({
|
||||
containerRef,
|
||||
imageRef,
|
||||
viewerStyle,
|
||||
controls,
|
||||
scaleLabel,
|
||||
imageStyle,
|
||||
loading,
|
||||
error,
|
||||
imageUrl,
|
||||
activeEntry,
|
||||
onRequestClose,
|
||||
onImageLoad,
|
||||
onWheel,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onMouseUp,
|
||||
onDoubleClick,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
}) => (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={viewerStyle}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div style={viewerStyles.viewerCloseWrap}>
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onRequestClose}
|
||||
style={viewerStyles.viewerClose}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Spin tip="加载中" />
|
||||
) : error ? (
|
||||
<Typography.Text type="danger">{error}</Typography.Text>
|
||||
) : imageUrl ? (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageUrl}
|
||||
alt={activeEntry.name}
|
||||
onLoad={onImageLoad}
|
||||
draggable={false}
|
||||
crossOrigin="anonymous"
|
||||
style={imageStyle}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text>无可用内容</Typography.Text>
|
||||
)}
|
||||
|
||||
<div style={viewerStyles.scaleBadge}>{scaleLabel}</div>
|
||||
|
||||
{controls}
|
||||
</div>
|
||||
);
|
||||
116
web/src/apps/ImageViewer/components/InfoPanel.tsx
Normal file
116
web/src/apps/ImageViewer/components/InfoPanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Typography, Empty } from 'antd';
|
||||
import type { HistogramData, InfoItem } from './types';
|
||||
|
||||
interface InfoPanelProps {
|
||||
style: React.CSSProperties;
|
||||
histogramCardStyle: React.CSSProperties;
|
||||
title: string;
|
||||
captureTime: string | number | null;
|
||||
basicList: InfoItem[];
|
||||
shootingList: InfoItem[];
|
||||
deviceList: InfoItem[];
|
||||
miscList: InfoItem[];
|
||||
histogram: HistogramData | null;
|
||||
}
|
||||
|
||||
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<Typography.Title level={5} style={{ color: '#fff', fontSize: 15, marginTop: 24, marginBottom: 12 }}>
|
||||
{children}
|
||||
</Typography.Title>
|
||||
);
|
||||
|
||||
const HistogramPlot: React.FC<{ data: HistogramData | null }> = ({ data }) => {
|
||||
if (!data) {
|
||||
return <Empty description="无法解析直方图" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
}
|
||||
const width = 260;
|
||||
const height = 140;
|
||||
const max = Math.max(...data.r, ...data.g, ...data.b, 1);
|
||||
const toPath = (arr: number[]) => arr
|
||||
.map((value, index) => {
|
||||
const x = (index / 255) * width;
|
||||
const y = height - (value / max) * height;
|
||||
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(' ');
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ width: '100%' }}>
|
||||
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.04)" />
|
||||
<path d={toPath(data.r)} stroke="rgba(255,99,132,0.88)" fill="none" strokeWidth={1.3} />
|
||||
<path d={toPath(data.g)} stroke="rgba(75,192,192,0.88)" fill="none" strokeWidth={1.3} />
|
||||
<path d={toPath(data.b)} stroke="rgba(54,162,235,0.88)" fill="none" strokeWidth={1.3} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoRows: React.FC<{ items: InfoItem[] }> = ({ items }) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', rowGap: 10, columnGap: 12 }}>
|
||||
{items
|
||||
.filter(item => item.value !== null && item.value !== undefined && item.value !== '')
|
||||
.map(item => (
|
||||
<React.Fragment key={item.label}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.55)' }}>
|
||||
{item.icon && <span style={{ display: 'inline-flex', alignItems: 'center' }}>{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
<span style={{ color: '#fff', wordBreak: 'break-all' }}>{item.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const InfoPanel: React.FC<InfoPanelProps> = ({
|
||||
style,
|
||||
histogramCardStyle,
|
||||
title,
|
||||
captureTime,
|
||||
basicList,
|
||||
shootingList,
|
||||
deviceList,
|
||||
miscList,
|
||||
histogram,
|
||||
}) => (
|
||||
<aside style={style}>
|
||||
<Typography.Title level={3} style={{ color: '#fff', marginTop: 6, wordBreak: 'break-all' }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{captureTime && (
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.6)' }}>拍摄时间 {captureTime}</Typography.Text>
|
||||
)}
|
||||
|
||||
<SectionTitle>基本信息</SectionTitle>
|
||||
<InfoRows items={basicList} />
|
||||
|
||||
{shootingList.some(i => i.value) && (
|
||||
<>
|
||||
<SectionTitle>拍摄参数</SectionTitle>
|
||||
<InfoRows items={shootingList} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{deviceList.some(i => i.value) && (
|
||||
<>
|
||||
<SectionTitle>设备信息</SectionTitle>
|
||||
<InfoRows items={deviceList} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{miscList.some(i => i.value) && (
|
||||
<>
|
||||
<SectionTitle>其他</SectionTitle>
|
||||
<InfoRows items={miscList} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionTitle>直方图</SectionTitle>
|
||||
<div style={histogramCardStyle}>
|
||||
<HistogramPlot data={histogram} />
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 12, fontSize: 12 }}>
|
||||
<span style={{ color: 'rgba(255,99,132,0.88)' }}>R</span>
|
||||
<span style={{ color: 'rgba(75,192,192,0.88)' }}>G</span>
|
||||
<span style={{ color: 'rgba(54,162,235,0.88)' }}>B</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
73
web/src/apps/ImageViewer/components/ViewerControls.tsx
Normal file
73
web/src/apps/ImageViewer/components/ViewerControls.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
RotateRightOutlined,
|
||||
ReloadOutlined,
|
||||
CompressOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ViewerControlsProps {
|
||||
style: React.CSSProperties;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onRotate: () => void;
|
||||
onReset: () => void;
|
||||
onFit: () => void;
|
||||
disableSwitch: boolean;
|
||||
}
|
||||
|
||||
export const ViewerControls: React.FC<ViewerControlsProps> = ({
|
||||
style,
|
||||
onPrev,
|
||||
onNext,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onRotate,
|
||||
onReset,
|
||||
onFit,
|
||||
disableSwitch,
|
||||
}) => (
|
||||
<div style={style}>
|
||||
<Tooltip title="上一张">
|
||||
<Button
|
||||
shape="circle"
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={onPrev}
|
||||
disabled={disableSwitch}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小">
|
||||
<Button shape="circle" type="text" icon={<ZoomOutOutlined />} onClick={onZoomOut} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="放大">
|
||||
<Button shape="circle" type="text" icon={<ZoomInOutlined />} onClick={onZoomIn} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="旋转 90°">
|
||||
<Button shape="circle" type="text" icon={<RotateRightOutlined />} onClick={onRotate} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重置">
|
||||
<Button shape="circle" type="text" icon={<ReloadOutlined />} onClick={onReset} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="适应窗口">
|
||||
<Button shape="circle" type="text" icon={<CompressOutlined />} onClick={onFit} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="下一张">
|
||||
<Button
|
||||
shape="circle"
|
||||
type="text"
|
||||
icon={<RightOutlined />}
|
||||
onClick={onNext}
|
||||
disabled={disableSwitch}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
19
web/src/apps/ImageViewer/components/types.ts
Normal file
19
web/src/apps/ImageViewer/components/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface HistogramData {
|
||||
r: number[];
|
||||
g: number[];
|
||||
b: number[];
|
||||
}
|
||||
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface InfoItem {
|
||||
label: string;
|
||||
value: string | number | null;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ImageViewerApp } from './ImageViewer.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'image-viewer',
|
||||
name: '图片查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:image.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -14,4 +15,4 @@ export const descriptor: AppDescriptor = {
|
||||
defaultMaximized:true,
|
||||
useSystemWindow:false,
|
||||
defaultBounds: { width: 820, height: 620, x: 140, y: 96 }
|
||||
};
|
||||
};
|
||||
|
||||
106
web/src/apps/ImageViewer/styles.ts
Normal file
106
web/src/apps/ImageViewer/styles.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export const viewerStyles = {
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 320px',
|
||||
columnGap: 0,
|
||||
color: '#fff',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
main: {
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
|
||||
minHeight: 0,
|
||||
},
|
||||
mainBackdrop: {
|
||||
position: 'absolute' as const,
|
||||
inset: 0,
|
||||
},
|
||||
mainContent: {
|
||||
position: 'relative' as const,
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
viewer: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||
touchAction: 'none' as const,
|
||||
minHeight: 0,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute' as const,
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
padding: '8px 18px',
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
scaleBadge: {
|
||||
position: 'absolute' as const,
|
||||
bottom: 64,
|
||||
left: 16,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
filmstripShell: {
|
||||
marginTop: 0,
|
||||
padding: '3px 12px',
|
||||
boxShadow: '0 16px 42px rgba(0,0,0,0.52)',
|
||||
},
|
||||
filmstrip: {
|
||||
display: 'flex',
|
||||
overflowX: 'auto' as const,
|
||||
gap: 12,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
sidePanel: {
|
||||
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
overflowY: 'auto' as const,
|
||||
minHeight: 0,
|
||||
},
|
||||
histogramCard: {
|
||||
padding: '12px 12px 18px',
|
||||
background: 'rgba(0,0,0,0.34)',
|
||||
borderRadius: 0,
|
||||
},
|
||||
viewerCloseWrap: {
|
||||
position: 'absolute' as const,
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 2,
|
||||
},
|
||||
viewerClose: {
|
||||
color: '#fff',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: '0 8px 18px rgba(0,0,0,0.45)',
|
||||
borderRadius: '100%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { OfficeViewerApp } from './OfficeViewer.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'office-viewer',
|
||||
name: 'Office 文档查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -12,4 +13,4 @@ export const descriptor: AppDescriptor = {
|
||||
component: OfficeViewerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 150, y: 100 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PdfViewerApp } from './PdfViewer';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'pdf-viewer',
|
||||
name: 'PDF 查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -13,4 +14,3 @@ export const descriptor: AppDescriptor = {
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import { Layout, Spin, Button, Space, message } from 'antd';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import request from '../../api/client';
|
||||
|
||||
const MonacoEditor = React.lazy(() => import('@monaco-editor/react'));
|
||||
const MarkdownEditor = React.lazy(() => import('@uiw/react-md-editor'));
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
|
||||
@@ -23,27 +24,124 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
const isMarkdown = ext === 'md' || ext === 'markdown';
|
||||
const monacoLanguage = useMemo(() => {
|
||||
switch (ext) {
|
||||
case 'json':
|
||||
return 'json';
|
||||
// Web technologies
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return 'javascript';
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'typescript';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'sh':
|
||||
return 'shell';
|
||||
case 'scss':
|
||||
case 'sass':
|
||||
return 'scss';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'vue':
|
||||
return 'html'; // Vue files are primarily HTML with some JS/TS
|
||||
|
||||
// Data formats
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'toml':
|
||||
return 'ini'; // TOML is similar to INI
|
||||
case 'ini':
|
||||
case 'cfg':
|
||||
case 'conf':
|
||||
return 'ini';
|
||||
|
||||
// Programming languages
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
case 'hxx':
|
||||
return 'cpp'; // Header files use C++ highlighting
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'kt':
|
||||
return 'kotlin';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'fs':
|
||||
return 'fsharp';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'pl':
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
|
||||
// Database
|
||||
case 'sql':
|
||||
return 'sql';
|
||||
|
||||
// Shell and scripts
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
case 'fish':
|
||||
return 'shell';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'bat':
|
||||
case 'cmd':
|
||||
return 'bat';
|
||||
|
||||
// Build and config files
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'makefile':
|
||||
return 'makefile';
|
||||
case 'gradle':
|
||||
return 'groovy';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
|
||||
// Markdown
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return 'markdown';
|
||||
|
||||
// Plain text and logs
|
||||
case 'txt':
|
||||
case 'log':
|
||||
case 'gitignore':
|
||||
case 'gitattributes':
|
||||
case 'editorconfig':
|
||||
case 'prettierrc':
|
||||
default:
|
||||
return 'plaintext';
|
||||
}
|
||||
@@ -143,26 +241,30 @@ export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, on
|
||||
</div>
|
||||
) : (
|
||||
isMarkdown ? (
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
preview={truncated ? 'preview' : 'live'}
|
||||
/>
|
||||
<Suspense fallback={<Spin style={{ marginTop: 24 }} />}>
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
preview={truncated ? 'preview' : 'live'}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Editor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
options={{
|
||||
readOnly: truncated,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Spin style={{ marginTop: 24 }} />}>
|
||||
<MonacoEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
options={{
|
||||
readOnly: truncated,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
</Content>
|
||||
|
||||
@@ -4,13 +4,33 @@ import { TextEditorApp } from './TextEditor.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'text-editor',
|
||||
name: '文本编辑器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
// Supports common text and markdown formats
|
||||
return ['txt', 'md', 'markdown', 'json', 'yaml', 'yml', 'xml', 'html', 'css', 'js', 'ts', 'py', 'sh', 'log'].includes(ext);
|
||||
// Supports common text and code formats
|
||||
return [
|
||||
// Text formats
|
||||
'txt', 'md', 'markdown', 'log',
|
||||
// Data formats
|
||||
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'cfg', 'conf',
|
||||
// Web technologies
|
||||
'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'jsx', 'ts', 'tsx', 'vue',
|
||||
// Programming languages
|
||||
'py', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx',
|
||||
'php', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'clj', 'cljs',
|
||||
'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm',
|
||||
// Database
|
||||
'sql',
|
||||
// Shell and scripts
|
||||
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
// Build and config files
|
||||
'dockerfile', 'makefile', 'gradle', 'cmake',
|
||||
// Other common text files
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'
|
||||
].includes(ext);
|
||||
},
|
||||
component: TextEditorApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 120, y: 80 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { VideoPlayerApp } from './VideoPlayer.tsx';
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'video-player',
|
||||
name: '视频播放器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:video.svg',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
@@ -12,4 +13,4 @@ export const descriptor: AppDescriptor = {
|
||||
component: VideoPlayerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 960, height: 600, x: 180, y: 120 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ function registerPluginAsApp(p: PluginItem) {
|
||||
name: p.name || `插件 ${p.id}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
iconUrl: p.icon || undefined,
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
defaultMaximized: p.default_maximized || undefined,
|
||||
@@ -88,6 +89,7 @@ export async function reloadPluginApps() {
|
||||
existing.name = p.name || `插件 ${p.id}`;
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
existing.iconUrl = p.icon || existing.iconUrl;
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AppDescriptor {
|
||||
name: string;
|
||||
supported: (entry: VfsEntry) => boolean;
|
||||
component: React.ComponentType<AppComponentProps>;
|
||||
iconUrl?: string;
|
||||
default?: boolean;
|
||||
defaultMaximized?: boolean;
|
||||
/**
|
||||
|
||||
143
web/src/components/PathSelectorModal.tsx
Normal file
143
web/src/components/PathSelectorModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
|
||||
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../api/client';
|
||||
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
|
||||
|
||||
export type PathSelectorMode = 'directory' | 'file' | 'any';
|
||||
|
||||
interface PathSelectorModalProps {
|
||||
open: boolean;
|
||||
mode?: PathSelectorMode;
|
||||
initialPath?: string;
|
||||
onOk: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
const s = ('/' + p).replace(/\/+/, '/');
|
||||
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const base = normalizePath(dir);
|
||||
if (base === '/') return `/${name}`;
|
||||
return `${base}/${name}`.replace(/\/+/, '/');
|
||||
}
|
||||
|
||||
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [path, setPath] = useState<string>(normalizePath(initialPath));
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (mode === 'file') return t('Select File');
|
||||
if (mode === 'any') return t('Select Path');
|
||||
return t('Select Folder');
|
||||
}, [mode, t]);
|
||||
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
|
||||
setEntries(listing.entries);
|
||||
setPath(listing.path || p);
|
||||
setSelected(null);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(normalizePath(initialPath));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialPath]);
|
||||
|
||||
const canOk = useMemo(() => {
|
||||
if (mode === 'file') return !!selected;
|
||||
return true;
|
||||
}, [mode, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (mode === 'directory') {
|
||||
onOk(normalizePath(path));
|
||||
return;
|
||||
}
|
||||
if (mode === 'file') {
|
||||
if (!selected) {
|
||||
message.warning(t('Please select a file'));
|
||||
return;
|
||||
}
|
||||
onOk(joinPath(path, selected));
|
||||
return;
|
||||
}
|
||||
// any
|
||||
if (selected) onOk(joinPath(path, selected));
|
||||
else onOk(normalizePath(path));
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const cur = normalizePath(path);
|
||||
if (cur === '/') return;
|
||||
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
load(parent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !canOk }}
|
||||
width={720}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
|
||||
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
|
||||
<Input value={path} readOnly />
|
||||
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
|
||||
{mode !== 'file' && (
|
||||
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
bordered
|
||||
loading={loading}
|
||||
dataSource={entries}
|
||||
style={{ maxHeight: 420, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = selected === item.name && !item.is_dir;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
if (item.is_dir) {
|
||||
load(joinPath(path, item.name));
|
||||
} else {
|
||||
setSelected((prev) => (prev === item.name ? null : item.name));
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
|
||||
>
|
||||
<Space>
|
||||
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
|
||||
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default PathSelectorModal;
|
||||
|
||||
122
web/src/components/ProfileModal.tsx
Normal file
122
web/src/components/ProfileModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Modal, Form, Input, message, Collapse } from 'antd';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface ProfileModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ProfileModal = memo(function ProfileModal({ open, onClose }: ProfileModalProps) {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
form.setFieldsValue({
|
||||
username: user.username || '',
|
||||
full_name: user.full_name || '',
|
||||
email: user.email || '',
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
} else if (!open) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, user, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload: any = {};
|
||||
if (values.full_name !== (user?.full_name || '')) payload.full_name = values.full_name || null;
|
||||
if (values.email !== (user?.email || '')) payload.email = values.email || null;
|
||||
if (values.old_password || values.new_password) {
|
||||
payload.old_password = values.old_password || '';
|
||||
payload.new_password = values.new_password || '';
|
||||
}
|
||||
setLoading(true);
|
||||
await authApi.updateMe(payload);
|
||||
await refreshUser();
|
||||
message.success(t('Saved successfully'));
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Save failed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Profile')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('Save')}
|
||||
cancelText={t('Cancel')}
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="username" label={t('Username')}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_name" label={t('Full Name')}>
|
||||
<Input placeholder={t('Full Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label={t('Email')}>
|
||||
<Input placeholder={t('Email')} type="email" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'pwd',
|
||||
label: t('Change Password'),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="old_password"
|
||||
label={t('Old Password')}
|
||||
dependencies={["new_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const newPwd = form.getFieldValue('new_password');
|
||||
if ((value && !newPwd) || (!value && newPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('Old Password')} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label={t('New Password')}
|
||||
dependencies={["old_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const oldPwd = form.getFieldValue('old_password');
|
||||
if ((value && !oldPwd) || (!value && oldPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('New Password')} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user